From eb2a9a8beff27991b3718f9d879e6471977eb7de Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 30 Apr 2026 22:04:01 +0800 Subject: [PATCH 001/129] feat(installer): add standalone archive installation --- .github/workflows/release.yml | 48 + .gitignore | 6 +- .../2026-04-30-standalone-installer-design.md | 141 +++ .../2026-04-30-standalone-installer-plan.md | 124 ++ ...26-04-30-standalone-installer-test-plan.md | 87 ++ README.md | 15 +- docs/users/overview.md | 12 +- docs/users/quickstart.md | 14 +- package.json | 1 + scripts/create-standalone-package.js | 376 ++++++ scripts/installation/INSTALLATION_GUIDE.md | 315 +++-- .../installation/install-qwen-with-source.bat | 738 ++++++++---- .../installation/install-qwen-with-source.sh | 1026 ++++++++++------- scripts/tests/install-script.test.js | 218 ++++ 14 files changed, 2312 insertions(+), 809 deletions(-) create mode 100644 .qwen/design/2026-04-30-standalone-installer-design.md create mode 100644 .qwen/design/2026-04-30-standalone-installer-plan.md create mode 100644 .qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md create mode 100644 scripts/create-standalone-package.js create mode 100644 scripts/tests/install-script.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 367a3834fca..bb7f897ddff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -379,6 +379,52 @@ jobs: npm run bundle npm run prepare:package + - name: 'Build Standalone Archives' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: |- + set -euo pipefail + + NODE_VERSION="$(node -p "process.versions.node")" + RUNTIME_DIR="${RUNNER_TEMP}/qwen-node-runtime" + NODE_DIST_URL="https://nodejs.org/dist/v${NODE_VERSION}" + mkdir -p "${RUNTIME_DIR}" + curl -fsSL "${NODE_DIST_URL}/SHASUMS256.txt" -o "${RUNTIME_DIR}/SHASUMS256.txt" + + download_node() { + local qwen_target="$1" + local node_target="$2" + local extension="$3" + local archive="${RUNTIME_DIR}/node-v${NODE_VERSION}-${node_target}.${extension}" + local archive_name + archive_name="$(basename "${archive}")" + local checksum_line + local url="${NODE_DIST_URL}/${archive_name}" + + echo "Downloading ${url}" + curl -fsSL "${url}" -o "${archive}" + checksum_line="$(awk -v name="${archive_name}" '$2 == name { print }' "${RUNTIME_DIR}/SHASUMS256.txt")" + if [[ -z "${checksum_line}" ]]; then + echo "::error::Node.js SHASUMS256.txt does not list ${archive_name}" + exit 1 + fi + printf '%s\n' "${checksum_line}" | (cd "${RUNTIME_DIR}" && sha256sum -c -) + npm run package:standalone -- \ + --target "${qwen_target}" \ + --node-archive "${archive}" \ + --out-dir dist/standalone \ + --version "${RELEASE_VERSION}" + } + + download_node darwin-arm64 darwin-arm64 tar.gz + download_node darwin-x64 darwin-x64 tar.gz + download_node linux-arm64 linux-arm64 tar.xz + download_node linux-x64 linux-x64 tar.xz + download_node win-x64 win-x64 zip + + ls -la dist/standalone + cat dist/standalone/SHA256SUMS + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -411,6 +457,8 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ + dist/standalone/qwen-code-* \ + dist/standalone/SHA256SUMS \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "${PREVIOUS_RELEASE_TAG}" \ diff --git a/.gitignore b/.gitignore index 2dae5710a42..0a0b02ff50b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ CLAUDE.md !.qwen/skills/** !.qwen/agents/ !.qwen/agents/** +!.qwen/design/ +!.qwen/design/** +!.qwen/e2e-tests/ +!.qwen/e2e-tests/** # OS metadata .DS_Store @@ -89,4 +93,4 @@ storybook-static # Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js) packages/core/src/skills/bundled/qc-helper/docs -tmp/ \ No newline at end of file +tmp/ diff --git a/.qwen/design/2026-04-30-standalone-installer-design.md b/.qwen/design/2026-04-30-standalone-installer-design.md new file mode 100644 index 00000000000..ba49364fe21 --- /dev/null +++ b/.qwen/design/2026-04-30-standalone-installer-design.md @@ -0,0 +1,141 @@ +# Standalone Installer Design + +## Problem + +The current one-line installer installs Qwen Code through npm. That keeps the +script small, but it still requires users to bring a working Node.js and npm +environment. This is fragile for less technical users, and it does not support +offline or controlled enterprise installs well. + +Qwen Code already publishes a bundled `dist/cli.js` to GitHub Releases, but the +asset still needs a local Node.js runtime. To remove that dependency, releases +need standalone archives that bundle the Qwen CLI with a private Node.js +runtime and a small launcher. + +## Goals + +- Prefer standalone release archives when they are available. +- Fall back to npm when no standalone asset exists for the requested platform. +- Keep npm installation available explicitly with `--method npm`. +- Support fully offline installs with `--archive /path/to/archive`. +- Support GitHub Releases and an Aliyun OSS/CDN mirror with the same artifact + names and checksums. +- Avoid modifying npm config, shell profiles, or user PATH permanently. +- Never start `qwen` automatically from the installer. + +## Non-Goals + +- Build a single native executable in this change. +- Add geolocation-based mirror selection. +- Install Node.js, NVM, or system packages on behalf of the user. +- Solve code signing or notarization in the first implementation. +- Guarantee parity for optional native modules such as `node-pty` and clipboard + packages. The CLI already degrades when these optional modules are absent; + a later release job can add target-specific `node_modules` if that parity is + required. + +## Artifact Format + +Each release can publish these assets: + +- `qwen-code-darwin-arm64.tar.gz` +- `qwen-code-darwin-x64.tar.gz` +- `qwen-code-linux-arm64.tar.gz` +- `qwen-code-linux-x64.tar.gz` +- `qwen-code-win-x64.zip` +- `SHA256SUMS` + +The asset names intentionally do not include the version. This allows the +installer to use GitHub's `releases/latest/download/` URL without an API +call. Versioned installation is still supported by switching the base URL to +`releases/download/vX.Y.Z`. + +Archive layout: + +```text +qwen-code/ + bin/qwen + bin/qwen.cmd + lib/cli.js + node/... + package.json + README.md + LICENSE + manifest.json +``` + +The Unix launcher executes `node/bin/node ../lib/cli.js`. The Windows launcher +executes `node/node.exe ..\lib\cli.js`. Bundling the full Node distribution is +larger than a single executable, but it is predictable and works with the +existing ESM bundle without requiring a user-managed Node.js installation. + +## Installer Behavior + +`--method detect` is the default: + +1. If `--archive` is provided, install that local archive. +2. Detect OS and architecture. +3. Build an archive URL from the selected mirror/base URL. +4. If the archive exists, download it, verify `SHA256SUMS`, extract it into the + user install directory, and expose `qwen`. +5. If the archive does not exist, fall back to npm. + +`--method standalone` follows the same standalone path, but a missing or failed +standalone asset is fatal. + +`--method npm` skips standalone logic and runs npm installation after checking +that Node.js 20+ and npm are available. + +## Install Locations + +Unix: + +- Runtime: `$HOME/.local/lib/qwen-code` +- Command shim: `$HOME/.local/bin/qwen` + +Windows: + +- Runtime: `%LOCALAPPDATA%\qwen-code\qwen-code` +- Command shim: `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` + +The installer may add the command directory to the current process PATH for +verification, but it does not write shell profiles or persistent environment +variables. If the command directory is not on PATH, the installer prints the +exact directory to add. + +## Distribution Sources + +GitHub is the canonical source: + +```text +https://github.com/QwenLM/qwen-code/releases/latest/download +https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z +``` + +Aliyun OSS/CDN is a mirror: + +```text +https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest +https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/vX.Y.Z +``` + +All mirrors must serve byte-identical artifacts and the same `SHA256SUMS`. + +## Safety + +- Remote standalone installs require checksum verification. +- Local archive installs do not require network access. +- The installer only deletes temporary extraction directories and the previous + managed standalone install directory. +- npm fallback does not change npm prefix, npmrc, or PATH. + +## Verification Strategy + +- Static tests ensure the installer keeps the expected methods and does not + reintroduce Node/NVM installation or automatic `qwen` startup. +- Packaging tests can run against a fake target and fake Node distribution. +- Shell smoke tests run installer branches with fake `curl`, `tar`, `npm`, + `node`, and `qwen`. +- GitHub Actions should later run Linux, macOS, and Windows installer smoke + tests with locally generated archives before enabling standalone as the + public default. diff --git a/.qwen/design/2026-04-30-standalone-installer-plan.md b/.qwen/design/2026-04-30-standalone-installer-plan.md new file mode 100644 index 00000000000..78c4648a4d6 --- /dev/null +++ b/.qwen/design/2026-04-30-standalone-installer-plan.md @@ -0,0 +1,124 @@ +# Standalone Installer Implementation Plan + +**Goal:** Add code-server-style standalone archive distribution with npm fallback. + +**Architecture:** Release builds produce per-platform archives that bundle +`dist/cli.js`, required runtime assets, and a private Node.js runtime. The +installer defaults to `detect`, installs a standalone archive when available, +and falls back to npm otherwise. + +**Tech Stack:** Bash, Windows batch, Node.js release scripting, GitHub Actions, +Vitest static/smoke tests. + +## Task 1: Installer Contract Tests + +**Files:** + +- Modify: `scripts/tests/install-script.test.js` + +**Steps:** + +1. Add tests asserting the Unix installer exposes `--method`, `--mirror`, + `--base-url`, `--archive`, standalone install functions, checksum + verification, and npm fallback. +2. Add tests asserting the Windows installer exposes the same options and uses + PowerShell/CertUtil for archive install and checksum verification. +3. Run `npm run test:scripts`. +4. Confirm the new tests fail before implementation. + +## Task 2: Standalone Package Script + +**Files:** + +- Create: `scripts/create-standalone-package.js` +- Modify: `package.json` + +**Steps:** + +1. Add a Node.js script that accepts `--target`, `--node-archive`, + `--out-dir`, and optional `--version`. +2. Require `dist/cli.js`, `dist/vendor`, `README.md`, and `LICENSE`. +3. Extract a Node.js distribution archive into a staging directory. +4. Create `qwen-code/bin/qwen`, `qwen-code/bin/qwen.cmd`, + `qwen-code/lib/cli.js`, copied runtime assets, and `manifest.json`. +5. Emit `qwen-code-.tar.gz` for Unix targets and + `qwen-code-.zip` for Windows targets. +6. Write/update `SHA256SUMS`. +7. Add `npm run package:standalone`. +8. Add focused script tests where practical. + +## Task 3: Unix Installer Standalone Flow + +**Files:** + +- Modify: `scripts/installation/install-qwen-with-source.sh` + +**Steps:** + +1. Add argument parsing for `--method`, `--mirror`, `--base-url`, `--archive`, + and `--version`. +2. Add target detection for supported OS/arch combinations. +3. Add URL construction for GitHub and Aliyun mirrors. +4. Add archive availability check for detect mode. +5. Add download, checksum verification, extraction, and shim creation. +6. Keep npm installation as fallback and as explicit `--method npm`. +7. Keep source tracking and final instructions. + +## Task 4: Windows Installer Standalone Flow + +**Files:** + +- Modify: `scripts/installation/install-qwen-with-source.bat` + +**Steps:** + +1. Add argument parsing for `--method`, `--mirror`, `--base-url`, `--archive`, + and `--version`. +2. Add target detection for `win-x64`. +3. Add archive download with PowerShell. +4. Add checksum verification with `certutil`. +5. Add archive extraction with PowerShell `Expand-Archive`. +6. Install to `%LOCALAPPDATA%\qwen-code\qwen-code` and expose + `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd`. +7. Keep npm fallback and source tracking. + +## Task 5: Release Workflow + +**Files:** + +- Modify: `.github/workflows/release.yml` + +**Steps:** + +1. After `npm run prepare:package`, download supported Node.js runtime + archives. +2. Run `npm run package:standalone -- --target ...` for each supported target. +3. Upload `dist/standalone/qwen-code-*` and `dist/standalone/SHA256SUMS` to the + GitHub Release alongside `dist/cli.js`. + +## Task 6: Documentation + +**Files:** + +- Modify: `README.md` +- Modify: `docs/users/overview.md` +- Modify: `docs/users/quickstart.md` +- Modify: `scripts/installation/INSTALLATION_GUIDE.md` +- Create: `.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md` + +**Steps:** + +1. Document install methods and mirror choices. +2. Document offline archive installation. +3. Document release artifact names. +4. Document platform verification plan. + +## Task 7: Verification + +**Commands:** + +- `npm run test:scripts` +- `npx prettier --check README.md docs/users/quickstart.md docs/users/overview.md scripts/installation/INSTALLATION_GUIDE.md .qwen/design/2026-04-30-standalone-installer-design.md .qwen/design/2026-04-30-standalone-installer-plan.md .qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md scripts/tests/install-script.test.js` +- `bash -n scripts/installation/install-qwen-with-source.sh` +- `git diff --check` +- Local fake-runtime installer smoke for npm and standalone paths. diff --git a/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md b/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md new file mode 100644 index 00000000000..edcf7ec8005 --- /dev/null +++ b/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md @@ -0,0 +1,87 @@ +# Standalone Installer Test Plan + +## Scope + +This plan verifies the one-line installer after standalone archive support is +added. It covers installer behavior, artifact packaging, and fallback behavior +without requiring real global npm writes. + +## Local Smoke Matrix + +Run from the repository root after `npm run bundle && npm run prepare:package`. + +### Unix Standalone Archive + +1. Build a standalone archive for the current target with a local Node.js + archive. +2. Create a temporary `HOME`. +3. Run: + + ```bash + HOME="$tmp_home" bash scripts/installation/install-qwen-with-source.sh \ + --method standalone \ + --archive dist/standalone/qwen-code-.tar.gz \ + --source github + ``` + +4. Expected: + - `$tmp_home/.local/lib/qwen-code` exists. + - `$tmp_home/.local/bin/qwen` exists and is executable. + - `$tmp_home/.qwen/source.json` contains `{"source":"github"}`. + - Installer does not write `.bashrc`, `.zshrc`, `.npmrc`, or npm prefix. + +### Unix npm Fallback + +1. Put fake `node`, `npm`, and `qwen` commands in a temporary PATH. +2. Run detect mode with a fake base URL whose archive does not exist. +3. Expected: + - npm is invoked with `install -g @qwen-code/qwen-code@latest`. + - `qwen` is not executed interactively. + +### Unix Standalone Failure + +1. Run `--method standalone` with a fake base URL whose archive does not exist. +2. Expected: + - installer exits non-zero. + - npm is not invoked. + +### Windows Standalone Archive + +Run on `windows-latest` or a Windows VM: + +```cmd +set USERPROFILE=%TEMP%\qwen-user +set LOCALAPPDATA=%TEMP%\qwen-local +scripts\installation\install-qwen-with-source.bat --method standalone --archive dist\standalone\qwen-code-win-x64.zip --source github +``` + +Expected: + +- `%LOCALAPPDATA%\qwen-code\qwen-code` exists. +- `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` exists. +- `%USERPROFILE%\.qwen\source.json` exists. +- The script does not require Administrator. + +## CI Matrix + +- `ubuntu-latest`: package + install Linux x64 archive. +- `macos-latest`: package + install Darwin arm64/x64 depending on runner. +- `windows-latest`: package + install Windows x64 archive. + +## Manual Release Verification + +For a release candidate: + +1. Download `SHA256SUMS` and all archives from GitHub Release. +2. Verify checksums locally. +3. Sync the same files to OSS/CDN. +4. Download one archive from GitHub and one from OSS/CDN. +5. Confirm byte-identical checksums. +6. Run installer with: + + ```bash + --mirror github --method standalone + --mirror aliyun --method standalone + --method npm + --archive /path/to/archive + ``` diff --git a/README.md b/README.md index 5358f9d0e1f..edae546ef55 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,18 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series ### Quick Install (Recommended) +The installer uses a standalone Qwen Code archive when one is available for +your platform, so the default path does not require a preinstalled Node.js +runtime. If a standalone archive is not available, it falls back to npm and then +requires Node.js 20 or later with npm on PATH. + #### Linux / macOS ```bash bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" ``` -#### Windows (Run as Administrator) +#### Windows Works in both Command Prompt and PowerShell: @@ -57,13 +62,17 @@ Works in both Command Prompt and PowerShell: 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')" ``` -> **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. +> **Note**: It's recommended to restart your terminal after installation if +> `qwen` is not immediately available on PATH. For offline installation, download +> a release archive such as `qwen-code-linux-x64.tar.gz` or +> `qwen-code-win-x64.zip`, then run the installer with `--archive PATH`. ### Manual Installation #### Prerequisites -Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). +Manual npm installation requires Node.js 20 or later. Download it from +[nodejs.org](https://nodejs.org/en/download). #### NPM diff --git a/docs/users/overview.md b/docs/users/overview.md index 55506fefad5..2bfee0b7a18 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -9,13 +9,17 @@ ### 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 +available on PATH. + **Linux / macOS** ```sh curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.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')" @@ -23,7 +27,11 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > [!note] > -> It's recommended to restart your terminal after installation to ensure environment variables take effect. If the installation fails, please refer to [Manual Installation](./quickstart#manual-installation) in the Quickstart guide. +> It's recommended to restart your terminal after installation if `qwen` is not +> immediately available on PATH. If the installation fails, please refer to +> [Manual Installation](./quickstart#manual-installation) in the Quickstart +> guide. For offline installation, download a release archive and run the +> installer with `--archive PATH`. ### Start using Qwen Code: diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index ee3ff6081ab..10840cbd4fa 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -12,6 +12,10 @@ Make sure you have: - A code project to work with - An API key from Alibaba Cloud Model Studio ([Beijing](https://bailian.console.aliyun.com/) / [intl](https://modelstudio.console.alibabacloud.com/)), or an Alibaba Cloud Coding Plan ([Beijing](https://bailian.console.aliyun.com/cn-beijing/?tab=coding-plan#/efm/coding-plan-index) / [intl](https://modelstudio.console.alibabacloud.com/?tab=coding-plan#/efm/coding-plan-index)) subscription +The recommended installer uses a standalone archive when one is available for +your platform. If it falls back to npm, you will need Node.js 20 or later with +npm available on PATH. + ## Step 1: Install Qwen Code To install Qwen Code, use one of the following methods: @@ -24,7 +28,7 @@ To install Qwen Code, use one of the following methods: curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.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')" @@ -32,13 +36,17 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > [!note] > -> It's recommended to restart your terminal after installation to ensure environment variables take effect. +> It's recommended to restart your terminal after installation if `qwen` is not +> immediately available on PATH. For offline installation, download a release +> archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip`, then +> run the installer with `--archive PATH`. ### Manual Installation **Prerequisites** -Make sure you have Node.js 20 or later installed. Download it from [nodejs.org](https://nodejs.org/en/download). +Manual npm installation requires Node.js 20 or later. Download it from +[nodejs.org](https://nodejs.org/en/download). **NPM** diff --git a/package.json b/package.json index 8ab445e3505..984d96e72e4 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", + "package:standalone": "node scripts/create-standalone-package.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js new file mode 100644 index 00000000000..6da9ef6dbb4 --- /dev/null +++ b/scripts/create-standalone-package.js @@ -0,0 +1,376 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); +const distDir = path.join(rootDir, 'dist'); + +const TARGETS = new Map([ + [ + 'darwin-arm64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + [ + 'darwin-x64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + [ + 'linux-arm64', + { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }, + ], + ['linux-x64', { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }], + ['win-x64', { outputExtension: 'zip', nodeExecutable: ['node.exe'] }], +]); + +const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs']; +const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; + +main(); + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printUsage(); + return; + } + + const target = args.target; + if (!target || !TARGETS.has(target)) { + fail(`--target must be one of: ${Array.from(TARGETS.keys()).join(', ')}`); + } + + if (!args.nodeArchive) { + fail('--node-archive is required'); + } + + const nodeArchive = path.resolve(args.nodeArchive); + if (!fs.existsSync(nodeArchive)) { + fail(`Node.js archive not found: ${nodeArchive}`); + } + + assertRequiredInputs(); + + const version = args.version || readPackageVersion(); + const outDir = path.resolve(args.outDir || path.join(distDir, 'standalone')); + fs.mkdirSync(outDir, { recursive: true }); + + const targetConfig = TARGETS.get(target); + const outputName = `qwen-code-${target}.${targetConfig.outputExtension}`; + const outputPath = path.join(outDir, outputName); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-standalone-')); + + try { + const packageRoot = path.join(tempRoot, 'qwen-code'); + const runtimeExtractDir = path.join(tempRoot, 'runtime'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.mkdirSync(runtimeExtractDir, { recursive: true }); + + copyRuntimeAssets(packageRoot); + extractNodeArchive(nodeArchive, runtimeExtractDir); + const nodeDir = path.join(packageRoot, 'node'); + copyExtractedNode(runtimeExtractDir, nodeDir); + validateNodeRuntime(target, nodeDir); + writeShims(packageRoot); + writeManifest(packageRoot, { + version, + target, + nodeArchive: path.basename(nodeArchive), + }); + + if (fs.existsSync(outputPath)) { + fs.rmSync(outputPath, { force: true }); + } + createArchive(targetConfig.outputExtension, outputPath, tempRoot); + writeSha256Sums(outDir); + + console.log(`Created ${path.relative(rootDir, outputPath)}`); + console.log( + `Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + nodeArchive: undefined, + target: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--target': + args.target = readOptionValue(argv, index, arg); + index += 1; + break; + case '--node-archive': + args.nodeArchive = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function printUsage() { + console.log(`Qwen Code standalone package builder + +Usage: + npm run package:standalone -- --target TARGET --node-archive PATH [OPTIONS] + +Options: + --target TARGET One of: ${Array.from(TARGETS.keys()).join(', ')} + --node-archive PATH Downloaded Node.js runtime archive. + --out-dir DIR Output directory. Defaults to dist/standalone. + --version VERSION Qwen Code version. Defaults to package.json version. + -h, --help Show this help message.`); +} + +function assertRequiredInputs() { + if (!fs.existsSync(distDir)) { + fail('dist/ directory not found. Run "npm run bundle" first.'); + } + + for (const relativePath of DIST_REQUIRED_PATHS) { + const fullPath = path.join(distDir, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`Required dist asset missing: ${fullPath}`); + } + } + + for (const relativePath of ROOT_REQUIRED_PATHS) { + const fullPath = path.join(rootDir, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`Required repository file missing: ${fullPath}`); + } + } +} + +function readPackageVersion() { + const packageJsonPath = path.join(rootDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function copyRuntimeAssets(packageRoot) { + const libDir = path.join(packageRoot, 'lib'); + fs.mkdirSync(libDir, { recursive: true }); + + for (const entry of fs.readdirSync(distDir)) { + if (entry === 'standalone') { + continue; + } + fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), { + recursive: true, + verbatimSymlinks: true, + }); + } + + for (const fileName of ROOT_REQUIRED_PATHS) { + fs.copyFileSync( + path.join(rootDir, fileName), + path.join(packageRoot, fileName), + ); + } + + const distPackageJson = path.join(distDir, 'package.json'); + if (fs.existsSync(distPackageJson)) { + fs.copyFileSync(distPackageJson, path.join(packageRoot, 'package.json')); + } else { + fs.copyFileSync( + path.join(rootDir, 'package.json'), + path.join(packageRoot, 'package.json'), + ); + } +} + +function extractNodeArchive(nodeArchive, extractDir) { + if (nodeArchive.endsWith('.zip')) { + run('unzip', ['-q', nodeArchive, '-d', extractDir]); + return; + } + + if ( + nodeArchive.endsWith('.tar.gz') || + nodeArchive.endsWith('.tgz') || + nodeArchive.endsWith('.tar.xz') + ) { + run('tar', ['-xf', nodeArchive, '-C', extractDir]); + return; + } + + fail( + `Unsupported Node.js archive format: ${nodeArchive}. Expected .zip, .tar.gz, .tgz, or .tar.xz.`, + ); +} + +function copyExtractedNode(extractDir, nodeDir) { + const entries = fs + .readdirSync(extractDir) + .filter((entry) => entry !== '.DS_Store'); + if (entries.length === 0) { + fail('Node.js archive did not contain any files.'); + } + + const sourceRoot = + entries.length === 1 && + fs.statSync(path.join(extractDir, entries[0])).isDirectory() + ? path.join(extractDir, entries[0]) + : extractDir; + + fs.cpSync(sourceRoot, nodeDir, { + recursive: true, + verbatimSymlinks: true, + }); +} + +function validateNodeRuntime(target, nodeDir) { + const targetConfig = TARGETS.get(target); + const executablePath = path.join(nodeDir, ...targetConfig.nodeExecutable); + const displayPath = targetConfig.nodeExecutable.join('/'); + + if (!fs.existsSync(executablePath)) { + fail(`Node.js runtime for ${target} must contain ${displayPath}.`); + } + + if (target !== 'win-x64') { + const mode = fs.statSync(executablePath).mode; + if ((mode & 0o111) === 0) { + fail( + `Node.js runtime for ${target} must provide executable ${displayPath}.`, + ); + } + } +} + +function writeShims(packageRoot) { + const binDir = path.join(packageRoot, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const unixShim = `#!/usr/bin/env sh +set -e +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +exec "$ROOT/node/bin/node" "$ROOT/lib/cli.js" "$@" +`; + const unixShimPath = path.join(binDir, 'qwen'); + fs.writeFileSync(unixShimPath, unixShim); + fs.chmodSync(unixShimPath, 0o755); + + const windowsShim = `@echo off +setlocal +set "ROOT=%~dp0.." +"%ROOT%\\node\\node.exe" "%ROOT%\\lib\\cli.js" %* +`; + fs.writeFileSync(path.join(binDir, 'qwen.cmd'), windowsShim); +} + +function writeManifest(packageRoot, manifest) { + const manifestPath = path.join(packageRoot, 'manifest.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + name: '@qwen-code/qwen-code', + version: manifest.version, + target: manifest.target, + nodeArchive: manifest.nodeArchive, + createdAt: new Date().toISOString(), + }, + null, + 2, + ) + '\n', + ); +} + +function createArchive(outputExtension, outputPath, cwd) { + if (outputExtension === 'zip') { + run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); + return; + } + + run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); +} + +function writeSha256Sums(outDir) { + const entries = fs + .readdirSync(outDir) + .filter( + (entry) => + entry.startsWith('qwen-code-') && + (entry.endsWith('.tar.gz') || entry.endsWith('.zip')), + ) + .sort(); + + const lines = entries.map((entry) => { + const filePath = path.join(outDir, entry); + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); + return `${hash} ${entry}`; + }); + + fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +function run(command, args, options = {}) { + try { + execFileSync(command, args, { + stdio: 'inherit', + ...options, + }); + } catch (error) { + const detail = + error && typeof error === 'object' && 'message' in error + ? `: ${error.message}` + : ''; + fail(`Command failed: ${command} ${args.join(' ')}${detail}`); + } +} + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 8a41bc47a9d..2df19c22ba0 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -1,148 +1,177 @@ # Installation Guide for Qwen Code with Source Tracking -This guide describes how to install Node.js and Qwen Code with source information tracking. +This guide describes the source-tracking installation scripts for Qwen Code. +The scripts prefer standalone release archives and can fall back to npm when a +standalone archive is not available. ## Overview -The installation scripts automate the process of installing Node.js (if not present or below version 20) and Qwen Code, while capturing and storing the installation source information for analytics and tracking purposes. +The installers are intentionally lightweight: -## Installation Scripts - -We provide platform-specific installation scripts: - -- **Linux/macOS**: `install-qwen-with-source.sh` -- **Windows**: `install-qwen-with-source.bat` +- They try a standalone archive first by default. +- They do not install Node.js, NVM, or any other Node version manager. +- They do not edit npm config or shell profiles. +- They do not start `qwen` automatically after installation. +- They store source information in `~/.qwen/source.json` or + `%USERPROFILE%\.qwen\source.json` when `--source` is provided. -## Linux/macOS Installation +Standalone archives include a private Node.js runtime, so users do not need a +local Node.js installation on the standalone path. Node.js 20 or newer and npm +are only required when the installer falls back to npm or when +`--method npm` is used. -### Script: install-qwen-with-source.sh +## Installation Scripts -#### Features: +- Linux/macOS: `install-qwen-with-source.sh` +- Windows: `install-qwen-with-source.bat` -- Checks for existing Node.js installation and version -- Installs Node.js 20+ if needed using NVM -- Installs Qwen Code globally with source information -- Stores the source information in `~/.qwen/source.json` +## Release Artifacts -#### Usage: +GitHub releases publish these standalone archives: -```bash -# Install with a specific source -sh install-qwen-with-source.sh --source github +- `qwen-code-darwin-arm64.tar.gz` +- `qwen-code-darwin-x64.tar.gz` +- `qwen-code-linux-arm64.tar.gz` +- `qwen-code-linux-x64.tar.gz` +- `qwen-code-win-x64.zip` +- `SHA256SUMS` -# Install with internal source -sh install-qwen-with-source.sh -s internal +Archive layout: -# Show help -sh install-qwen-with-source.sh --help +```text +qwen-code/ + bin/qwen + bin/qwen.cmd + lib/cli.js + node/ + package.json + README.md + LICENSE + manifest.json ``` -#### Supported Source Values: +## Install Methods -- `github` - Installed from GitHub repository -- `npm` - Installed from npm registry -- `internal` - Internal installation -- `local-build` - Local build installation +The default method is `detect`: -#### How it Works: +1. Detect the current platform. +2. Try to download and install the matching standalone archive. +3. Verify the archive with `SHA256SUMS` when available. +4. Fall back to npm if the standalone archive is not available. -1. The script accepts a `--source` parameter to specify where Qwen Code is being installed from -2. It installs Node.js if needed -3. It installs Qwen Code globally -4. It creates `~/.qwen/source.json` with the specified source information - -#### Important Notes: - -⚠️ **After installation, you need to restart your terminal or run:** +You can force a method: ```bash -source ~/.bashrc # For bash users -# or -source ~/.zshrc # For zsh users +bash install-qwen-with-source.sh --method standalone +bash install-qwen-with-source.sh --method npm ``` -This is required to load the newly installed Node.js and Qwen Code into your PATH. +```bat +install-qwen-with-source.bat --method standalone +install-qwen-with-source.bat --method npm +``` -#### Prerequisites: +## Optional Native Modules -- curl (for NVM installation and script download) -- bash-compatible shell +The standalone archives bundle Qwen Code and a private Node.js runtime. They do +not currently install npm optional native modules such as `node-pty` and +`@teddyzhu/clipboard`. Qwen Code is designed to degrade when these optional +modules are absent, but terminal pty behavior and clipboard image support may +not be identical to an npm installation. -## Windows Installation +Use `--method npm` if you specifically need npm to resolve optional native +modules for the current machine. -### Script: install-qwen-with-source.bat +## Linux/macOS Usage -#### Features: +```bash +# Default: standalone archive with npm fallback +bash install-qwen-with-source.sh -- Checks for existing Node.js installation and version (requires version 18+) -- Automatically downloads and installs Node.js 24 LTS if not present or version is too low -- Installs Qwen Code globally with source information -- Stores the source information in `%USERPROFILE%\.qwen\source.json` +# Record a source value +bash install-qwen-with-source.sh --source github -#### Prerequisites: +# Use npm explicitly +bash install-qwen-with-source.sh --method npm --registry https://registry.npmjs.org -- **PowerShell (Administrator)**: The script must be run in PowerShell with Administrator privileges -- Internet connection for downloading Node.js and Qwen Code +# Use the Aliyun standalone mirror +bash install-qwen-with-source.sh --mirror aliyun -#### Usage: +# Install an offline archive +bash install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz +``` -> ⚠️ **Important**: You must run PowerShell as Administrator to install Node.js and global npm packages. +Standalone installs to: -**Step 1**: Open PowerShell as Administrator +- Runtime: `~/.local/lib/qwen-code` +- Shim: `~/.local/bin/qwen` -- Right-click on PowerShell and select "Run as Administrator" -- Or press `Win + X` and select "Windows PowerShell (Admin)" +Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, +`QWEN_INSTALL_LIB_DIR`, or `QWEN_INSTALL_BIN_DIR` when needed. -**Step 2**: Navigate to the script directory and run: +## Windows Usage -```powershell -# Install with a specific source using --source parameter -./install-qwen-with-source.bat --source github +```bat +REM Default: standalone archive with npm fallback +install-qwen-with-source.bat -# Install with short parameter -./install-qwen-with-source.bat -s internal +REM Record a source value +install-qwen-with-source.bat --source github -# Use default source (unknown) -./install-qwen-with-source.bat +REM Use npm explicitly +install-qwen-with-source.bat --method npm --registry https://registry.npmjs.org + +REM Use the Aliyun standalone mirror +install-qwen-with-source.bat --mirror aliyun + +REM Install an offline archive +install-qwen-with-source.bat --archive qwen-code-win-x64.zip ``` -#### Supported Source Values: +Standalone installs to: -- `github` - Installed from GitHub repository -- `npm` - Installed from npm registry -- `internal` - Internal installation -- `local-build` - Local build installation +- Runtime: `%LOCALAPPDATA%\qwen-code\qwen-code` +- Shim: `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` -#### How it Works: +Restart the terminal if `qwen` is not immediately available on PATH. -1. The script accepts a `--source` or `-s` parameter to specify where Qwen Code is being installed from -2. It checks if Node.js is already installed and if the version is 18 or higher -3. If Node.js is not installed or version is too low, it automatically downloads and installs Node.js 24 LTS -4. It installs Qwen Code globally using npm -5. It creates `%USERPROFILE%\.qwen\source.json` with the specified source information +## Mirrors and Overrides -#### Why Administrator Privileges are Required: +Options: -- Installing Node.js requires writing to `C:\Program Files\nodejs` -- Installing global npm packages requires elevated permissions -- Modifying system PATH environment variables requires Administrator access +- `--method detect|standalone|npm` +- `--mirror github|aliyun` +- `--base-url URL` +- `--archive PATH` +- `--version VERSION` +- `--registry REGISTRY` +- `--source SOURCE` -## Installation Source Feature +Environment variables: -### Overview +- `QWEN_INSTALL_METHOD` +- `QWEN_INSTALL_MIRROR` +- `QWEN_INSTALL_BASE_URL` +- `QWEN_INSTALL_ARCHIVE` +- `QWEN_INSTALL_VERSION` +- `QWEN_NPM_REGISTRY` -This feature implements the ability to capture and store the installation source of the Qwen Code package. The source information is used for analytics and tracking purposes. +Use `--base-url` for private mirrors. The URL must contain +`qwen-code-` archives and `SHA256SUMS` in the same directory. -### Storage Location +## Supported Source Values -The installation source is stored in a separate file at: +The source value may only contain letters, numbers, dot, underscore, and dash. +Common values are: -- **Unix/Linux/macOS**: `~/.qwen/source.json` -- **Windows**: `%USERPROFILE%\.qwen\source.json` (equivalent to `C:\Users\{username}\.qwen\source.json`) +- `github` +- `npm` +- `internal` +- `local-build` -### File Format +## Source Tracking -The `source.json` file contains: +When `--source` or `-s` is provided, the installer writes: ```json { @@ -150,53 +179,23 @@ The `source.json` file contains: } ``` -### How the Source Information is Used - -1. **Telemetry Tracking**: The source information is included in RUM (Real User Monitoring) telemetry logs -2. **Analytics**: Helps understand how users are discovering and installing Qwen Code -3. **Distribution Analysis**: Tracks which distribution channels are most popular +Locations: -### Technical Implementation +- Linux/macOS: `~/.qwen/source.json` +- Windows: `%USERPROFILE%\.qwen\source.json` -- The source information is stored as a separate JSON file -- The `QwenLogger` class reads this file during telemetry initialization -- The source is included in the `app.channel` field of the RUM payload -- The implementation gracefully handles missing files, unknown values, and parsing errors +The telemetry logger reads this file when available. Missing, invalid, or +unreadable source files are ignored. -### Verification +## Manual Installation -After installation and restarting your terminal (or sourcing your shell configuration), you can verify the source information: - -**Linux/macOS:** - -```bash -cat ~/.qwen/source.json -``` - -**Windows:** - -```cmd -type %USERPROFILE%\.qwen\source.json -``` - -## Manual Installation (Without Source Tracking) - -If you prefer not to use the installation scripts or don't want source tracking: - -### Prerequisites - -```bash -# Node.js 20+ -curl -qL https://www.npmjs.com/install.sh | sh -``` - -### NPM Installation +If source tracking is not needed and Node.js 20 or newer is already available: ```bash npm install -g @qwen-code/qwen-code@latest ``` -### Homebrew (macOS, Linux) +Homebrew users can also install Qwen Code with: ```bash brew install qwen-code @@ -204,47 +203,47 @@ brew install qwen-code ## Troubleshooting -### Script Execution Issues +### Standalone Archive Missing -**Linux/macOS:** +In `detect` mode, the installer falls back to npm. In `standalone` mode, install +fails so that automation can detect the missing artifact. -```bash -# Run with sh -sh install-qwen-with-source.sh --source github -``` +### Node.js Missing or Too Old -**Windows (PowerShell as Administrator):** +This only blocks npm installation. Install or activate Node.js 20 or newer, then +rerun the installer with `--method npm` or let `detect` fall back again. -```powershell -# Run the script with --source parameter -./install-qwen-with-source.bat --source github +### npm Missing -# Or with short parameter -./install-qwen-with-source.bat -s github -``` +Install a Node.js distribution that includes npm, then rerun the installer. -### Node.js Installation Issues +### Permission Errors During npm Install -**Linux/macOS:** +The installers do not rewrite npm prefix settings. If global npm installation +fails with a permission error, fix the npm global install location or use a +user-owned Node.js installation, then rerun: -- Ensure NVM is installed: `curl -o- https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh | bash` -- Restart your terminal or run: `source ~/.bashrc` +```bash +npm install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com +``` -**Windows:** +### qwen Is Not on PATH After Installation -- Install NVM for Windows from: https://github.com/coreybutler/nvm-windows/releases -- After installation, run the script again +Restart the terminal first. For standalone installs, add the shim directory: -### Permission Issues +```bash +export PATH="$HOME/.local/bin:$PATH" +``` -You may need administrative privileges for global npm installation: +For npm installs, add npm's global binary directory. On Linux/macOS this is +usually: -- **Linux/macOS**: Use `sudo` with npm -- **Windows**: Run PowerShell as Administrator (required for Node.js installation and global npm packages) +```bash +export PATH="$(npm prefix -g)/bin:$PATH" +``` -## Notes +On Windows standalone installs, add this directory to PATH: -- The scripts require internet access to download Node.js and Qwen Code -- Administrative privileges may be required for global npm installation -- The installation source is stored locally and used for tracking purposes only -- If the source file is missing or invalid, the application continues to work normally +```bat +%LOCALAPPDATA%\qwen-code\bin +``` diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index fe5263e0e29..def861137ed 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -1,304 +1,596 @@ @echo off -REM Script to install Node.js and Qwen Code with source information -REM This script handles the installation process and sets the installation source -REM -REM Usage: install-qwen-with-source.bat --source -REM install-qwen-with-source.bat -s -REM +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. setlocal enabledelayedexpansion set "SOURCE=unknown" +set "METHOD=%QWEN_INSTALL_METHOD%" +set "MIRROR=github" +if not "%QWEN_INSTALL_MIRROR%"=="" set "MIRROR=%QWEN_INSTALL_MIRROR%" +set "BASE_URL=%QWEN_INSTALL_BASE_URL%" +set "ARCHIVE_PATH=%QWEN_INSTALL_ARCHIVE%" +set "VERSION=latest" +if not "%QWEN_INSTALL_VERSION%"=="" set "VERSION=%QWEN_INSTALL_VERSION%" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if not "%QWEN_NPM_REGISTRY%"=="" set "NPM_REGISTRY=%QWEN_NPM_REGISTRY%" +set "INSTALL_BASE=%LOCALAPPDATA%\qwen-code" +set "INSTALL_DIR=%INSTALL_BASE%\qwen-code" +set "INSTALL_BIN_DIR=%INSTALL_BASE%\bin" -REM Parse command line arguments :parse_args if "%~1"=="" goto end_parse if /i "%~1"=="--source" ( - if not "%~2"=="" ( - set "SOURCE=%~2" - shift - shift - goto parse_args + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 ) + set "SOURCE=%~2" + shift + shift + goto parse_args ) if /i "%~1"=="-s" ( - if not "%~2"=="" ( - set "SOURCE=%~2" - shift - shift - goto parse_args + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--method" ( + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--mirror" ( + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--base-url" ( + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 ) + set "BASE_URL=%~2" + shift + shift + goto parse_args ) -shift -goto parse_args +if /i "%~1"=="--archive" ( + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--registry" ( + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option: %~1 +echo. +goto usage_error :end_parse +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + echo =========================================== -echo Qwen Code Installation Script with Source Tracking +echo Qwen Code Installation Script echo =========================================== echo. -echo INFO: Installation source: %SOURCE% +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! + ) else ( + echo INFO: Standalone version: !VERSION! + ) +) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! echo. -REM Check if Node.js is already installed -call :CheckCommandExists node -if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('node --version') do set "NODE_VERSION=%%i" - echo INFO: Node.js is already installed: !NODE_VERSION! - - REM Extract major version number - set "MAJOR_VERSION=!NODE_VERSION:v=!" - for /f "tokens=1 delims=." %%a in ("!MAJOR_VERSION!") do ( - set "MAJOR_VERSION=%%a" - ) - - if !MAJOR_VERSION! GEQ 20 ( - echo INFO: Node.js version !NODE_VERSION! is sufficient. Skipping Node.js installation. - goto :InstallQwenCode - ) else ( - echo INFO: Node.js version !NODE_VERSION! is too low. Need version 20 or higher. - echo INFO: Installing Node.js 20+ - call :InstallNodeJSDirectly +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +exit /b !STANDALONE_STATUS! + +:usage +echo Qwen Code Installer +echo. +echo Usage: install-qwen-with-source.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: github or aliyun. +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 -h, --help Show this help message. +exit /b 0 + +:usage_error +echo Qwen Code Installer +echo. +echo Usage: install-qwen-with-source.bat [OPTIONS] +echo. +echo Options: +echo -s, --source SOURCE Record the installation source. +echo --method METHOD Install method: detect, standalone, or npm. +echo --mirror MIRROR Standalone archive mirror: github or aliyun. +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 -h, --help Show this help message. +exit /b 1 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +echo ERROR: --mirror must be github or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:StandaloneBaseUrl +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath +if /i "!MIRROR!"=="aliyun" ( + set "STANDALONE_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/!VERSION_PATH!" + exit /b 0 +) + +if /i "!VERSION_PATH!"=="latest" ( + set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" + exit /b 0 +) + +set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/!VERSION_PATH!" +exit /b 0 + +:UrlExists +set "CHECK_URL=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$request = [Net.WebRequest]::Create('%CHECK_URL%'); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +exit /b %ERRORLEVEL% + +:DownloadFile +set "DOWNLOAD_URL=%~1" +set "DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%DOWNLOAD_DEST%')" +exit /b %ERRORLEVEL% + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +set "REQUIRE_CHECKSUM=0" + +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + echo !CHECKSUM_FILE!| findstr /R /C:"^https*://" >nul + if !ERRORLEVEL! EQU 0 ( + set "REQUIRE_CHECKSUM=1" + set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. Cannot continue with Qwen Code installation. + if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + echo ERROR: Could not download SHA256SUMS for checksum verification. exit /b 1 ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" ) -) else ( - echo INFO: Node.js not found. Installing Node.js 20+ - call :InstallNodeJSDirectly - if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. Cannot continue with Qwen Code installation. +) + +if not exist "!CHECKSUM_FILE!" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: SHA256SUMS not found; cannot verify remote archive. exit /b 1 ) + echo WARNING: SHA256SUMS not found; skipping checksum verification. + exit /b 0 ) -:InstallQwenCode - -REM Verify npm is available before installing Qwen Code -REM Always use full path to npm to avoid local node_modules conflicts -set "NODEJS_PATH=C:\Program Files\nodejs" -set "NODEJS_PATH_X86=C:\Program Files (x86)\nodejs" +set "EXPECTED_HASH=" +for /f "tokens=1" %%H in ('findstr /C:"!ARCHIVE_NAME!" "!CHECKSUM_FILE!"') do ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=%%H" +) -if exist "!NODEJS_PATH!\npm.cmd" ( - echo INFO: Using npm from !NODEJS_PATH! - set "NPM_CMD=!NODEJS_PATH!\npm.cmd" -) else if exist "!NODEJS_PATH_X86!\npm.cmd" ( - echo INFO: Using npm from !NODEJS_PATH_X86! - set "NPM_CMD=!NODEJS_PATH_X86!\npm.cmd" -) else ( - call :CheckCommandExists npm - if !ERRORLEVEL! NEQ 0 ( - echo ERROR: npm command not found. Node.js installation may have failed. - echo INFO: Please restart your command prompt and try again. - echo INFO: If the problem persists, manually install Node.js from: https://nodejs.org/ +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. exit /b 1 ) - set "NPM_CMD=npm" + echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. + exit /b 0 ) -REM Install Qwen Code with source information -echo INFO: Installing Qwen Code with source: %SOURCE% -echo INFO: Running: %NPM_CMD% install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com -call "%NPM_CMD%" install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com - -if %ERRORLEVEL% EQU 0 ( - echo SUCCESS: Qwen Code installed successfully! -) else ( - echo ERROR: Failed to install Qwen Code. - exit /b 1 +set "ACTUAL_HASH=" +for /f "tokens=1" %%H in ('certutil -hashfile "!ARCHIVE_FILE!" SHA256 ^| findstr /R /C:"^[0-9A-Fa-f][0-9A-Fa-f]"') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" ) -REM Create source.json only if --source or -s was explicitly provided -if not "!SOURCE!"=="unknown" ( - echo INFO: Creating source.json in %USERPROFILE%\.qwen... +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - set "QWEN_DIR=%USERPROFILE%\.qwen" - if not exist "!QWEN_DIR!" ( - mkdir "!QWEN_DIR!" +if "!ACTUAL_HASH!"=="" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Could not calculate SHA-256 checksum for remote archive. + exit /b 1 ) - - REM Create the source.json file with the installation source - ( - echo { - echo "source": "!SOURCE!" - echo } - ) > "!QWEN_DIR!\source.json" - - echo SUCCESS: Installation source saved to %USERPROFILE%\.qwen\source.json + echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. + exit /b 0 ) -REM Verify installation -call :CheckCommandExists qwen -if %ERRORLEVEL% EQU 0 ( - echo SUCCESS: Qwen Code is available as 'qwen' command. - call qwen --version - echo. - echo INFO: Starting Qwen Code... - echo. - call qwen -) else ( - echo WARNING: Qwen Code may not be in PATH. Please check your npm global bin directory. - echo. - echo =========================================== - echo SUCCESS: Installation completed! - echo The source information is stored in %USERPROFILE%\.qwen\source.json - echo. - echo =========================================== +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. + exit /b 1 ) -endlocal +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. exit /b 0 -REM ============================================================ -REM Function: CheckCommandExists -REM Description: Check if a command exists in the system -REM ============================================================ -:CheckCommandExists -where %~1 >nul 2>&1 -exit /b %ERRORLEVEL% +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" -REM ============================================================ -REM Function: InstallNodeJSDirectly -REM Description: Download and install Node.js directly from official website -REM ============================================================ -:InstallNodeJSDirectly -echo INFO: Downloading Node.js LTS (20.x) from official website +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 -REM Create temp directory for download -set "TEMP_DIR=%TEMP%\qwen-nodejs-install" -if not exist "%TEMP_DIR%" mkdir "%TEMP_DIR%" + set "ARCHIVE_NAME=qwen-code-win-x64.zip" + call :StandaloneBaseUrl + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" -REM Determine architecture -set "ARCH=x64" -if "%PROCESSOR_ARCHITECTURE%"=="x86" set "ARCH=x86" -if "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "ARCH=x64" -if defined PROCESSOR_ARCHITEW6432 set "ARCH=x64" + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) -REM Set Node.js download URL (LTS version 20.x) -set "NODE_VERSION=20.18.1" -set "NODE_URL=https://nodejs.org/dist/v!NODE_VERSION!/node-v!NODE_VERSION!-!ARCH!.msi" -set "NODE_INSTALLER=%TEMP_DIR%\nodejs-installer.msi" + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + exit /b 2 + ) +) -echo INFO: Downloading from: !NODE_URL! -echo INFO: Architecture: !ARCH! +if "!TEMP_DIR!"=="" ( + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 +) -REM Download Node.js installer using PowerShell -powershell -Command "try { Invoke-WebRequest -Uri '!NODE_URL!' -OutFile '!NODE_INSTALLER!' -UseBasicParsing; Write-Host 'Download completed successfully.' } catch { Write-Host 'Download failed:' $_.Exception.Message; exit 1 }" +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +set "EXTRACT_DIR=!TEMP_DIR!\extract" +mkdir "!EXTRACT_DIR!" >nul 2>&1 +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath '%ARCHIVE_FILE%' -DestinationPath '%EXTRACT_DIR%' -Force" if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to download Node.js installer from official source. - echo INFO: Please manually download and install Node.js from: https://nodejs.org/ - echo INFO: After manual installation, restart your command prompt and run this script again. + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. exit /b 1 ) -if not exist "!NODE_INSTALLER!" ( - echo ERROR: Node.js installer not found after download. +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. exit /b 1 ) -echo INFO: Installing Node.js silently -REM Install Node.js silently -msiexec /i "!NODE_INSTALLER!" /quiet /norestart ADDLOCAL=ALL +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +if not exist "!INSTALL_BASE!" mkdir "!INSTALL_BASE!" +if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" + +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" +if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) +if exist "!INSTALL_DIR!" move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to install Node.js. - echo INFO: You may need to run this script as Administrator. - echo INFO: Or manually install Node.js from: https://nodejs.org/ + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. exit /b 1 ) -echo INFO: Node.js installation completed. +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 -REM Clean up installer -del "!NODE_INSTALLER!" 2>nul -rmdir "!TEMP_DIR!" 2>nul +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd" -REM Refresh environment variables -echo INFO: Refreshing environment variables -call :RefreshEnvVars +set "PATH=!INSTALL_BIN_DIR!;!PATH!" +call :CreateSourceJson +if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 -REM Verify installation and return success -set "NODEJS_INSTALL_PATH=C:\Program Files\nodejs" -if exist "!NODEJS_INSTALL_PATH!\node.exe" ( - for /f "delims=" %%i in ('"!NODEJS_INSTALL_PATH!\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! - exit /b 0 +echo SUCCESS: Qwen Code standalone archive installed successfully. +echo INFO: Installed to !INSTALL_DIR! +exit /b 0 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 ) -set "NODEJS_INSTALL_PATH_X86=C:\Program Files (x86)\nodejs" -if exist "!NODEJS_INSTALL_PATH_X86!\node.exe" ( - for /f "delims=" %%i in ('"!NODEJS_INSTALL_PATH_X86!\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! - exit /b 0 +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 ) -call :CheckCommandExists node -if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('node --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js !NODE_VERSION! installed successfully! - exit /b 0 -) else ( - echo WARNING: Node.js installed but not found in PATH. - echo INFO: Trying to use Node.js from default installation path - - REM Try to use Node.js directly from installation path - set "NODE_PATH=C:\Program Files\nodejs" - if exist "%NODE_PATH%\node.exe" ( - echo INFO: Found Node.js at %NODE_PATH% - REM Update PATH for current session - set "PATH=%PATH%;%NODE_PATH%" - - REM Test if node works now - "%NODE_PATH%\node.exe" --version >nul 2>&1 - if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('"%NODE_PATH%\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js %NODE_VERSION% is working from %NODE_PATH% - exit /b 0 - ) - ) - - REM Try x86 path - set "NODE_PATH_X86=C:\Program Files (x86)\nodejs" - if exist "%NODE_PATH_X86%\node.exe" ( - echo INFO: Found Node.js at %NODE_PATH_X86% - REM Update PATH for current session - set "PATH=%PATH%;%NODE_PATH_X86%" - - REM Test if node works now - "%NODE_PATH_X86%\node.exe" --version >nul 2>&1 - if !ERRORLEVEL! EQU 0 ( - for /f "delims=" %%i in ('"%NODE_PATH_X86%\node.exe" --version') do set "NODE_VERSION=%%i" - echo SUCCESS: Node.js %NODE_VERSION% is working from %NODE_PATH_X86% - exit /b 0 - ) - ) - - echo ERROR: Node.js installation completed but cannot be executed +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 20 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 20 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +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! + echo INFO: Upgrading to the latest version. +) + +echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! +call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! exit /b 1 ) +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson exit /b 0 -REM ============================================================ -REM Function: RefreshEnvVars -REM Description: Refresh environment variables without restarting -REM ============================================================ -:RefreshEnvVars -REM Add Node.js to PATH if not already there -set "NODEJS_DIR=C:\Program Files\nodejs" -if exist "!NODEJS_DIR!\node.exe" ( - echo INFO: Found Node.js at !NODEJS_DIR! - set "PATH=!PATH!;!NODEJS_DIR!" +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=%USERPROFILE%\.qwen" +if not exist "%QWEN_DIR%" mkdir "%QWEN_DIR%" + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "%QWEN_DIR%\source.json" + +echo SUCCESS: Installation source saved to %USERPROFILE%\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" +if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" + +echo. +echo =========================================== +echo Installation completed! +echo =========================================== +echo. + +where qwen >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" + echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! + echo. + echo You can now run: qwen + echo. + echo INFO: Run qwen in your project directory to start an interactive session. + exit /b 0 ) -REM Try alternative path for x86 systems -set "NODEJS_DIR_X86=C:\Program Files (x86)\nodejs" -if exist "!NODEJS_DIR_X86!\node.exe" ( - echo INFO: Found Node.js at !NODEJS_DIR_X86! - set "PATH=!PATH!;!NODEJS_DIR_X86!" +echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. +echo. +echo Restart your command prompt, then run: qwen +if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 ) +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" +if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen +) exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index ce6d46c2695..6e60c6b5d08 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -1,574 +1,762 @@ -#!/bin/bash +#!/usr/bin/env bash # Qwen Code Installation Script -# This script installs Node.js (via NVM) and Qwen Code CLI -# Supports Linux and macOS +# Installs Qwen Code from a standalone archive when available, with npm fallback. +# This script intentionally does not install Node.js or change npm config. # -# Usage: install-qwen-with-source.sh --source [github|npm|internal|local-build] -# install-qwen-with-source.sh -s [github|npm|internal|local-build] +# Usage: +# install-qwen-with-source.sh --source [github|npm|internal|local-build] +# install-qwen-with-source.sh --method [detect|standalone|npm] -# Re-execute with bash if running with sh or other shells -# This block must use POSIX-compliant syntax ([ not [[) since it runs before we know bash is available if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then - # Check if we're in a git hook environment - case "${0}" in - *.git/hooks/*) export __QWEN_IN_GIT_HOOK=1 ;; - esac - if [ -n "${GIT_DIR:-}" ]; then - export __QWEN_IN_GIT_HOOK=1 - fi - - # Try to find bash if command -v bash >/dev/null 2>&1; then - export __QWEN_INSTALL_REEXEC=1 - # Re-exec with bash, preserving all arguments - exec bash -- "${0}" "$@" - else - echo "Error: This script requires bash. Please install bash first." + if [ -f "${0}" ]; then + export __QWEN_INSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the installer with: curl ... | bash" exit 1 fi -fi -# Enable strict mode (bash-specific options) -# pipefail requires bash 3+; check before setting -if [ -n "${BASH_VERSION:-}" ]; then - # shellcheck disable=SC3040 - set -eo pipefail -else - set -e + echo "Error: This script requires bash. Please install bash first." + exit 1 fi -# ============================================ -# Color definitions -# ============================================ +set -eo pipefail + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -NC='\033[0m' # No Color +NC='\033[0m' -# ============================================ -# Log functions -# ============================================ log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" + echo -e "${BLUE}INFO:${NC} $1" } log_success() { - echo -e "${GREEN}✅ $1${NC}" + echo -e "${GREEN}SUCCESS:${NC} $1" } log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" + echo -e "${YELLOW}WARNING:${NC} $1" } log_error() { - echo -e "${RED}❌ $1${NC}" + echo -e "${RED}ERROR:${NC} $1" } -# ============================================ -# Utility functions -# ============================================ command_exists() { command -v "$1" >/dev/null 2>&1 } -get_shell_profile() { - local current_shell - current_shell=$(basename "${SHELL}") - case "${current_shell}" in - bash) - echo "${HOME}/.bashrc" +print_usage() { + cat </dev/null || echo unknown) $(uname -r 2>/dev/null || true)" + log_info "Install method: ${METHOD}" + if [[ "${METHOD}" != "npm" ]]; then + log_info "Standalone mirror: ${MIRROR}" + if [[ -n "${BASE_URL}" ]]; then + log_info "Standalone base URL: ${BASE_URL}" + fi + if [[ -n "${ARCHIVE_PATH}" ]]; then + log_info "Standalone archive: ${ARCHIVE_PATH}" + else + log_info "Standalone version: ${VERSION}" + fi + fi + if [[ "${METHOD}" != "standalone" ]]; then + log_info "npm registry: ${NPM_REGISTRY}" + fi + if [[ "${SOURCE}" != "unknown" ]]; then + log_info "Installation source: ${SOURCE}" fi + echo "" +} - if command_exists wget; then - DOWNLOAD_CMD="wget" - DOWNLOAD_ARGS="-qO -" +print_node_help() { + echo "" + echo "Node.js 20 or newer is required before installing Qwen Code with npm." + echo "" + echo "Install Node.js, then rerun this installer:" + case "$(uname -s 2>/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 20+" + echo "in this shell before rerunning the installer." +} + +require_node() { + if ! command_exists node; then + log_error "Node.js was not found." + print_node_help + exit 1 + fi + + local node_version + node_version=$(node -p "process.versions.node" 2>/dev/null || true) + local node_major + node_major=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || true) + + if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then + log_error "Unable to determine Node.js version." + print_node_help + exit 1 + fi + + if [[ "${node_major}" -lt 20 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required." + print_node_help + exit 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 "Neither curl nor wget found" - log_info "Please install curl or wget manually:" - echo " - macOS: brew install curl" - echo " - Ubuntu/Debian: sudo apt-get install curl" - echo " - CentOS/RHEL: sudo yum install curl" + 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." exit 1 } -# ============================================ -# Clean npm configuration conflicts -# ============================================ -clean_npmrc_conflict() { - local npmrc="${HOME}/.npmrc" - if [[ -f "${npmrc}" ]]; then - # Only clean if conflicting entries actually exist - if grep -Eq '^(prefix|globalconfig) *= *' "${npmrc}" 2>/dev/null; then - log_info "Cleaning npmrc conflicts..." - # Backup original npmrc before modifying - cp -f "${npmrc}" "${npmrc}.bak" - log_info "Backed up original .npmrc to ${npmrc}.bak" - grep -Ev '^(prefix|globalconfig) *= *' "${npmrc}.bak" > "${npmrc}.tmp" || true - mv -f "${npmrc}.tmp" "${npmrc}" || true - log_success "Removed conflicting prefix/globalconfig entries from .npmrc" - fi +get_npm_global_bin() { + local prefix + prefix=$(npm prefix -g 2>/dev/null || true) + + if [[ -z "${prefix}" ]]; then + return 0 + fi + + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*) + echo "${prefix}" + ;; + *) + echo "${prefix}/bin" + ;; + esac +} + +create_source_json() { + if [[ "${SOURCE}" == "unknown" ]]; then + return 0 fi + + local qwen_dir="${HOME}/.qwen" + mkdir -p "${qwen_dir}" + + local escaped_source + escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') + + cat > "${qwen_dir}/source.json" </dev/null || echo unknown) + local arch + arch=$(uname -m 2>/dev/null || echo unknown) + + case "${os}" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + return 1 + ;; + esac -# ============================================ -# Install NVM -# ============================================ -install_nvm() { - local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" - local NVM_VERSION="${NVM_VERSION:-v0.40.3}" + case "${arch}" in + x86_64|amd64) + arch="x64" + ;; + arm64|aarch64) + arch="arm64" + ;; + *) + return 1 + ;; + esac - if [[ -s "${NVM_DIR}/nvm.sh" ]]; then - log_info "NVM is already installed at ${NVM_DIR}" + echo "${os}-${arch}" +} + +archive_extension_for_target() { + case "$1" in + darwin-*|linux-*) + echo "tar.gz" + ;; + *) + return 1 + ;; + esac +} + +release_version_path() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" return 0 fi - log_info "Installing NVM ${NVM_VERSION}..." + case "${VERSION}" in + v*) + echo "${VERSION}" + ;; + *) + echo "v${VERSION}" + ;; + esac +} - # Download and install NVM from Aliyun OSS - # Use temporary file instead of pipe to avoid potential subshell issues - local NVM_INSTALL_TEMP - NVM_INSTALL_TEMP=$(mktemp) - if "${DOWNLOAD_CMD}" "${DOWNLOAD_ARGS}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${NVM_INSTALL_TEMP}"; then - # Run the script in current shell environment - # shellcheck source=/dev/null - . "${NVM_INSTALL_TEMP}" - rm -f "${NVM_INSTALL_TEMP}" - log_success "NVM installed successfully" - else - rm -f "${NVM_INSTALL_TEMP}" - log_error "Failed to install NVM" - log_info "Please install NVM manually: https://github.com/nvm-sh/nvm#install--update-script" - exit 1 +standalone_base_url() { + if [[ -n "${BASE_URL}" ]]; then + echo "${BASE_URL%/}" + return 0 fi - # Configure shell profile - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - - # Fish shell returns empty string from get_shell_profile because export/source - # syntax is incompatible with fish. Skip automatic profile writes for fish users. - if [[ -z "${PROFILE_FILE}" ]]; then - log_warning "Fish shell detected: automatic shell profile configuration is not supported." - log_info "Please add NVM configuration manually. See: https://github.com/nvm-sh/nvm#fish" - # Check if profile file is writable - elif [[ -f "${PROFILE_FILE}" ]] && [[ ! -w "${PROFILE_FILE}" ]]; then - log_warning "Cannot write to ${PROFILE_FILE} (permission denied)" - log_info "Skipping shell profile configuration" - log_info "You may need to manually add NVM configuration to your shell profile" - elif ! grep -q 'NVM_DIR' "${PROFILE_FILE}" 2>/dev/null; then - # shellcheck disable=SC2016 - # The following echo statements intentionally use single quotes to write literal strings - { - echo "" - echo "# NVM configuration (added by Qwen Code installer)" - echo "export NVM_DIR=\"\$HOME/.nvm\"" - echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' - echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' - } >> "${PROFILE_FILE}" 2>/dev/null || { - log_warning "Failed to write to ${PROFILE_FILE}" - log_info "Skipping shell profile configuration" - return 0 - } - log_info "Added NVM config to ${PROFILE_FILE}" - fi - - # Load NVM for current session - export NVM_DIR="${NVM_DIR}" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" - - log_success "NVM configured successfully" - return 0 + local version_path + version_path=$(release_version_path) + + if [[ "${MIRROR}" == "aliyun" ]]; then + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" + return 0 + fi + + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/QwenLM/qwen-code/releases/latest/download" + return 0 + fi + + echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" } -# ============================================ -# Install Node.js via NVM -# ============================================ -install_nodejs_with_nvm() { - local NODE_VERSION="${NODE_VERSION:-20}" - local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" +download_file() { + local url="$1" + local destination="$2" - # Ensure NVM is loaded - export NVM_DIR="${NVM_DIR}" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + if command_exists curl; then + curl -fsSL --retry 2 "${url}" -o "${destination}" + return $? + fi - if ! command_exists nvm; then - log_error "NVM not loaded properly" - return 1 + if command_exists wget; then + wget -q "${url}" -O "${destination}" + return $? fi - # Set Node.js mirror source for faster downloads in China - export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node" + log_error "curl or wget is required to download the standalone archive." + return 1 +} - # Install Node.js - log_info "Installing Node.js v${NODE_VERSION}..." - if nvm install "${NODE_VERSION}"; then - nvm alias default "${NODE_VERSION}" || true - nvm use default || true - log_success "Node.js v${NODE_VERSION} installed successfully" +url_exists() { + local url="$1" - # Verify installation - log_info "Node.js version: $(node -v)" || true - log_info "npm version: $(npm -v)" || true + if command_exists curl; then + curl -fsIL --retry 1 "${url}" >/dev/null 2>&1 + return $? + fi + if command_exists wget; then + wget -q --spider "${url}" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +sha256_file() { + local file_path="$1" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print $1}' + return 0 + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print $1}' return 0 - else - log_error "Failed to install Node.js" - return 1 fi + + return 1 } -# ============================================ -# Check Node.js version -# ============================================ -check_node_version() { - if ! command_exists node; then - return 1 +verify_checksum() { + local archive_path="$1" + local checksum_source="$2" + local archive_name="$3" + local checksum_file="${checksum_source}" + local temp_checksum="" + local checksum_required="false" + + if [[ -z "${checksum_file}" ]]; then + checksum_file="$(dirname "${archive_path}")/SHA256SUMS" + elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then + checksum_required="true" + temp_checksum="$(mktemp)" + if ! download_file "${checksum_file}" "${temp_checksum}"; then + rm -f "${temp_checksum}" + log_error "Could not download SHA256SUMS for checksum verification." + return 1 + fi + checksum_file="${temp_checksum}" fi - local current_version - current_version=$(node -v | sed 's/v//') - local major_version - major_version=$(echo "${current_version}" | cut -d. -f1 | sed 's/[^0-9]//g') + if [[ ! -f "${checksum_file}" ]]; then + if [[ "${checksum_required}" == "true" ]]; then + log_error "SHA256SUMS not found; cannot verify remote archive." + return 1 + fi + log_warning "SHA256SUMS not found; skipping checksum verification." + return 0 + fi - # Handle cases where major_version is empty or non-numeric - if [[ -z "${major_version}" ]]; then - log_warning "Unable to determine Node.js version from: $(node -v)" - return 1 + local expected + expected=$(grep -E "(^|[[:space:]])[*]?${archive_name}$" "${checksum_file}" | awk '{print $1}' | head -n 1) + if [[ -z "${expected}" ]]; then + rm -f "${temp_checksum}" + if [[ "${checksum_required}" == "true" ]]; then + log_error "Checksum entry for ${archive_name} not found." + return 1 + fi + log_warning "Checksum entry for ${archive_name} not found; skipping checksum verification." + return 0 fi - if [[ "${major_version}" -ge 20 ]]; then - log_success "Node.js v${current_version} is already installed (>= 20)" + local actual + if ! actual=$(sha256_file "${archive_path}"); then + rm -f "${temp_checksum}" + if [[ "${checksum_required}" == "true" ]]; then + log_error "No SHA-256 utility found; cannot verify remote archive." + return 1 + fi + log_warning "No SHA-256 utility found; skipping checksum verification." return 0 - else - log_warning "Node.js v${current_version} is installed but version < 20" - return 1 fi -} -# ============================================ -# Install Node.js -# ============================================ -install_nodejs() { - local platform - platform=$(uname -s) + rm -f "${temp_checksum}" + + if [[ "${expected}" != "${actual}" ]]; then + log_error "Checksum verification failed for ${archive_name}." + return 1 + fi - case "${platform}" in - Linux|Darwin) - log_info "Installing Node.js on ${platform}..." + log_success "Checksum verified for ${archive_name}." +} - # Install NVM - if ! install_nvm; then - log_error "Failed to install NVM" - return 1 - fi +extract_archive() { + local archive_path="$1" + local destination="$2" - # Load NVM - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + mkdir -p "${destination}" - # Install Node.js - if ! install_nodejs_with_nvm; then - log_error "Failed to install Node.js" + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to extract ${archive_path}." return 1 fi + unzip -q "${archive_path}" -d "${destination}" ;; - MINGW*|CYGWIN*|MSYS*) - log_error "Windows platform detected. Please use Windows installer or WSL." - log_info "Visit: https://nodejs.org/en/download/" - exit 1 + *.tar.gz|*.tgz) + tar -xzf "${archive_path}" -C "${destination}" ;; *) - log_error "Unsupported platform: ${platform}" - exit 1 + log_error "Unsupported archive format: ${archive_path}" + return 1 ;; esac } -# ============================================ -# Check and install Node.js -# ============================================ -check_and_install_nodejs() { - if check_node_version; then - log_info "Using existing Node.js installation" - clean_npmrc_conflict +install_standalone() { + local target="" + local archive_name="" + local archive_path="" + local checksum_source="" + local temp_dir="" + + if [[ -n "${ARCHIVE_PATH}" ]]; then + archive_path="${ARCHIVE_PATH}" + archive_name="$(basename "${archive_path}")" + if [[ ! -f "${archive_path}" ]]; then + log_error "Standalone archive not found: ${archive_path}" + return 1 + fi else - log_warning "Installing or upgrading Node.js..." - install_nodejs - fi -} + if ! target=$(detect_target); then + log_warning "Standalone archive is not available for this platform." + return 2 + fi + + local archive_extension + archive_extension=$(archive_extension_for_target "${target}") + archive_name="qwen-code-${target}.${archive_extension}" -# ============================================ -# Fix npm permissions (without using sudo) -# ============================================ -fix_npm_permissions() { - log_info "Checking npm permissions..." + local base_url + base_url=$(standalone_base_url) + local archive_url="${base_url}/${archive_name}" + checksum_source="${base_url}/SHA256SUMS" - local NPM_GLOBAL_DIR - NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) || true + if [[ "${METHOD}" == "detect" ]] && ! url_exists "${archive_url}"; then + log_warning "Standalone archive not found: ${archive_name}" + return 2 + fi - # Determine whether we need to fall back to ~/.npm-global: - # 1. prefix is empty or contains an error string - # 2. prefix is a system directory (would break sudo setuid binaries) - # 3. prefix directory is not writable - local use_user_dir=false + temp_dir=$(mktemp -d) + archive_path="${temp_dir}/${archive_name}" - if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then - log_info "npm prefix is unset or invalid, switching to user directory" - use_user_dir=true - else - # SAFETY CHECK: Never use system directories - case "${NPM_GLOBAL_DIR}" in - /|/usr|/usr/local|/bin|/sbin|/lib|/lib64|/opt|/snap|/var|/etc) - log_warning "npm prefix is a system directory (${NPM_GLOBAL_DIR}), switching to user directory to avoid breaking system binaries." - use_user_dir=true - ;; - esac - fi - - if [[ "${use_user_dir}" == false ]] && [[ ! -w "${NPM_GLOBAL_DIR}" ]]; then - log_warning "npm global directory is not writable: ${NPM_GLOBAL_DIR}, switching to user directory." - use_user_dir=true - fi - - if [[ "${use_user_dir}" == true ]]; then - NPM_GLOBAL_DIR="${HOME}/.npm-global" - # Create the directory before setting prefix so npm config set succeeds - mkdir -p "${NPM_GLOBAL_DIR}" - npm config set prefix "${NPM_GLOBAL_DIR}" - log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" - - # Only add ~/.npm-global/bin to PATH when we actually use it - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - if [[ -n "${PROFILE_FILE}" ]] && ! grep -q '.npm-global/bin' "${PROFILE_FILE}" 2>/dev/null; then - { - echo "" - echo "# NPM global bin (added by Qwen Code installer)" - echo "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" - } >> "${PROFILE_FILE}" 2>/dev/null || log_warning "Failed to write PATH update to ${PROFILE_FILE}" - log_info "Added npm global bin to PATH in ${PROFILE_FILE}" + log_info "Downloading ${archive_url}" + if ! download_file "${archive_url}" "${archive_path}"; then + rm -rf "${temp_dir}" + log_warning "Failed to download standalone archive." + return 2 fi - else - log_info "npm global directory is writable: ${NPM_GLOBAL_DIR}" fi - return 0 -} + if [[ -z "${temp_dir}" ]]; then + temp_dir=$(mktemp -d) + fi -# ============================================ -# Install Qwen Code -# ============================================ -install_qwen_code() { - # Ensure NVM node is in PATH - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true + if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then + rm -rf "${temp_dir}" + return 1 + fi - # Add npm global bin to PATH - local NPM_GLOBAL_BIN - NPM_GLOBAL_BIN=$(npm config get prefix 2>/dev/null)/bin - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - export PATH="${NPM_GLOBAL_BIN}:${PATH}" + local extract_dir="${temp_dir}/extract" + if ! extract_archive "${archive_path}" "${extract_dir}"; then + rm -rf "${temp_dir}" + return 1 fi - if command_exists qwen; then - local QWEN_VERSION - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - log_success "Qwen Code is already installed: ${QWEN_VERSION}" - log_info "Upgrading to the latest version..." + if [[ ! -x "${extract_dir}/qwen-code/bin/qwen" ]]; then + log_error "Archive does not contain qwen-code/bin/qwen." + rm -rf "${temp_dir}" + return 1 fi - # Clean npmrc conflicts - clean_npmrc_conflict + if [[ ! -x "${extract_dir}/qwen-code/node/bin/node" ]]; then + log_error "Archive does not contain executable qwen-code/node/bin/node." + rm -rf "${temp_dir}" + return 1 + fi - # Fix npm permissions if needed - fix_npm_permissions + mkdir -p "${INSTALL_LIB_PARENT}" "${INSTALL_BIN_DIR}" - # Install Qwen Code - log_info "Installing Qwen Code..." - if npm install -g @qwen-code/qwen-code@latest --registry https://registry.npmmirror.com; then - log_success "Qwen Code installed successfully!" + local new_install_dir="${INSTALL_LIB_DIR}.new" + local old_install_dir="${INSTALL_LIB_DIR}.old" + rm -rf "${new_install_dir}" "${old_install_dir}" + mv "${extract_dir}/qwen-code" "${new_install_dir}" - # Verify installation - if command_exists qwen; then - local qwen_version - qwen_version=$(qwen --version 2>/dev/null) || qwen_version="unknown" - log_info "Qwen Code version: ${qwen_version}" - fi - else - log_error "Failed to install Qwen Code!" - log_info "Please check your internet connection and try again" - exit 1 + if [[ -e "${INSTALL_LIB_DIR}" ]]; then + mv "${INSTALL_LIB_DIR}" "${old_install_dir}" fi - # Create source.json if source parameter was provided - if [[ "${SOURCE}" != "unknown" ]]; then - create_source_json + if ! mv "${new_install_dir}" "${INSTALL_LIB_DIR}"; then + if [[ -e "${old_install_dir}" ]]; then + mv "${old_install_dir}" "${INSTALL_LIB_DIR}" + fi + rm -rf "${temp_dir}" + log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}." + return 1 fi -} -# ============================================ -# Create source.json -# ============================================ -create_source_json() { - local QWEN_DIR="${HOME}/.qwen" - - mkdir -p "${QWEN_DIR}" + rm -rf "${old_install_dir}" + cat > "${INSTALL_BIN_DIR}/qwen" < "${QWEN_DIR}/source.json" </dev/null || echo "unknown") + log_info "Existing Qwen Code detected: ${qwen_version}" + log_info "Upgrading to the latest version." fi - # Ensure download tool is available - ensure_download_tool + local install_cmd=( + npm + install + -g + @qwen-code/qwen-code@latest + --registry + "${NPM_REGISTRY}" + ) + + log_info "Running: npm install -g @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" + if "${install_cmd[@]}"; then + log_success "Qwen Code installed successfully." + create_source_json + return 0 + fi - # Check and install Node.js - check_and_install_nodejs + 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 @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" + exit 1 +} - # Install Qwen Code - install_qwen_code - echo "" +print_final_instructions() { + local install_bin_dir="${1:-}" + if [[ -n "${install_bin_dir}" ]]; then + export PATH="${install_bin_dir}:${PATH}" + fi - # ============================================ - # Final instructions - # ============================================ + echo "" echo "==========================================" - echo "✅ Installation completed!" + echo "Installation completed!" echo "==========================================" echo "" - # Ensure NVM and npm global bin are in PATH - export NVM_DIR="${HOME}/.nvm" - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true - local NPM_GLOBAL_BIN - NPM_GLOBAL_BIN=$(npm config get prefix 2>/dev/null)/bin - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - export PATH="${NPM_GLOBAL_BIN}:${PATH}" - fi - - # Check if qwen is immediately available if command_exists qwen; then - log_success "Qwen Code is ready to use!" + local qwen_version + qwen_version=$(qwen --version 2>/dev/null || echo "unknown") + log_success "Qwen Code is ready to use: ${qwen_version}" echo "" echo "You can now run: qwen" echo "" - # Auto-start qwen - log_info "Starting Qwen Code..." - echo "" - exec qwen - else - log_warning "Qwen Code command not found in current session" - echo "" - echo "To use Qwen Code immediately without restarting your terminal," - echo "run the following command in your current shell:" - echo " eval \$(${0} --print-env)" + log_info "Run qwen in your project directory to start an interactive session." + return 0 + fi + + log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." + echo "" + echo "Restart your terminal, then run: qwen" + if [[ -n "${install_bin_dir}" ]]; then echo "" - log_info "Or simply restart your terminal, then run: qwen" + echo "Or run this in the current shell:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " qwen" + fi +} + +main() { + if [[ -z "${HOME:-}" ]]; then + log_error "HOME is not set; cannot determine where to install Qwen Code." + exit 1 fi + + print_header + + case "${METHOD}" in + standalone) + install_standalone + print_final_instructions "${INSTALL_BIN_DIR}" + ;; + npm) + install_npm + print_final_instructions "$(get_npm_global_bin)" + ;; + detect) + if install_standalone; then + print_final_instructions "${INSTALL_BIN_DIR}" + else + standalone_status=$? + if [[ "${standalone_status}" -eq 2 ]]; then + log_warning "Falling back to npm installation." + install_npm + print_final_instructions "$(get_npm_global_bin)" + else + exit "${standalone_status}" + fi + fi + ;; + esac } -# Run main function main "$@" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js new file mode 100644 index 00000000000..4fe0348506b --- /dev/null +++ b/scripts/tests/install-script.test.js @@ -0,0 +1,218 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; + +const { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = await vi.importActual('node:fs'); +const { execFileSync } = await vi.importActual('node:child_process'); +const { tmpdir } = await vi.importActual('node:os'); +const path = await vi.importActual('node:path'); +const readScript = (path) => readFileSync(path, 'utf8'); + +describe('installation scripts', () => { + it('keeps the Linux/macOS installer lightweight', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.sh', + ); + + expect(script).not.toContain('install_nvm'); + expect(script).not.toContain('install_nvm.sh'); + expect(script).not.toContain('nvm install'); + expect(script).not.toContain('NVM_NODEJS_ORG_MIRROR'); + expect(script).not.toContain('npm config set prefix'); + expect(script).not.toContain('clean_npmrc_conflict'); + expect(script).not.toContain('.npmrc'); + expect(script).not.toContain('.npm-global'); + expect(script).not.toMatch(/^\s*exec\s+qwen\s*$/m); + expect(script).not.toContain('--print-env'); + expect(script).not.toContain('brew install node@20'); + expect(script).toContain('brew install node'); + expect(script).toContain( + '--source may only contain letters, numbers, dot, underscore, or dash', + ); + expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain( + 'npm install -g @qwen-code/qwen-code@latest --registry', + ); + expect(script).toContain('You can now run: qwen'); + }); + + it('supports code-server-style standalone install on Linux/macOS', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.sh', + ); + + expect(script).toContain('--method METHOD'); + expect(script).toContain('--mirror MIRROR'); + expect(script).toContain('--base-url URL'); + expect(script).toContain('--archive PATH'); + expect(script).toContain('install_standalone()'); + expect(script).toContain('install_npm()'); + expect(script).toContain('detect_target()'); + expect(script).toContain('verify_checksum()'); + expect(script).toContain( + 'SHA256SUMS not found; cannot verify remote archive', + ); + expect(script).toContain('qwen-code-${target}'); + expect(script).toContain('METHOD="${METHOD:-detect}"'); + expect(script).toContain('Falling back to npm installation'); + expect(script).toContain('standalone_status=$?'); + expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]'); + expect(script).not.toContain('ln -sf "${INSTALL_LIB_DIR}/bin/qwen"'); + expect(script).toContain('exec "${INSTALL_LIB_DIR}/bin/qwen"'); + expect(script).toContain('qwen-code/node/bin/node'); + }); + + it('keeps the Windows installer lightweight', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.bat', + ); + + expect(script).not.toContain('InstallNodeJSDirectly'); + expect(script).not.toContain('node-v!NODE_VERSION!'); + expect(script).not.toContain('msiexec'); + expect(script).not.toContain('Invoke-WebRequest'); + expect(script).not.toContain('PowerShell (Administrator)'); + expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); + expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); + expect(script).toContain(':ValidateSource'); + expect(script).toContain('findstr /R'); + expect(script).toContain( + '--source may only contain letters, numbers, dot, underscore, or dash', + ); + expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Please install Node.js'); + expect(script).toContain( + 'npm install -g @qwen-code/qwen-code@latest --registry', + ); + expect(script).toContain('You can now run: qwen'); + }); + + it('supports code-server-style standalone install on Windows', () => { + const script = readScript( + 'scripts/installation/install-qwen-with-source.bat', + ); + + expect(script).toContain('--method METHOD'); + expect(script).toContain('--mirror MIRROR'); + expect(script).toContain('--base-url URL'); + expect(script).toContain('--archive PATH'); + expect(script).toContain(':InstallStandalone'); + expect(script).toContain(':InstallNpm'); + expect(script).toContain(':VerifyChecksum'); + expect(script).toContain( + 'SHA256SUMS not found; cannot verify remote archive', + ); + expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain('Expand-Archive'); + 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('qwen-code\\node\\node.exe'); + }); +}); + +describe('standalone release packaging', () => { + it('defines a standalone packaging script', () => { + const packageJson = JSON.parse(readScript('package.json')); + + expect(packageJson.scripts['package:standalone']).toBe( + 'node scripts/create-standalone-package.js', + ); + expect(existsSync('scripts/create-standalone-package.js')).toBe(true); + + const packageScript = readScript('scripts/create-standalone-package.js'); + expect(packageScript).toContain("'bundled/qc-helper/docs'"); + expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); + expect(packageScript).toContain('validateNodeRuntime'); + }); + + it('rejects a runtime archive without a Node executable', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const fakeRuntimeDir = path.join(tmpDir, 'not-node'); + mkdirSync(fakeRuntimeDir, { recursive: true }); + writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); + const fakeRuntimeArchive = path.join(tmpDir, 'bad-runtime.tar.gz'); + execFileSync( + 'tar', + ['-czf', fakeRuntimeArchive, '-C', tmpDir, 'not-node'], + { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }, + ); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Node.js runtime for linux-x64 must contain/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + it('uploads standalone archives during release', () => { + const workflow = readScript('.github/workflows/release.yml'); + + expect(workflow).toContain('set -euo pipefail'); + expect(workflow).toContain('SHASUMS256.txt'); + expect(workflow).toContain('$2 == name'); + expect(workflow).toContain('does not list ${archive_name}'); + expect(workflow).toContain('sha256sum -c -'); + expect(workflow).toContain('npm run package:standalone'); + expect(workflow).toContain('dist/standalone/qwen-code-*'); + expect(workflow).toContain('dist/standalone/SHA256SUMS'); + }); + + it('documents optional native module parity for standalone installs', () => { + const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); + + expect(guide).toContain('Optional Native Modules'); + expect(guide).toContain('node-pty'); + expect(guide).toContain('clipboard'); + }); +}); + +function ensureMinimalDist() { + if (existsSync('dist')) { + return false; + } + + mkdirSync('dist/vendor', { recursive: true }); + mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); + writeFileSync('dist/cli.js', 'console.log("qwen");\n'); + writeFileSync( + 'dist/package.json', + JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), + ); + return true; +} From d2e5b1bd077ea83d29b334ba669d9144c03b15db Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 20:20:56 +0800 Subject: [PATCH 002/129] fix(installer): harden standalone archive installs --- .github/workflows/release.yml | 5 + .../2026-04-30-standalone-installer-design.md | 8 +- ...26-04-30-standalone-installer-test-plan.md | 11 +- README.md | 3 +- docs/users/overview.md | 2 +- docs/users/quickstart.md | 4 +- scripts/create-standalone-package.js | 8 +- scripts/installation/INSTALLATION_GUIDE.md | 14 +- .../installation/install-qwen-with-source.bat | 104 ++++++- .../installation/install-qwen-with-source.sh | 88 ++++-- scripts/tests/install-script.test.js | 273 +++++++++++++++++- 11 files changed, 463 insertions(+), 57 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb7f897ddff..cd6287a1636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -424,6 +424,11 @@ jobs: ls -la dist/standalone cat dist/standalone/SHA256SUMS + archive_count="$(grep -c '^[0-9a-f]' dist/standalone/SHA256SUMS)" + if [[ "${archive_count}" -ne 5 ]]; then + echo "::error::Expected 5 standalone checksums, found ${archive_count}" + exit 1 + fi - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' diff --git a/.qwen/design/2026-04-30-standalone-installer-design.md b/.qwen/design/2026-04-30-standalone-installer-design.md index ba49364fe21..762338a019b 100644 --- a/.qwen/design/2026-04-30-standalone-installer-design.md +++ b/.qwen/design/2026-04-30-standalone-installer-design.md @@ -80,6 +80,9 @@ existing ESM bundle without requiring a user-managed Node.js installation. user install directory, and expose `qwen`. 5. If the archive does not exist, fall back to npm. +Local `--archive` installs also require a `SHA256SUMS` file next to the archive. +This keeps offline installs deterministic without trusting an unchecked tarball. + `--method standalone` follows the same standalone path, but a missing or failed standalone asset is fatal. @@ -120,11 +123,14 @@ https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/vX.Y.Z ``` All mirrors must serve byte-identical artifacts and the same `SHA256SUMS`. +Aliyun OSS/CDN does not have GitHub's `releases/latest/download` redirect, so +release publishing must maintain both `vX.Y.Z/` and `latest/` directories. ## Safety - Remote standalone installs require checksum verification. -- Local archive installs do not require network access. +- Local archive installs do not require network access, but they still require + an adjacent `SHA256SUMS` file. - The installer only deletes temporary extraction directories and the previous managed standalone install directory. - npm fallback does not change npm prefix, npmrc, or PATH. diff --git a/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md b/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md index edcf7ec8005..c903ee3c14d 100644 --- a/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md +++ b/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md @@ -14,8 +14,9 @@ Run from the repository root after `npm run bundle && npm run prepare:package`. 1. Build a standalone archive for the current target with a local Node.js archive. -2. Create a temporary `HOME`. -3. Run: +2. Keep the generated `SHA256SUMS` next to the archive. +3. Create a temporary `HOME`. +4. Run: ```bash HOME="$tmp_home" bash scripts/installation/install-qwen-with-source.sh \ @@ -24,11 +25,13 @@ Run from the repository root after `npm run bundle && npm run prepare:package`. --source github ``` -4. Expected: +5. Expected: - `$tmp_home/.local/lib/qwen-code` exists. - `$tmp_home/.local/bin/qwen` exists and is executable. - `$tmp_home/.qwen/source.json` contains `{"source":"github"}`. - Installer does not write `.bashrc`, `.zshrc`, `.npmrc`, or npm prefix. + - Installer rejects the same archive after tampering with its contents. + - Installer rejects the archive if `SHA256SUMS` is missing. ### Unix npm Fallback @@ -74,7 +77,7 @@ For a release candidate: 1. Download `SHA256SUMS` and all archives from GitHub Release. 2. Verify checksums locally. -3. Sync the same files to OSS/CDN. +3. Sync the same files to OSS/CDN under both `vX.Y.Z/` and `latest/`. 4. Download one archive from GitHub and one from OSS/CDN. 5. Confirm byte-identical checksums. 6. Run installer with: diff --git a/README.md b/README.md index edae546ef55..03e3d439507 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > **Note**: It's recommended to restart your terminal after installation if > `qwen` is not immediately available on PATH. For offline installation, download > a release archive such as `qwen-code-linux-x64.tar.gz` or -> `qwen-code-win-x64.zip`, then run the installer with `--archive PATH`. +> `qwen-code-win-x64.zip` plus `SHA256SUMS`, then run the installer with +> `--archive PATH`. ### Manual Installation diff --git a/docs/users/overview.md b/docs/users/overview.md index 2bfee0b7a18..a40753d7605 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -31,7 +31,7 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > immediately available on PATH. If the installation fails, please refer to > [Manual Installation](./quickstart#manual-installation) in the Quickstart > guide. For offline installation, download a release archive and run the -> installer with `--archive PATH`. +> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. ### Start using Qwen Code: diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 10840cbd4fa..aba0a8c07ca 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -38,8 +38,8 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > > It's recommended to restart your terminal after installation if `qwen` is not > immediately available on PATH. For offline installation, download a release -> archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip`, then -> run the installer with `--archive PATH`. +> archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip` plus +> `SHA256SUMS`, then run the installer with `--archive PATH`. ### Manual Installation diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 6da9ef6dbb4..9116d733f93 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -2,7 +2,7 @@ /** * @license - * Copyright 2025 Qwen + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -343,6 +343,12 @@ function writeSha256Sums(outDir) { ) .sort(); + if (entries.length === 0) { + fail( + `No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`, + ); + } + const lines = entries.map((entry) => { const filePath = path.join(outDir, entry); const hash = crypto diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 2df19c22ba0..21a2b52f2b3 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -56,7 +56,7 @@ The default method is `detect`: 1. Detect the current platform. 2. Try to download and install the matching standalone archive. -3. Verify the archive with `SHA256SUMS` when available. +3. Verify the archive with `SHA256SUMS`. 4. Fall back to npm if the standalone archive is not available. You can force a method: @@ -98,6 +98,7 @@ bash install-qwen-with-source.sh --method npm --registry https://registry.npmjs. bash install-qwen-with-source.sh --mirror aliyun # Install an offline archive +# SHA256SUMS must be in the same directory. bash install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz ``` @@ -125,6 +126,7 @@ REM Use the Aliyun standalone mirror install-qwen-with-source.bat --mirror aliyun REM Install an offline archive +REM SHA256SUMS must be in the same directory. install-qwen-with-source.bat --archive qwen-code-win-x64.zip ``` @@ -133,6 +135,9 @@ Standalone installs to: - Runtime: `%LOCALAPPDATA%\qwen-code\qwen-code` - Shim: `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` +Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_DIR`, or +`QWEN_INSTALL_BIN_DIR` when needed. + Restart the terminal if `qwen` is not immediately available on PATH. ## Mirrors and Overrides @@ -157,7 +162,12 @@ Environment variables: - `QWEN_NPM_REGISTRY` Use `--base-url` for private mirrors. The URL must contain -`qwen-code-` archives and `SHA256SUMS` in the same directory. +`qwen-code-` archives and `SHA256SUMS` in the same directory. Custom +base URLs must use `https://`. + +For Aliyun OSS/CDN, release publishing must upload byte-identical artifacts to +both the versioned directory, for example `v0.16.0/`, and the `latest/` +directory used by the default installer path. ## Supported Source Values diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index def861137ed..9826579dbb4 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -16,8 +16,11 @@ if not "%QWEN_INSTALL_VERSION%"=="" set "VERSION=%QWEN_INSTALL_VERSION%" set "NPM_REGISTRY=https://registry.npmmirror.com" if not "%QWEN_NPM_REGISTRY%"=="" set "NPM_REGISTRY=%QWEN_NPM_REGISTRY%" set "INSTALL_BASE=%LOCALAPPDATA%\qwen-code" +if not "%QWEN_INSTALL_ROOT%"=="" set "INSTALL_BASE=%QWEN_INSTALL_ROOT%" set "INSTALL_DIR=%INSTALL_BASE%\qwen-code" +if not "%QWEN_INSTALL_LIB_DIR%"=="" set "INSTALL_DIR=%QWEN_INSTALL_LIB_DIR%" set "INSTALL_BIN_DIR=%INSTALL_BASE%\bin" +if not "%QWEN_INSTALL_BIN_DIR%"=="" set "INSTALL_BIN_DIR=%QWEN_INSTALL_BIN_DIR%" :parse_args if "%~1"=="" goto end_parse @@ -164,6 +167,7 @@ if !STANDALONE_STATUS! EQU 2 ( exit /b 0 ) +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. exit /b !STANDALONE_STATUS! :usage @@ -216,9 +220,21 @@ echo ERROR: --mirror must be github or aliyun. exit /b 1 :validate_mirror_ok +call :ValidateHttpsUrl "!BASE_URL!" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + call :ValidateSource exit /b %ERRORLEVEL% +:ValidateHttpsUrl +set "URL_VALUE=%~1" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 + :ValidateSource if "!SOURCE!"=="unknown" exit /b 0 echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul @@ -268,15 +284,20 @@ set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/! exit /b 0 :UrlExists -set "CHECK_URL=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$request = [Net.WebRequest]::Create('%CHECK_URL%'); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 -exit /b %ERRORLEVEL% +set "QWEN_CHECK_URL=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% :DownloadFile -set "DOWNLOAD_URL=%~1" -set "DOWNLOAD_DEST=%~2" -powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%DOWNLOAD_DEST%')" -exit /b %ERRORLEVEL% +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST)" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% :VerifyChecksum set "ARCHIVE_FILE=%~1" @@ -284,13 +305,12 @@ set "CHECKSUM_SOURCE=%~2" set "ARCHIVE_NAME=%~3" set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" set "TEMP_CHECKSUM=" -set "REQUIRE_CHECKSUM=0" +set "REQUIRE_CHECKSUM=1" if "!CHECKSUM_FILE!"=="" ( for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" ) else ( - echo !CHECKSUM_FILE!| findstr /R /C:"^https*://" >nul - if !ERRORLEVEL! EQU 0 ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( set "REQUIRE_CHECKSUM=1" set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" @@ -305,7 +325,7 @@ if "!CHECKSUM_FILE!"=="" ( if not exist "!CHECKSUM_FILE!" ( if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: SHA256SUMS not found; cannot verify remote archive. + echo ERROR: SHA256SUMS not found; cannot verify archive. exit /b 1 ) echo WARNING: SHA256SUMS not found; skipping checksum verification. @@ -336,7 +356,7 @@ if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 if "!ACTUAL_HASH!"=="" ( if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Could not calculate SHA-256 checksum for remote archive. + echo ERROR: Could not calculate SHA-256 checksum for archive. exit /b 1 ) echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. @@ -405,13 +425,24 @@ if !ERRORLEVEL! NEQ 0 ( set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 -powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath '%ARCHIVE_FILE%' -DestinationPath '%EXTRACT_DIR%' -Force" -if !ERRORLEVEL! NEQ 0 ( +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to extract standalone archive. exit /b 1 ) +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. @@ -426,9 +457,28 @@ if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( if not exist "!INSTALL_BASE!" mkdir "!INSTALL_BASE!" if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +if not exist "!INSTALL_PARENT!" mkdir "!INSTALL_PARENT!" set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul @@ -438,7 +488,14 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -if exist "!INSTALL_DIR!" move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul @@ -462,6 +519,23 @@ echo SUCCESS: Qwen Code standalone archive installed successfully. echo INFO: Installed to !INSTALL_DIR! exit /b 0 +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +if not exist "!MANAGED_DIR!" exit /b 0 +if exist "!MANAGED_DIR!\manifest.json" exit /b 0 + +echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. +exit /b 1 + :RequireNode where node >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 6e60c6b5d08..2ec73f9655b 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -86,9 +86,14 @@ ARCHIVE_PATH="${QWEN_INSTALL_ARCHIVE:-}" VERSION="${QWEN_INSTALL_VERSION:-latest}" NPM_REGISTRY="${QWEN_NPM_REGISTRY:-https://registry.npmmirror.com}" INSTALL_ROOT="${QWEN_INSTALL_ROOT:-${HOME:-}/.local}" -INSTALL_LIB_PARENT="${QWEN_INSTALL_LIB_PARENT:-${INSTALL_ROOT}/lib}" +if [[ -n "${QWEN_INSTALL_LIB_DIR:-}" ]]; then + INSTALL_LIB_DIR="${QWEN_INSTALL_LIB_DIR}" + INSTALL_LIB_PARENT="$(dirname "${INSTALL_LIB_DIR}")" +else + INSTALL_LIB_PARENT="${QWEN_INSTALL_LIB_PARENT:-${INSTALL_ROOT}/lib}" + INSTALL_LIB_DIR="${INSTALL_LIB_PARENT}/qwen-code" +fi INSTALL_BIN_DIR="${QWEN_INSTALL_BIN_DIR:-${INSTALL_ROOT}/bin}" -INSTALL_LIB_DIR="${QWEN_INSTALL_LIB_DIR:-${INSTALL_LIB_PARENT}/qwen-code}" validate_source() { if [[ "${SOURCE}" == "unknown" ]]; then @@ -103,6 +108,22 @@ validate_source() { exit 1 } +validate_https_url() { + local value="$1" + local option_name="$2" + + if [[ -z "${value}" ]]; then + return 0 + fi + + if [[ "${value}" == https://* ]]; then + return 0 + fi + + log_error "${option_name} must start with https://" + exit 1 +} + validate_options() { METHOD="${METHOD:-detect}" @@ -124,6 +145,7 @@ validate_options() { ;; esac + validate_https_url "${BASE_URL}" "--base-url" validate_source } @@ -158,6 +180,7 @@ while [[ $# -gt 0 ]]; do log_error "--base-url requires a value" exit 1 fi + validate_https_url "$2" "--base-url" BASE_URL="$2" shift 2 ;; @@ -467,12 +490,10 @@ verify_checksum() { local archive_name="$3" local checksum_file="${checksum_source}" local temp_checksum="" - local checksum_required="false" if [[ -z "${checksum_file}" ]]; then checksum_file="$(dirname "${archive_path}")/SHA256SUMS" elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then - checksum_required="true" temp_checksum="$(mktemp)" if ! download_file "${checksum_file}" "${temp_checksum}"; then rm -f "${temp_checksum}" @@ -483,35 +504,24 @@ verify_checksum() { fi if [[ ! -f "${checksum_file}" ]]; then - if [[ "${checksum_required}" == "true" ]]; then - log_error "SHA256SUMS not found; cannot verify remote archive." - return 1 - fi - log_warning "SHA256SUMS not found; skipping checksum verification." - return 0 + rm -f "${temp_checksum}" + log_error "SHA256SUMS not found; cannot verify archive." + return 1 fi local expected expected=$(grep -E "(^|[[:space:]])[*]?${archive_name}$" "${checksum_file}" | awk '{print $1}' | head -n 1) if [[ -z "${expected}" ]]; then rm -f "${temp_checksum}" - if [[ "${checksum_required}" == "true" ]]; then - log_error "Checksum entry for ${archive_name} not found." - return 1 - fi - log_warning "Checksum entry for ${archive_name} not found; skipping checksum verification." - return 0 + log_error "Checksum entry for ${archive_name} not found." + return 1 fi local actual if ! actual=$(sha256_file "${archive_path}"); then rm -f "${temp_checksum}" - if [[ "${checksum_required}" == "true" ]]; then - log_error "No SHA-256 utility found; cannot verify remote archive." - return 1 - fi - log_warning "No SHA-256 utility found; skipping checksum verification." - return 0 + log_error "No SHA-256 utility found; cannot verify archive." + return 1 fi rm -f "${temp_checksum}" @@ -546,6 +556,29 @@ extract_archive() { return 1 ;; esac + + local symlink_entry + symlink_entry=$(find "${destination}" -type l -print -quit) + if [[ -n "${symlink_entry}" ]]; then + log_error "Archive contains symlinks; refusing to install." + return 1 + fi +} + +ensure_managed_install_dir() { + local install_dir="$1" + + if [[ ! -e "${install_dir}" ]]; then + return 0 + fi + + if [[ -f "${install_dir}/manifest.json" ]]; then + return 0 + fi + + log_error "${install_dir} exists but is not a Qwen Code standalone install." + log_error "Refusing to overwrite it. Move or remove it manually, then rerun the installer." + return 1 } install_standalone() { @@ -608,13 +641,13 @@ install_standalone() { return 1 fi - if [[ ! -x "${extract_dir}/qwen-code/bin/qwen" ]]; then + if [[ ! -f "${extract_dir}/qwen-code/bin/qwen" || -L "${extract_dir}/qwen-code/bin/qwen" || ! -x "${extract_dir}/qwen-code/bin/qwen" ]]; then log_error "Archive does not contain qwen-code/bin/qwen." rm -rf "${temp_dir}" return 1 fi - if [[ ! -x "${extract_dir}/qwen-code/node/bin/node" ]]; then + if [[ ! -f "${extract_dir}/qwen-code/node/bin/node" || -L "${extract_dir}/qwen-code/node/bin/node" || ! -x "${extract_dir}/qwen-code/node/bin/node" ]]; then log_error "Archive does not contain executable qwen-code/node/bin/node." rm -rf "${temp_dir}" return 1 @@ -624,6 +657,12 @@ install_standalone() { local new_install_dir="${INSTALL_LIB_DIR}.new" local old_install_dir="${INSTALL_LIB_DIR}.old" + if ! ensure_managed_install_dir "${INSTALL_LIB_DIR}" || + ! ensure_managed_install_dir "${new_install_dir}" || + ! ensure_managed_install_dir "${old_install_dir}"; then + rm -rf "${temp_dir}" + return 1 + fi rm -rf "${new_install_dir}" "${old_install_dir}" mv "${extract_dir}/qwen-code" "${new_install_dir}" @@ -752,6 +791,7 @@ main() { install_npm print_final_instructions "$(get_npm_global_bin)" else + log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." exit "${standalone_status}" fi fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 4fe0348506b..ed07626dc8f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -7,17 +7,22 @@ import { describe, expect, it, vi } from 'vitest'; const { + appendFileSync, + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } = await vi.importActual('node:fs'); const { execFileSync } = await vi.importActual('node:child_process'); +const crypto = await vi.importActual('node:crypto'); const { tmpdir } = await vi.importActual('node:os'); const path = await vi.importActual('node:path'); const readScript = (path) => readFileSync(path, 'utf8'); +const itOnUnix = process.platform === 'win32' ? it.skip : it; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { @@ -60,17 +65,21 @@ describe('installation scripts', () => { expect(script).toContain('install_npm()'); expect(script).toContain('detect_target()'); expect(script).toContain('verify_checksum()'); - expect(script).toContain( - 'SHA256SUMS not found; cannot verify remote archive', - ); + expect(script).toContain('SHA256SUMS not found; cannot verify archive'); expect(script).toContain('qwen-code-${target}'); expect(script).toContain('METHOD="${METHOD:-detect}"'); + expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); expect(script).toContain('standalone_status=$?'); expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]'); + expect(script).toContain( + 'Standalone install failed. Retry with --method npm', + ); expect(script).not.toContain('ln -sf "${INSTALL_LIB_DIR}/bin/qwen"'); expect(script).toContain('exec "${INSTALL_LIB_DIR}/bin/qwen"'); expect(script).toContain('qwen-code/node/bin/node'); + expect(script).toContain('Archive contains symlinks; refusing to install'); + expect(script).toContain('not a Qwen Code standalone install'); }); it('keeps the Windows installer lightweight', () => { @@ -110,15 +119,21 @@ describe('installation scripts', () => { expect(script).toContain(':InstallStandalone'); expect(script).toContain(':InstallNpm'); expect(script).toContain(':VerifyChecksum'); - expect(script).toContain( - 'SHA256SUMS not found; cannot verify remote archive', - ); + expect(script).toContain('SHA256SUMS not found; cannot verify archive'); expect(script).toContain('qwen-code-win-x64.zip'); expect(script).toContain('Expand-Archive'); + expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); + expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); + expect(script).toContain('must start with https://'); 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( + 'Standalone install failed. Retry with --method npm', + ); expect(script).toContain('qwen-code\\node\\node.exe'); + expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('QWEN_INSTALL_ROOT'); }); }); @@ -135,6 +150,7 @@ describe('standalone release packaging', () => { expect(packageScript).toContain("'bundled/qc-helper/docs'"); expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); + expect(packageScript).toContain('refusing to write empty SHA256SUMS'); }); it('rejects a runtime archive without a Node executable', () => { @@ -188,6 +204,7 @@ describe('standalone release packaging', () => { expect(workflow).toContain('$2 == name'); expect(workflow).toContain('does not list ${archive_name}'); expect(workflow).toContain('sha256sum -c -'); + expect(workflow).toContain('Expected 5 standalone checksums'); expect(workflow).toContain('npm run package:standalone'); expect(workflow).toContain('dist/standalone/qwen-code-*'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); @@ -202,6 +219,134 @@ describe('standalone release packaging', () => { }); }); +describe('Linux/macOS installer end-to-end', () => { + itOnUnix( + 'installs a local standalone archive with checksum verification', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); + expect( + existsSync( + path.join(installRoot, 'lib', 'qwen-code', 'node', 'bin', 'node'), + ), + ).toBe(true); + expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain( + '"source": "smoke"', + ); + + const version = execFileSync(path.join(installRoot, 'bin', 'qwen'), [ + '--version', + ]) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + ); + + itOnUnix('rejects a tampered local archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + appendFileSync(archive, 'tamper'); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects a local archive when SHA256SUMS is missing', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/SHA256SUMS not found/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects standalone archives containing symlinks', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createSymlinkStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains symlinks/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnUnix('refuses to overwrite a non-managed install directory', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'lib', 'qwen-code'); + mkdirSync(installDir, { recursive: true }); + writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); + + expect(() => + runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), + ).toThrow(/not a Qwen Code standalone install/); + expect(readScript(path.join(installDir, 'important.txt'))).toBe( + 'keep me\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); +}); + function ensureMinimalDist() { if (existsSync('dist')) { return false; @@ -216,3 +361,119 @@ function ensureMinimalDist() { ); return true; } + +function createFakeNodeArchive(tmpDir) { + const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64'); + mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true }); + writeFileSync( + path.join(fakeNodeDir, 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(fakeNodeDir, 'bin', 'node'), 0o755); + + const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); + execFileSync( + 'tar', + ['-czf', archive, '-C', tmpDir, path.basename(fakeNodeDir)], + { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }, + ); + return archive; +} + +function packageFakeStandalone(tmpDir) { + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir), + '--out-dir', + outDir, + '--version', + '0.0.0-smoke', + ], + { stdio: 'pipe' }, + ); + return path.join(outDir, 'qwen-code-linux-x64.tar.gz'); +} + +function runUnixInstaller(archive, installRoot, home) { + mkdirSync(home, { recursive: true }); + try { + return execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + 'standalone', + '--archive', + archive, + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function createSymlinkStandaloneArchive(tmpDir) { + const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true }); + symlinkSync('/usr/bin/env', path.join(packageRoot, 'bin', 'qwen')); + writeFileSync( + path.join(packageRoot, 'node', 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.tar.gz'); + execFileSync( + 'tar', + ['-czf', archive, '-C', path.dirname(packageRoot), 'qwen-code'], + { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }, + ); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + +function writeChecksumFile(outDir, archiveName) { + const archive = path.join(outDir, archiveName); + const hash = crypto + .createHash('sha256') + .update(readFileSync(archive)) + .digest('hex'); + writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); +} From d7277a697688a3de96a1ecf5f6a7c90c48142c5e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 21:07:14 +0800 Subject: [PATCH 003/129] fix(installer): address standalone review findings --- .github/workflows/release.yml | 28 +- scripts/create-standalone-package.js | 141 +++++++- .../installation/install-qwen-with-source.bat | 2 +- .../installation/install-qwen-with-source.sh | 5 +- scripts/tests/install-script.test.js | 323 +++++++++++++++++- 5 files changed, 485 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd6287a1636..8eea0d67754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -391,6 +391,32 @@ jobs: mkdir -p "${RUNTIME_DIR}" curl -fsSL "${NODE_DIST_URL}/SHASUMS256.txt" -o "${RUNTIME_DIR}/SHASUMS256.txt" + verify_node_checksum() { + local checksum_line="$1" + + if command -v sha256sum >/dev/null 2>&1; then + printf '%s\n' "${checksum_line}" | (cd "${RUNTIME_DIR}" && sha256sum -c -) + return + fi + + if command -v shasum >/dev/null 2>&1; then + local expected + local archive_name + local actual + expected="$(printf '%s\n' "${checksum_line}" | awk '{ print $1 }')" + archive_name="$(printf '%s\n' "${checksum_line}" | awk '{ print $2 }')" + actual="$(shasum -a 256 "${RUNTIME_DIR}/${archive_name}" | awk '{ print $1 }')" + if [[ "${expected}" != "${actual}" ]]; then + echo "::error::Checksum verification failed for ${archive_name}" + exit 1 + fi + return + fi + + echo "::error::No SHA-256 utility found to verify Node.js runtime" + exit 1 + } + download_node() { local qwen_target="$1" local node_target="$2" @@ -408,7 +434,7 @@ jobs: echo "::error::Node.js SHASUMS256.txt does not list ${archive_name}" exit 1 fi - printf '%s\n' "${checksum_line}" | (cd "${RUNTIME_DIR}" && sha256sum -c -) + verify_node_checksum "${checksum_line}" npm run package:standalone -- \ --target "${qwen_target}" \ --node-archive "${archive}" \ diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 9116d733f93..fe1ee5e28bd 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -228,7 +228,7 @@ function copyRuntimeAssets(packageRoot) { function extractNodeArchive(nodeArchive, extractDir) { if (nodeArchive.endsWith('.zip')) { - run('unzip', ['-q', nodeArchive, '-d', extractDir]); + extractZipArchive(nodeArchive, extractDir); return; } @@ -246,6 +246,31 @@ function extractNodeArchive(nodeArchive, extractDir) { ); } +function extractZipArchive(nodeArchive, extractDir) { + if (process.platform === 'win32') { + run( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Expand-Archive -LiteralPath $env:QWEN_NODE_ARCHIVE -DestinationPath $env:QWEN_EXTRACT_DIR -Force', + ], + { + env: { + ...process.env, + QWEN_NODE_ARCHIVE: nodeArchive, + QWEN_EXTRACT_DIR: extractDir, + }, + }, + ); + return; + } + + run('unzip', ['-q', nodeArchive, '-d', extractDir]); +} + function copyExtractedNode(extractDir, nodeDir) { const entries = fs .readdirSync(extractDir) @@ -260,10 +285,89 @@ function copyExtractedNode(extractDir, nodeDir) { ? path.join(extractDir, entries[0]) : extractDir; - fs.cpSync(sourceRoot, nodeDir, { - recursive: true, - verbatimSymlinks: true, - }); + assertSymlinksStayInside(sourceRoot); + copyDereferenced(sourceRoot, nodeDir); + assertNoSymlinks(nodeDir, 'Copied Node.js runtime still contains symlinks.'); +} + +function copyDereferenced(source, destination) { + const stat = fs.statSync(source); + + if (stat.isDirectory()) { + fs.mkdirSync(destination, { recursive: true }); + fs.chmodSync(destination, stat.mode); + for (const entry of fs.readdirSync(source)) { + copyDereferenced(path.join(source, entry), path.join(destination, entry)); + } + return; + } + + if (stat.isFile()) { + fs.copyFileSync(source, destination); + fs.chmodSync(destination, stat.mode); + return; + } + + fail(`Unsupported Node.js runtime entry type: ${source}`); +} + +function assertSymlinksStayInside(root) { + const realRoot = fs.realpathSync(root); + + for (const entry of walkDirectory(root)) { + if (!fs.lstatSync(entry).isSymbolicLink()) { + continue; + } + + const target = fs.readlinkSync(entry); + const resolvedTarget = path.resolve(path.dirname(entry), target); + let realTarget; + try { + realTarget = fs.realpathSync(resolvedTarget); + } catch { + fail( + `Node.js runtime symlink points to a missing target: ${path.relative( + root, + entry, + )} -> ${target}`, + ); + } + + if (!isPathInside(realRoot, realTarget)) { + fail( + `Node.js runtime symlink escapes the archive: ${path.relative( + root, + entry, + )} -> ${target}`, + ); + } + } +} + +function assertNoSymlinks(root, message) { + for (const entry of walkDirectory(root)) { + if (fs.lstatSync(entry).isSymbolicLink()) { + fail(`${message} First symlink: ${path.relative(root, entry)}`); + } + } +} + +function* walkDirectory(root) { + for (const entry of fs.readdirSync(root)) { + const fullPath = path.join(root, entry); + yield fullPath; + if (fs.lstatSync(fullPath).isDirectory()) { + yield* walkDirectory(fullPath); + } + } +} + +function isPathInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); } function validateNodeRuntime(target, nodeDir) { @@ -326,13 +430,38 @@ function writeManifest(packageRoot, manifest) { function createArchive(outputExtension, outputPath, cwd) { if (outputExtension === 'zip') { - run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); + createZipArchive(outputPath, cwd); return; } run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); } +function createZipArchive(outputPath, cwd) { + if (process.platform === 'win32') { + run( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force', + ], + { + env: { + ...process.env, + QWEN_PACKAGE_ROOT: path.join(cwd, 'qwen-code'), + QWEN_OUTPUT_PATH: outputPath, + }, + }, + ); + return; + } + + run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); +} + function writeSha256Sums(outDir) { const entries = fs .readdirSync(outDir) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 9826579dbb4..615a4131cb1 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -285,7 +285,7 @@ exit /b 0 :UrlExists set "QWEN_CHECK_URL=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 set "PS_STATUS=%ERRORLEVEL%" set "QWEN_CHECK_URL=" exit /b %PS_STATUS% diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 2ec73f9655b..73f432e59d2 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -551,6 +551,9 @@ extract_archive() { *.tar.gz|*.tgz) tar -xzf "${archive_path}" -C "${destination}" ;; + *.tar.xz) + tar -xf "${archive_path}" -C "${destination}" + ;; *) log_error "Unsupported archive format: ${archive_path}" return 1 @@ -558,7 +561,7 @@ extract_archive() { esac local symlink_entry - symlink_entry=$(find "${destination}" -type l -print -quit) + symlink_entry=$(find "${destination}" -type l -print | sed -n '1p') if [[ -n "${symlink_entry}" ]]; then log_error "Archive contains symlinks; refusing to install." return 1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index ed07626dc8f..6935c2bd5b9 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -10,6 +10,7 @@ const { appendFileSync, chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -67,6 +68,7 @@ describe('installation scripts', () => { expect(script).toContain('verify_checksum()'); expect(script).toContain('SHA256SUMS not found; cannot verify archive'); expect(script).toContain('qwen-code-${target}'); + expect(script).toContain('*.tar.xz)'); expect(script).toContain('METHOD="${METHOD:-detect}"'); expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); @@ -80,6 +82,7 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); + expect(script).not.toContain('-print -quit'); }); it('keeps the Windows installer lightweight', () => { @@ -124,6 +127,9 @@ describe('installation scripts', () => { expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); + expect(script).toContain( + '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + ); expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); @@ -150,7 +156,11 @@ describe('standalone release packaging', () => { expect(packageScript).toContain("'bundled/qc-helper/docs'"); expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); + expect(packageScript).toContain('assertSymlinksStayInside'); + expect(packageScript).toContain('copyDereferenced'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); + expect(packageScript).toContain('Expand-Archive'); + expect(packageScript).toContain('Compress-Archive'); }); it('rejects a runtime archive without a Node executable', () => { @@ -196,6 +206,112 @@ describe('standalone release packaging', () => { } }); + it('packages a win-x64 standalone archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const outDir = path.join(tmpDir, 'out'); + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'win-x64', + '--node-archive', + createFakeWindowsNodeArchive(tmpDir), + '--out-dir', + outDir, + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + const extractDir = path.join(tmpDir, 'extract'); + mkdirSync(extractDir, { recursive: true }); + extractZipForTest(archive, extractDir); + + expect(existsSync(path.join(extractDir, 'qwen-code'))).toBe(true); + expect( + existsSync(path.join(extractDir, 'qwen-code', 'bin', 'qwen.cmd')), + ).toBe(true); + expect( + existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')), + ).toBe(true); + expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain( + 'qwen-code-win-x64.zip', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('dereferences safe Node.js runtime symlinks', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const archive = packageFakeStandalone(tmpDir, { + withSafeNodeSymlink: true, + }); + const installRoot = path.join(tmpDir, 'install'); + runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')); + + const npmShim = path.join( + installRoot, + 'lib', + 'qwen-code', + 'node', + 'bin', + 'npm', + ); + expect(existsSync(npmShim)).toBe(true); + expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir, { + withEscapingNodeSymlink: true, + }), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/symlink escapes the archive/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); @@ -203,7 +319,9 @@ describe('standalone release packaging', () => { expect(workflow).toContain('SHASUMS256.txt'); expect(workflow).toContain('$2 == name'); expect(workflow).toContain('does not list ${archive_name}'); + expect(workflow).toContain('verify_node_checksum()'); expect(workflow).toContain('sha256sum -c -'); + expect(workflow).toContain('shasum -a 256'); expect(workflow).toContain('Expected 5 standalone checksums'); expect(workflow).toContain('npm run package:standalone'); expect(workflow).toContain('dist/standalone/qwen-code-*'); @@ -345,6 +463,122 @@ describe('Linux/macOS installer end-to-end', () => { } } }); + + itOnUnix('does not fall back to npm when detect finds a bad archive', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + appendFileSync(archive, 'tamper'); + + let failureMessage = ''; + try { + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'detect', + ); + } catch (error) { + failureMessage = error.message; + } + + expect(failureMessage).toContain('Checksum verification failed'); + expect(failureMessage).toContain('Standalone install failed'); + expect(failureMessage).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + itOnUnix( + 'falls back to npm in detect mode when archive is unavailable', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const home = path.join(tmpDir, 'home'); + const npmLog = path.join(tmpDir, 'npm-args.txt'); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + + writeFileSync( + path.join(fakeBin, 'curl'), + '#!/usr/bin/env sh\nexit 22\n', + ); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then', + ' case "$2" in', + ' *split*) echo 20 ;;', + ' *) echo 20.19.0 ;;', + ' esac', + ' exit 0', + 'fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -v) echo 10.0.0 ;;', + ' prefix) echo "$QWEN_FAKE_NPM_PREFIX" ;;', + ' install) printf "%s\\n" "$*" > "$QWEN_FAKE_NPM_LOG" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'qwen'), + '#!/usr/bin/env sh\necho 0.0.0-npm\n', + ); + for (const command of ['curl', 'node', 'npm', 'qwen']) { + chmodSync(path.join(fakeBin, command), 0o755); + } + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + }, + stdio: 'pipe', + }, + ).toString(); + + expect(output).toContain('Falling back to npm installation'); + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@latest --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); }); function ensureMinimalDist() { @@ -362,7 +596,7 @@ function ensureMinimalDist() { return true; } -function createFakeNodeArchive(tmpDir) { +function createFakeNodeArchive(tmpDir, options = {}) { const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64'); mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true }); writeFileSync( @@ -371,6 +605,18 @@ function createFakeNodeArchive(tmpDir) { ); chmodSync(path.join(fakeNodeDir, 'bin', 'node'), 0o755); + if (options.withSafeNodeSymlink) { + mkdirSync(path.join(fakeNodeDir, 'lib'), { recursive: true }); + writeFileSync(path.join(fakeNodeDir, 'lib', 'npm-cli.js'), 'npm cli\n'); + symlinkSync('../lib/npm-cli.js', path.join(fakeNodeDir, 'bin', 'npm')); + } + + if (options.withEscapingNodeSymlink) { + const outsideTarget = path.join(tmpDir, 'outside-node-helper.js'); + writeFileSync(outsideTarget, 'outside\n'); + symlinkSync(outsideTarget, path.join(fakeNodeDir, 'bin', 'npm')); + } + const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); execFileSync( 'tar', @@ -383,7 +629,74 @@ function createFakeNodeArchive(tmpDir) { return archive; } -function packageFakeStandalone(tmpDir) { +function createFakeWindowsNodeArchive(tmpDir) { + const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64'); + mkdirSync(fakeNodeDir, { recursive: true }); + writeFileSync(path.join(fakeNodeDir, 'node.exe'), 'fake node.exe\n'); + + const archive = path.join(tmpDir, 'node-v20.0.0-win-x64.zip'); + createZipForTest(archive, tmpDir, path.basename(fakeNodeDir)); + return archive; +} + +function createZipForTest(archive, cwd, entry) { + if (process.platform === 'win32') { + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force', + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ENTRY: path.join(cwd, entry), + QWEN_TEST_ZIP_ARCHIVE: archive, + }, + stdio: 'ignore', + }, + ); + return; + } + + execFileSync('zip', ['-qr', archive, entry], { + cwd, + stdio: 'ignore', + }); +} + +function extractZipForTest(archive, destination) { + if (process.platform === 'win32') { + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'Expand-Archive -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -DestinationPath $env:QWEN_TEST_ZIP_DESTINATION -Force', + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ARCHIVE: archive, + QWEN_TEST_ZIP_DESTINATION: destination, + }, + stdio: 'ignore', + }, + ); + return; + } + + execFileSync('unzip', ['-q', archive, '-d', destination], { + stdio: 'ignore', + }); +} + +function packageFakeStandalone(tmpDir, nodeArchiveOptions = {}) { const outDir = path.join(tmpDir, 'out'); mkdirSync(outDir, { recursive: true }); execFileSync( @@ -393,7 +706,7 @@ function packageFakeStandalone(tmpDir) { '--target', 'linux-x64', '--node-archive', - createFakeNodeArchive(tmpDir), + createFakeNodeArchive(tmpDir, nodeArchiveOptions), '--out-dir', outDir, '--version', @@ -404,7 +717,7 @@ function packageFakeStandalone(tmpDir) { return path.join(outDir, 'qwen-code-linux-x64.tar.gz'); } -function runUnixInstaller(archive, installRoot, home) { +function runUnixInstaller(archive, installRoot, home, method = 'standalone') { mkdirSync(home, { recursive: true }); try { return execFileSync( @@ -412,7 +725,7 @@ function runUnixInstaller(archive, installRoot, home) { [ 'scripts/installation/install-qwen-with-source.sh', '--method', - 'standalone', + method, '--archive', archive, '--source', From 571959532be7a0ad92a0fa690379d83f1a83ad5a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 21:11:50 +0800 Subject: [PATCH 004/129] chore(installer): clarify review followups --- scripts/create-standalone-package.js | 3 +++ .../installation/install-qwen-with-source.bat | 24 +++++++------------ scripts/tests/install-script.test.js | 3 +++ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index fe1ee5e28bd..43482036574 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -285,6 +285,9 @@ function copyExtractedNode(extractDir, nodeDir) { ? path.join(extractDir, entries[0]) : extractDir; + // Official Unix Node.js archives include internal npm/npx symlinks. + // The installer rejects symlinks in final archives, so keep safe internal + // targets by copying their referents and reject any symlink that escapes. assertSymlinksStayInside(sourceRoot); copyDereferenced(sourceRoot, nodeDir); assertNoSymlinks(nodeDir, 'Copied Node.js runtime still contains symlinks.'); diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 615a4131cb1..46c4bf1853b 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -171,38 +171,30 @@ echo WARNING: Standalone install failed. Retry with --method npm to use npm, or exit /b !STANDALONE_STATUS! :usage -echo Qwen Code Installer -echo. -echo Usage: install-qwen-with-source.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: github or aliyun. -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 -h, --help Show this help message. +call :PrintUsage exit /b 0 :usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage echo Qwen Code Installer echo. echo Usage: install-qwen-with-source.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: github or aliyun. 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 -h, --help Show this help message. -exit /b 1 +exit /b 0 :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 6935c2bd5b9..508b9ea7af0 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -23,6 +23,8 @@ const crypto = await vi.importActual('node:crypto'); const { tmpdir } = await vi.importActual('node:os'); const path = await vi.importActual('node:path'); const readScript = (path) => readFileSync(path, 'utf8'); +// These E2E cases execute the Unix shell installer and POSIX symlink behavior. +// Windows batch behavior is covered by static checks plus win-x64 packaging. const itOnUnix = process.platform === 'win32' ? it.skip : it; describe('installation scripts', () => { @@ -98,6 +100,7 @@ describe('installation scripts', () => { expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); expect(script).toContain(':ValidateSource'); + expect(script).toContain(':PrintUsage'); expect(script).toContain('findstr /R'); expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', From 472e51eb193726710689da7602bfb43558d9eb92 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 22:52:44 +0800 Subject: [PATCH 005/129] fix(installer): stabilize standalone script checks --- .../installation/install-qwen-with-source.bat | 9 +++- scripts/tests/install-script.test.js | 50 +++++++++++++------ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 46c4bf1853b..4adedf3a062 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -325,8 +325,13 @@ if not exist "!CHECKSUM_FILE!" ( ) set "EXPECTED_HASH=" -for /f "tokens=1" %%H in ('findstr /C:"!ARCHIVE_NAME!" "!CHECKSUM_FILE!"') do ( - if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=%%H" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) ) if "!EXPECTED_HASH!"=="" ( diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 508b9ea7af0..a44e440a94f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -126,6 +126,10 @@ describe('installation scripts', () => { expect(script).toContain(':InstallNpm'); expect(script).toContain(':VerifyChecksum'); expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain('tokens=1,2'); + expect(script).toContain('CHECKSUM_NAME'); + expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"'); + expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).toContain('qwen-code-win-x64.zip'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); @@ -171,18 +175,11 @@ describe('standalone release packaging', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { - const fakeRuntimeDir = path.join(tmpDir, 'not-node'); - mkdirSync(fakeRuntimeDir, { recursive: true }); - writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); - const fakeRuntimeArchive = path.join(tmpDir, 'bad-runtime.tar.gz'); - execFileSync( - 'tar', - ['-czf', fakeRuntimeArchive, '-C', tmpDir, 'not-node'], - { - env: { ...process.env, LC_ALL: 'C' }, - stdio: 'ignore', - }, - ); + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createBadWindowsNodeArchive(tmpDir) + : createBadUnixNodeArchive(tmpDir); expect(() => execFileSync( @@ -190,7 +187,7 @@ describe('standalone release packaging', () => { [ 'scripts/create-standalone-package.js', '--target', - 'linux-x64', + target, '--node-archive', fakeRuntimeArchive, '--out-dir', @@ -200,7 +197,7 @@ describe('standalone release packaging', () => { ], { stdio: 'pipe' }, ), - ).toThrow(/Node.js runtime for linux-x64 must contain/); + ).toThrow(/Node\.js runtime for .* must contain/); } finally { rmSync(tmpDir, { recursive: true, force: true }); if (createdDist) { @@ -252,7 +249,7 @@ describe('standalone release packaging', () => { rmSync('dist', { recursive: true, force: true }); } } - }); + }, 30_000); itOnUnix('dereferences safe Node.js runtime symlinks', () => { const createdDist = ensureMinimalDist(); @@ -632,6 +629,29 @@ function createFakeNodeArchive(tmpDir, options = {}) { return archive; } +function createBadUnixNodeArchive(tmpDir) { + const fakeRuntimeDir = path.join(tmpDir, 'not-node'); + mkdirSync(fakeRuntimeDir, { recursive: true }); + writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); + + const archive = path.join(tmpDir, 'bad-runtime.tar.gz'); + execFileSync('tar', ['-czf', archive, '-C', tmpDir, 'not-node'], { + env: { ...process.env, LC_ALL: 'C' }, + stdio: 'ignore', + }); + return archive; +} + +function createBadWindowsNodeArchive(tmpDir) { + const fakeRuntimeDir = path.join(tmpDir, 'not-node'); + mkdirSync(fakeRuntimeDir, { recursive: true }); + writeFileSync(path.join(fakeRuntimeDir, 'README.txt'), 'not node\n'); + + const archive = path.join(tmpDir, 'bad-runtime.zip'); + createZipForTest(archive, tmpDir, path.basename(fakeRuntimeDir)); + return archive; +} + function createFakeWindowsNodeArchive(tmpDir) { const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64'); mkdirSync(fakeNodeDir, { recursive: true }); From 47fdfbc64df7932807844c558f1bcabc2d1303ed Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 23:08:29 +0800 Subject: [PATCH 006/129] chore(installer): remove internal planning docs --- .../2026-04-30-standalone-installer-design.md | 147 ------------------ .../2026-04-30-standalone-installer-plan.md | 124 --------------- ...26-04-30-standalone-installer-test-plan.md | 90 ----------- 3 files changed, 361 deletions(-) delete mode 100644 .qwen/design/2026-04-30-standalone-installer-design.md delete mode 100644 .qwen/design/2026-04-30-standalone-installer-plan.md delete mode 100644 .qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md diff --git a/.qwen/design/2026-04-30-standalone-installer-design.md b/.qwen/design/2026-04-30-standalone-installer-design.md deleted file mode 100644 index 762338a019b..00000000000 --- a/.qwen/design/2026-04-30-standalone-installer-design.md +++ /dev/null @@ -1,147 +0,0 @@ -# Standalone Installer Design - -## Problem - -The current one-line installer installs Qwen Code through npm. That keeps the -script small, but it still requires users to bring a working Node.js and npm -environment. This is fragile for less technical users, and it does not support -offline or controlled enterprise installs well. - -Qwen Code already publishes a bundled `dist/cli.js` to GitHub Releases, but the -asset still needs a local Node.js runtime. To remove that dependency, releases -need standalone archives that bundle the Qwen CLI with a private Node.js -runtime and a small launcher. - -## Goals - -- Prefer standalone release archives when they are available. -- Fall back to npm when no standalone asset exists for the requested platform. -- Keep npm installation available explicitly with `--method npm`. -- Support fully offline installs with `--archive /path/to/archive`. -- Support GitHub Releases and an Aliyun OSS/CDN mirror with the same artifact - names and checksums. -- Avoid modifying npm config, shell profiles, or user PATH permanently. -- Never start `qwen` automatically from the installer. - -## Non-Goals - -- Build a single native executable in this change. -- Add geolocation-based mirror selection. -- Install Node.js, NVM, or system packages on behalf of the user. -- Solve code signing or notarization in the first implementation. -- Guarantee parity for optional native modules such as `node-pty` and clipboard - packages. The CLI already degrades when these optional modules are absent; - a later release job can add target-specific `node_modules` if that parity is - required. - -## Artifact Format - -Each release can publish these assets: - -- `qwen-code-darwin-arm64.tar.gz` -- `qwen-code-darwin-x64.tar.gz` -- `qwen-code-linux-arm64.tar.gz` -- `qwen-code-linux-x64.tar.gz` -- `qwen-code-win-x64.zip` -- `SHA256SUMS` - -The asset names intentionally do not include the version. This allows the -installer to use GitHub's `releases/latest/download/` URL without an API -call. Versioned installation is still supported by switching the base URL to -`releases/download/vX.Y.Z`. - -Archive layout: - -```text -qwen-code/ - bin/qwen - bin/qwen.cmd - lib/cli.js - node/... - package.json - README.md - LICENSE - manifest.json -``` - -The Unix launcher executes `node/bin/node ../lib/cli.js`. The Windows launcher -executes `node/node.exe ..\lib\cli.js`. Bundling the full Node distribution is -larger than a single executable, but it is predictable and works with the -existing ESM bundle without requiring a user-managed Node.js installation. - -## Installer Behavior - -`--method detect` is the default: - -1. If `--archive` is provided, install that local archive. -2. Detect OS and architecture. -3. Build an archive URL from the selected mirror/base URL. -4. If the archive exists, download it, verify `SHA256SUMS`, extract it into the - user install directory, and expose `qwen`. -5. If the archive does not exist, fall back to npm. - -Local `--archive` installs also require a `SHA256SUMS` file next to the archive. -This keeps offline installs deterministic without trusting an unchecked tarball. - -`--method standalone` follows the same standalone path, but a missing or failed -standalone asset is fatal. - -`--method npm` skips standalone logic and runs npm installation after checking -that Node.js 20+ and npm are available. - -## Install Locations - -Unix: - -- Runtime: `$HOME/.local/lib/qwen-code` -- Command shim: `$HOME/.local/bin/qwen` - -Windows: - -- Runtime: `%LOCALAPPDATA%\qwen-code\qwen-code` -- Command shim: `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` - -The installer may add the command directory to the current process PATH for -verification, but it does not write shell profiles or persistent environment -variables. If the command directory is not on PATH, the installer prints the -exact directory to add. - -## Distribution Sources - -GitHub is the canonical source: - -```text -https://github.com/QwenLM/qwen-code/releases/latest/download -https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z -``` - -Aliyun OSS/CDN is a mirror: - -```text -https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest -https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/vX.Y.Z -``` - -All mirrors must serve byte-identical artifacts and the same `SHA256SUMS`. -Aliyun OSS/CDN does not have GitHub's `releases/latest/download` redirect, so -release publishing must maintain both `vX.Y.Z/` and `latest/` directories. - -## Safety - -- Remote standalone installs require checksum verification. -- Local archive installs do not require network access, but they still require - an adjacent `SHA256SUMS` file. -- The installer only deletes temporary extraction directories and the previous - managed standalone install directory. -- npm fallback does not change npm prefix, npmrc, or PATH. - -## Verification Strategy - -- Static tests ensure the installer keeps the expected methods and does not - reintroduce Node/NVM installation or automatic `qwen` startup. -- Packaging tests can run against a fake target and fake Node distribution. -- Shell smoke tests run installer branches with fake `curl`, `tar`, `npm`, - `node`, and `qwen`. -- GitHub Actions should later run Linux, macOS, and Windows installer smoke - tests with locally generated archives before enabling standalone as the - public default. diff --git a/.qwen/design/2026-04-30-standalone-installer-plan.md b/.qwen/design/2026-04-30-standalone-installer-plan.md deleted file mode 100644 index 78c4648a4d6..00000000000 --- a/.qwen/design/2026-04-30-standalone-installer-plan.md +++ /dev/null @@ -1,124 +0,0 @@ -# Standalone Installer Implementation Plan - -**Goal:** Add code-server-style standalone archive distribution with npm fallback. - -**Architecture:** Release builds produce per-platform archives that bundle -`dist/cli.js`, required runtime assets, and a private Node.js runtime. The -installer defaults to `detect`, installs a standalone archive when available, -and falls back to npm otherwise. - -**Tech Stack:** Bash, Windows batch, Node.js release scripting, GitHub Actions, -Vitest static/smoke tests. - -## Task 1: Installer Contract Tests - -**Files:** - -- Modify: `scripts/tests/install-script.test.js` - -**Steps:** - -1. Add tests asserting the Unix installer exposes `--method`, `--mirror`, - `--base-url`, `--archive`, standalone install functions, checksum - verification, and npm fallback. -2. Add tests asserting the Windows installer exposes the same options and uses - PowerShell/CertUtil for archive install and checksum verification. -3. Run `npm run test:scripts`. -4. Confirm the new tests fail before implementation. - -## Task 2: Standalone Package Script - -**Files:** - -- Create: `scripts/create-standalone-package.js` -- Modify: `package.json` - -**Steps:** - -1. Add a Node.js script that accepts `--target`, `--node-archive`, - `--out-dir`, and optional `--version`. -2. Require `dist/cli.js`, `dist/vendor`, `README.md`, and `LICENSE`. -3. Extract a Node.js distribution archive into a staging directory. -4. Create `qwen-code/bin/qwen`, `qwen-code/bin/qwen.cmd`, - `qwen-code/lib/cli.js`, copied runtime assets, and `manifest.json`. -5. Emit `qwen-code-.tar.gz` for Unix targets and - `qwen-code-.zip` for Windows targets. -6. Write/update `SHA256SUMS`. -7. Add `npm run package:standalone`. -8. Add focused script tests where practical. - -## Task 3: Unix Installer Standalone Flow - -**Files:** - -- Modify: `scripts/installation/install-qwen-with-source.sh` - -**Steps:** - -1. Add argument parsing for `--method`, `--mirror`, `--base-url`, `--archive`, - and `--version`. -2. Add target detection for supported OS/arch combinations. -3. Add URL construction for GitHub and Aliyun mirrors. -4. Add archive availability check for detect mode. -5. Add download, checksum verification, extraction, and shim creation. -6. Keep npm installation as fallback and as explicit `--method npm`. -7. Keep source tracking and final instructions. - -## Task 4: Windows Installer Standalone Flow - -**Files:** - -- Modify: `scripts/installation/install-qwen-with-source.bat` - -**Steps:** - -1. Add argument parsing for `--method`, `--mirror`, `--base-url`, `--archive`, - and `--version`. -2. Add target detection for `win-x64`. -3. Add archive download with PowerShell. -4. Add checksum verification with `certutil`. -5. Add archive extraction with PowerShell `Expand-Archive`. -6. Install to `%LOCALAPPDATA%\qwen-code\qwen-code` and expose - `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd`. -7. Keep npm fallback and source tracking. - -## Task 5: Release Workflow - -**Files:** - -- Modify: `.github/workflows/release.yml` - -**Steps:** - -1. After `npm run prepare:package`, download supported Node.js runtime - archives. -2. Run `npm run package:standalone -- --target ...` for each supported target. -3. Upload `dist/standalone/qwen-code-*` and `dist/standalone/SHA256SUMS` to the - GitHub Release alongside `dist/cli.js`. - -## Task 6: Documentation - -**Files:** - -- Modify: `README.md` -- Modify: `docs/users/overview.md` -- Modify: `docs/users/quickstart.md` -- Modify: `scripts/installation/INSTALLATION_GUIDE.md` -- Create: `.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md` - -**Steps:** - -1. Document install methods and mirror choices. -2. Document offline archive installation. -3. Document release artifact names. -4. Document platform verification plan. - -## Task 7: Verification - -**Commands:** - -- `npm run test:scripts` -- `npx prettier --check README.md docs/users/quickstart.md docs/users/overview.md scripts/installation/INSTALLATION_GUIDE.md .qwen/design/2026-04-30-standalone-installer-design.md .qwen/design/2026-04-30-standalone-installer-plan.md .qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md scripts/tests/install-script.test.js` -- `bash -n scripts/installation/install-qwen-with-source.sh` -- `git diff --check` -- Local fake-runtime installer smoke for npm and standalone paths. diff --git a/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md b/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md deleted file mode 100644 index c903ee3c14d..00000000000 --- a/.qwen/e2e-tests/2026-04-30-standalone-installer-test-plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Standalone Installer Test Plan - -## Scope - -This plan verifies the one-line installer after standalone archive support is -added. It covers installer behavior, artifact packaging, and fallback behavior -without requiring real global npm writes. - -## Local Smoke Matrix - -Run from the repository root after `npm run bundle && npm run prepare:package`. - -### Unix Standalone Archive - -1. Build a standalone archive for the current target with a local Node.js - archive. -2. Keep the generated `SHA256SUMS` next to the archive. -3. Create a temporary `HOME`. -4. Run: - - ```bash - HOME="$tmp_home" bash scripts/installation/install-qwen-with-source.sh \ - --method standalone \ - --archive dist/standalone/qwen-code-.tar.gz \ - --source github - ``` - -5. Expected: - - `$tmp_home/.local/lib/qwen-code` exists. - - `$tmp_home/.local/bin/qwen` exists and is executable. - - `$tmp_home/.qwen/source.json` contains `{"source":"github"}`. - - Installer does not write `.bashrc`, `.zshrc`, `.npmrc`, or npm prefix. - - Installer rejects the same archive after tampering with its contents. - - Installer rejects the archive if `SHA256SUMS` is missing. - -### Unix npm Fallback - -1. Put fake `node`, `npm`, and `qwen` commands in a temporary PATH. -2. Run detect mode with a fake base URL whose archive does not exist. -3. Expected: - - npm is invoked with `install -g @qwen-code/qwen-code@latest`. - - `qwen` is not executed interactively. - -### Unix Standalone Failure - -1. Run `--method standalone` with a fake base URL whose archive does not exist. -2. Expected: - - installer exits non-zero. - - npm is not invoked. - -### Windows Standalone Archive - -Run on `windows-latest` or a Windows VM: - -```cmd -set USERPROFILE=%TEMP%\qwen-user -set LOCALAPPDATA=%TEMP%\qwen-local -scripts\installation\install-qwen-with-source.bat --method standalone --archive dist\standalone\qwen-code-win-x64.zip --source github -``` - -Expected: - -- `%LOCALAPPDATA%\qwen-code\qwen-code` exists. -- `%LOCALAPPDATA%\qwen-code\bin\qwen.cmd` exists. -- `%USERPROFILE%\.qwen\source.json` exists. -- The script does not require Administrator. - -## CI Matrix - -- `ubuntu-latest`: package + install Linux x64 archive. -- `macos-latest`: package + install Darwin arm64/x64 depending on runner. -- `windows-latest`: package + install Windows x64 archive. - -## Manual Release Verification - -For a release candidate: - -1. Download `SHA256SUMS` and all archives from GitHub Release. -2. Verify checksums locally. -3. Sync the same files to OSS/CDN under both `vX.Y.Z/` and `latest/`. -4. Download one archive from GitHub and one from OSS/CDN. -5. Confirm byte-identical checksums. -6. Run installer with: - - ```bash - --mirror github --method standalone - --mirror aliyun --method standalone - --method npm - --archive /path/to/archive - ``` From 2b40bad5df87b82424843e2542efe99f5718c4b3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 23:27:25 +0800 Subject: [PATCH 007/129] chore(installer): simplify standalone release review fixes --- .github/workflows/release.yml | 74 +---- .gitignore | 4 - package.json | 1 + scripts/build-standalone-release.js | 271 ++++++++++++++++++ scripts/create-standalone-package.js | 2 +- .../installation/install-qwen-with-source.bat | 6 + .../installation/install-qwen-with-source.sh | 6 + scripts/tests/install-script.test.js | 47 ++- 8 files changed, 323 insertions(+), 88 deletions(-) create mode 100644 scripts/build-standalone-release.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8eea0d67754..28f3559bd52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,79 +382,7 @@ jobs: - name: 'Build Standalone Archives' env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' - run: |- - set -euo pipefail - - NODE_VERSION="$(node -p "process.versions.node")" - RUNTIME_DIR="${RUNNER_TEMP}/qwen-node-runtime" - NODE_DIST_URL="https://nodejs.org/dist/v${NODE_VERSION}" - mkdir -p "${RUNTIME_DIR}" - curl -fsSL "${NODE_DIST_URL}/SHASUMS256.txt" -o "${RUNTIME_DIR}/SHASUMS256.txt" - - verify_node_checksum() { - local checksum_line="$1" - - if command -v sha256sum >/dev/null 2>&1; then - printf '%s\n' "${checksum_line}" | (cd "${RUNTIME_DIR}" && sha256sum -c -) - return - fi - - if command -v shasum >/dev/null 2>&1; then - local expected - local archive_name - local actual - expected="$(printf '%s\n' "${checksum_line}" | awk '{ print $1 }')" - archive_name="$(printf '%s\n' "${checksum_line}" | awk '{ print $2 }')" - actual="$(shasum -a 256 "${RUNTIME_DIR}/${archive_name}" | awk '{ print $1 }')" - if [[ "${expected}" != "${actual}" ]]; then - echo "::error::Checksum verification failed for ${archive_name}" - exit 1 - fi - return - fi - - echo "::error::No SHA-256 utility found to verify Node.js runtime" - exit 1 - } - - download_node() { - local qwen_target="$1" - local node_target="$2" - local extension="$3" - local archive="${RUNTIME_DIR}/node-v${NODE_VERSION}-${node_target}.${extension}" - local archive_name - archive_name="$(basename "${archive}")" - local checksum_line - local url="${NODE_DIST_URL}/${archive_name}" - - echo "Downloading ${url}" - curl -fsSL "${url}" -o "${archive}" - checksum_line="$(awk -v name="${archive_name}" '$2 == name { print }' "${RUNTIME_DIR}/SHASUMS256.txt")" - if [[ -z "${checksum_line}" ]]; then - echo "::error::Node.js SHASUMS256.txt does not list ${archive_name}" - exit 1 - fi - verify_node_checksum "${checksum_line}" - npm run package:standalone -- \ - --target "${qwen_target}" \ - --node-archive "${archive}" \ - --out-dir dist/standalone \ - --version "${RELEASE_VERSION}" - } - - download_node darwin-arm64 darwin-arm64 tar.gz - download_node darwin-x64 darwin-x64 tar.gz - download_node linux-arm64 linux-arm64 tar.xz - download_node linux-x64 linux-x64 tar.xz - download_node win-x64 win-x64 zip - - ls -la dist/standalone - cat dist/standalone/SHA256SUMS - archive_count="$(grep -c '^[0-9a-f]' dist/standalone/SHA256SUMS)" - if [[ "${archive_count}" -ne 5 ]]; then - echo "::error::Expected 5 standalone checksums, found ${archive_count}" - exit 1 - fi + run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' diff --git a/.gitignore b/.gitignore index 0a0b02ff50b..f6ea62c7f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,10 +36,6 @@ CLAUDE.md !.qwen/skills/** !.qwen/agents/ !.qwen/agents/** -!.qwen/design/ -!.qwen/design/** -!.qwen/e2e-tests/ -!.qwen/e2e-tests/** # OS metadata .DS_Store diff --git a/package.json b/package.json index 984d96e72e4..00fd82774b2 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "package:standalone": "node scripts/create-standalone-package.js", + "package:standalone:release": "node scripts/build-standalone-release.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js new file mode 100644 index 00000000000..947af5cf604 --- /dev/null +++ b/scripts/build-standalone-release.js @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const EXPECTED_ARCHIVE_COUNT = 5; +const RELEASE_TARGETS = [ + { + qwenTarget: 'darwin-arm64', + nodeTarget: 'darwin-arm64', + extension: 'tar.gz', + }, + { + qwenTarget: 'darwin-x64', + nodeTarget: 'darwin-x64', + extension: 'tar.gz', + }, + { + qwenTarget: 'linux-arm64', + nodeTarget: 'linux-arm64', + extension: 'tar.xz', + }, + { qwenTarget: 'linux-x64', nodeTarget: 'linux-x64', extension: 'tar.xz' }, + { qwenTarget: 'win-x64', nodeTarget: 'win-x64', extension: 'zip' }, +]; + +await main(); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printUsage(); + return; + } + + const nodeVersion = args.nodeVersion || process.versions.node; + const outDir = path.resolve( + args.outDir || path.join(rootDir, 'dist', 'standalone'), + ); + const runtimeParent = path.resolve( + args.runtimeDir || process.env.RUNNER_TEMP || os.tmpdir(), + ); + fs.mkdirSync(runtimeParent, { recursive: true }); + const runtimeDir = fs.mkdtempSync( + path.join(runtimeParent, 'qwen-node-runtime-'), + ); + const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`; + + try { + fs.mkdirSync(outDir, { recursive: true }); + const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt'); + await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); + const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); + + for (const target of RELEASE_TARGETS) { + await packageTarget({ + ...target, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion: args.version, + runtimeDir, + checksums, + }); + } + + assertStandaloneOutput(outDir); + } finally { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + } +} + +async function packageTarget({ + qwenTarget, + nodeTarget, + extension, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion, + runtimeDir, + checksums, +}) { + const archiveName = `node-v${nodeVersion}-${nodeTarget}.${extension}`; + const archivePath = path.join(runtimeDir, archiveName); + + await downloadFile(`${nodeDistUrl}/${archiveName}`, archivePath); + verifyNodeArchive(archivePath, archiveName, checksums); + + const args = [ + 'scripts/create-standalone-package.js', + '--target', + qwenTarget, + '--node-archive', + archivePath, + '--out-dir', + outDir, + ]; + if (releaseVersion) { + args.push('--version', releaseVersion); + } + + execFileSync(process.execPath, args, { + cwd: rootDir, + stdio: 'inherit', + }); +} + +async function downloadFile(url, destination) { + console.log(`Downloading ${url}`); + const response = await fetch(url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(destination, buffer); +} + +function parseChecksums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const [hash, fileName] = line.trim().split(/\s+/, 2); + if (hash && fileName) { + checksums.set(fileName.replace(/^\*/, ''), hash); + } + } + return checksums; +} + +function verifyNodeArchive(archivePath, archiveName, checksums) { + const expected = checksums.get(archiveName); + if (!expected) { + fail(`Node.js SHASUMS256.txt does not list ${archiveName}`); + } + + const actual = crypto + .createHash('sha256') + .update(fs.readFileSync(archivePath)) + .digest('hex'); + if (actual !== expected) { + fail(`Checksum verification failed for ${archiveName}`); + } + + console.log(`Verified Node.js runtime checksum for ${archiveName}`); +} + +function assertStandaloneOutput(outDir) { + const checksumPath = path.join(outDir, 'SHA256SUMS'); + if (!fs.existsSync(checksumPath)) { + fail(`Standalone SHA256SUMS was not created at ${checksumPath}`); + } + + const archiveNames = fs + .readFileSync(checksumPath, 'utf8') + .split(/\r?\n/) + .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) + .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) + .filter(Boolean) + .sort(); + const expectedArchiveNames = RELEASE_TARGETS.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + ).sort(); + const missing = expectedArchiveNames.filter( + (archiveName) => !archiveNames.includes(archiveName), + ); + const extra = archiveNames.filter( + (archiveName) => !expectedArchiveNames.includes(archiveName), + ); + + if ( + archiveNames.length !== EXPECTED_ARCHIVE_COUNT || + missing.length > 0 || + extra.length > 0 + ) { + fail( + [ + `Expected standalone checksums for ${expectedArchiveNames.join(', ')}`, + `found ${archiveNames.join(', ') || 'none'}.`, + missing.length > 0 ? `Missing: ${missing.join(', ')}.` : '', + extra.length > 0 ? `Extra: ${extra.join(', ')}.` : '', + ] + .filter(Boolean) + .join(' '), + ); + } + + console.log(`Verified ${archiveNames.length} standalone release checksums.`); +} + +function parseArgs(argv) { + const args = { + help: false, + nodeVersion: undefined, + outDir: undefined, + runtimeDir: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--node-version': + args.nodeVersion = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--runtime-dir': + args.runtimeDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function printUsage() { + console.log(` +Usage: + npm run package:standalone:release -- [OPTIONS] + +Options: + --version VERSION Release version written to standalone manifests. + --out-dir PATH Output directory. Defaults to dist/standalone. + --runtime-dir PATH Temporary Node.js runtime download directory. + --node-version VERSION Node.js version to download. Defaults to current Node. +`); +} + +function fail(message) { + console.error(`ERROR: ${message}`); + process.exit(1); +} diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 43482036574..7d85aed344b 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -2,7 +2,7 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 4adedf3a062..a5dd66fb956 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -22,6 +22,7 @@ if not "%QWEN_INSTALL_LIB_DIR%"=="" set "INSTALL_DIR=%QWEN_INSTALL_LIB_DIR%" set "INSTALL_BIN_DIR=%INSTALL_BASE%\bin" if not "%QWEN_INSTALL_BIN_DIR%"=="" set "INSTALL_BIN_DIR=%QWEN_INSTALL_BIN_DIR%" +REM Parse flags before any network or filesystem work. :parse_args if "%~1"=="" goto end_parse if /i "%~1"=="--source" ( @@ -134,6 +135,7 @@ if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! echo. +REM Dispatch after validation; detect falls back to npm only when unavailable. if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! @@ -372,6 +374,7 @@ exit /b 0 set "TEMP_DIR=" set "CHECKSUM_SOURCE=" +REM Resolve the archive from a local file or from the configured release mirror. if not "!ARCHIVE_PATH!"=="" ( set "ARCHIVE_FILE=!ARCHIVE_PATH!" for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" @@ -414,12 +417,14 @@ if "!TEMP_DIR!"=="" ( mkdir "!TEMP_DIR!" >nul 2>&1 ) +REM Verify integrity before extraction or changing the install directory. call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 exit /b 1 ) +REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" @@ -457,6 +462,7 @@ if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" if not exist "!INSTALL_PARENT!" mkdir "!INSTALL_PARENT!" +REM Stage into .new and keep .old so failed upgrades can roll back. set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 73f432e59d2..76dc18937fb 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -221,6 +221,7 @@ while [[ $# -gt 0 ]]; do esac done +# Validate all user-supplied options before doing network or filesystem work. validate_options print_header() { @@ -591,6 +592,7 @@ install_standalone() { local checksum_source="" local temp_dir="" + # Resolve the archive from a local file or from the configured release mirror. if [[ -n "${ARCHIVE_PATH}" ]]; then archive_path="${ARCHIVE_PATH}" archive_name="$(basename "${archive_path}")" @@ -633,11 +635,13 @@ install_standalone() { temp_dir=$(mktemp -d) 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}" @@ -658,6 +662,7 @@ install_standalone() { mkdir -p "${INSTALL_LIB_PARENT}" "${INSTALL_BIN_DIR}" + # Stage into .new and keep .old so failed upgrades can roll back. local new_install_dir="${INSTALL_LIB_DIR}.new" local old_install_dir="${INSTALL_LIB_DIR}.old" if ! ensure_managed_install_dir "${INSTALL_LIB_DIR}" || @@ -785,6 +790,7 @@ main() { print_final_instructions "$(get_npm_global_bin)" ;; detect) + # Try the standalone archive first; fall back only when unavailable. if install_standalone; then print_final_instructions "${INSTALL_BIN_DIR}" else diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a44e440a94f..a946cc0eb6e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -157,9 +157,14 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:standalone']).toBe( 'node scripts/create-standalone-package.js', ); + expect(packageJson.scripts['package:standalone:release']).toBe( + 'node scripts/build-standalone-release.js', + ); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); + expect(existsSync('scripts/build-standalone-release.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); + expect(packageScript).toContain('Copyright 2025 Qwen Team'); expect(packageScript).toContain("'bundled/qc-helper/docs'"); expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); @@ -168,6 +173,27 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); + + const releaseScript = readScript('scripts/build-standalone-release.js'); + expect(releaseScript).toContain('Copyright 2025 Qwen Team'); + expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); + expect(releaseScript).toContain('SHASUMS256.txt'); + expect(releaseScript).toContain('verifyNodeArchive'); + expect(releaseScript).toContain('EXPECTED_ARCHIVE_COUNT = 5'); + expect(releaseScript).toContain('expectedArchiveNames'); + expect(releaseScript).toContain('qwen-code-${qwenTarget}'); + expect(releaseScript).toContain('scripts/create-standalone-package.js'); + }); + + it('loads the standalone release packaging helper', () => { + const output = execFileSync( + process.execPath, + ['scripts/build-standalone-release.js', '--help'], + { encoding: 'utf8' }, + ); + + expect(output).toContain('package:standalone:release'); + expect(output).toContain('--node-version VERSION'); }); it('rejects a runtime archive without a Node executable', () => { @@ -315,19 +341,20 @@ describe('standalone release packaging', () => { it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); - expect(workflow).toContain('set -euo pipefail'); - expect(workflow).toContain('SHASUMS256.txt'); - expect(workflow).toContain('$2 == name'); - expect(workflow).toContain('does not list ${archive_name}'); - expect(workflow).toContain('verify_node_checksum()'); - expect(workflow).toContain('sha256sum -c -'); - expect(workflow).toContain('shasum -a 256'); - expect(workflow).toContain('Expected 5 standalone checksums'); - expect(workflow).toContain('npm run package:standalone'); + expect(workflow).toContain('npm run package:standalone:release --'); + expect(workflow).not.toContain('verify_node_checksum()'); + expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); }); + it('does not whitelist internal planning documents in gitignore', () => { + const gitignore = readScript('.gitignore'); + + expect(gitignore).not.toContain('!.qwen/design/'); + expect(gitignore).not.toContain('!.qwen/e2e-tests/'); + }); + it('documents optional native module parity for standalone installs', () => { const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); From a4a15e1ded4e4fe9d06d3d8a4fccffdb848e91b7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 23:56:09 +0800 Subject: [PATCH 008/129] test(installer): add Windows batch install smoke --- scripts/tests/install-script.test.js | 130 ++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a946cc0eb6e..3463a3b0d01 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -24,8 +24,9 @@ const { tmpdir } = await vi.importActual('node:os'); const path = await vi.importActual('node:path'); const readScript = (path) => readFileSync(path, 'utf8'); // These E2E cases execute the Unix shell installer and POSIX symlink behavior. -// Windows batch behavior is covered by static checks plus win-x64 packaging. +// Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; +const itOnWindows = process.platform === 'win32' ? it : it.skip; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { @@ -608,6 +609,61 @@ describe('Linux/macOS installer end-to-end', () => { ); }); +describe('Windows installer end-to-end', () => { + itOnWindows( + 'installs a local standalone archive with checksum verification', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runWindowsInstaller(archive, installRoot, home); + + expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe( + true, + ); + expect( + existsSync(path.join(installRoot, 'qwen-code', 'node', 'node.exe')), + ).toBe(true); + expect(readScript(path.join(home, '.qwen', 'source.json'))).toContain( + '"source": "smoke"', + ); + + const version = runWindowsCommand( + `"${path.join(installRoot, 'bin', 'qwen.cmd')}" --version`, + { USERPROFILE: home }, + ) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnWindows('rejects a tampered local archive', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + appendFileSync(archive, 'tamper'); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + function ensureMinimalDist() { if (existsSync('dist')) { return false; @@ -689,6 +745,29 @@ function createFakeWindowsNodeArchive(tmpDir) { return archive; } +function createFakeWindowsStandaloneArchive(tmpDir) { + const packageRoot = path.join(tmpDir, 'qwen-code'); + const outDir = path.join(tmpDir, 'out'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node'), { recursive: true }); + mkdirSync(outDir, { recursive: true }); + + writeFileSync( + path.join(packageRoot, 'bin', 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), + ); + writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + createZipForTest(archive, tmpDir, path.basename(packageRoot)); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( @@ -802,6 +881,55 @@ function runUnixInstaller(archive, installRoot, home, method = 'standalone') { } } +function runWindowsInstaller( + archive, + installRoot, + home, + method = 'standalone', +) { + mkdirSync(home, { recursive: true }); + try { + return runWindowsCommand( + [ + `"${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + '--method', + method, + '--archive', + `"${archive}"`, + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function runWindowsCommand(command, env = {}) { + return execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/s', '/c', command], + { + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + }, + ); +} + function createSymlinkStandaloneArchive(tmpDir) { const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); From fee13e4ddf71d1102026a706f2776365b2307cb8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 3 May 2026 00:27:50 +0800 Subject: [PATCH 009/129] test(installer): fix Windows batch smoke quoting --- scripts/tests/install-script.test.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 3463a3b0d01..4981097d4f9 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -632,7 +632,7 @@ describe('Windows installer end-to-end', () => { ); const version = runWindowsCommand( - `"${path.join(installRoot, 'bin', 'qwen.cmd')}" --version`, + `call "${path.join(installRoot, 'bin', 'qwen.cmd')}" --version`, { USERPROFILE: home }, ) .toString() @@ -891,7 +891,7 @@ function runWindowsInstaller( try { return runWindowsCommand( [ - `"${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + `call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, '--method', method, '--archive', @@ -917,17 +917,13 @@ function runWindowsInstaller( } function runWindowsCommand(command, env = {}) { - return execFileSync( - process.env.ComSpec || 'cmd.exe', - ['/d', '/s', '/c', command], - { - env: { - ...process.env, - ...env, - }, - stdio: 'pipe', + return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], { + env: { + ...process.env, + ...env, }, - ); + stdio: 'pipe', + }); } function createSymlinkStandaloneArchive(tmpDir) { From 69e22f941840b7f59fc0ebc586012b121101a2f7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 3 May 2026 00:50:18 +0800 Subject: [PATCH 010/129] test(installer): preserve Windows cmd quotes --- scripts/tests/install-script.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 4981097d4f9..c2b794ba02e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -923,6 +923,8 @@ function runWindowsCommand(command, env = {}) { ...env, }, stdio: 'pipe', + // cmd.exe parses the command string itself; preserve quoted paths. + windowsVerbatimArguments: true, }); } From faa9087f2d0dafe7bb2a463628231ce2a5c8a247 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 3 May 2026 01:14:19 +0800 Subject: [PATCH 011/129] fix(installer): use robust Windows checksum hashing --- scripts/installation/install-qwen-with-source.bat | 4 +++- scripts/tests/install-script.test.js | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index a5dd66fb956..2d2c2bd63a8 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -347,9 +347,11 @@ if "!EXPECTED_HASH!"=="" ( ) set "ACTUAL_HASH=" -for /f "tokens=1" %%H in ('certutil -hashfile "!ARCHIVE_FILE!" SHA256 ^| findstr /R /C:"^[0-9A-Fa-f][0-9A-Fa-f]"') do ( +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" ) +set "QWEN_HASH_FILE=" if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c2b794ba02e..9829a4be3b5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -127,10 +127,12 @@ describe('installation scripts', () => { expect(script).toContain(':InstallNpm'); expect(script).toContain(':VerifyChecksum'); expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain('Get-FileHash -Algorithm SHA256'); expect(script).toContain('tokens=1,2'); expect(script).toContain('CHECKSUM_NAME'); expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"'); expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); + expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); From 66dec5f5b48f3ee20e233cc02b2f0aac3b7339b8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 3 May 2026 01:14:48 +0800 Subject: [PATCH 012/129] ci: narrow installer debug matrix --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dab4d198496..3864df989da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,6 @@ jobs: fail-fast: false # So we can see all test failures matrix: os: - - 'macos-latest' - - 'ubuntu-latest' - 'windows-latest' node-version: - '20.x' From 1ca86fe8e77534ee9e5a687ae4e4bc9547334e17 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 16:03:34 +0800 Subject: [PATCH 013/129] fix(installer): address standalone review hardening --- .github/workflows/ci.yml | 2 + scripts/build-standalone-release.js | 56 ++++--- scripts/create-standalone-package.js | 39 +++-- .../installation/install-qwen-with-source.bat | 139 +++++++++++++++--- .../installation/install-qwen-with-source.sh | 121 +++++++++++++-- scripts/tests/install-script.test.js | 105 ++++++++++++- 6 files changed, 390 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3864df989da..dab4d198496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: fail-fast: false # So we can see all test failures matrix: os: + - 'macos-latest' + - 'ubuntu-latest' - 'windows-latest' node-version: - '20.x' diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 947af5cf604..efb82b062eb 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -11,34 +11,45 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -const EXPECTED_ARCHIVE_COUNT = 5; const RELEASE_TARGETS = [ { qwenTarget: 'darwin-arm64', nodeTarget: 'darwin-arm64', - extension: 'tar.gz', + nodeArchiveExtension: 'tar.gz', }, { qwenTarget: 'darwin-x64', nodeTarget: 'darwin-x64', - extension: 'tar.gz', + nodeArchiveExtension: 'tar.gz', }, { qwenTarget: 'linux-arm64', nodeTarget: 'linux-arm64', - extension: 'tar.xz', + nodeArchiveExtension: 'tar.xz', }, - { qwenTarget: 'linux-x64', nodeTarget: 'linux-x64', extension: 'tar.xz' }, - { qwenTarget: 'win-x64', nodeTarget: 'win-x64', extension: 'zip' }, + { + qwenTarget: 'linux-x64', + nodeTarget: 'linux-x64', + nodeArchiveExtension: 'tar.xz', + }, + { qwenTarget: 'win-x64', nodeTarget: 'win-x64', nodeArchiveExtension: 'zip' }, ]; +const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -await main(); +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} async function main() { const args = parseArgs(process.argv.slice(2)); @@ -87,7 +98,7 @@ async function main() { async function packageTarget({ qwenTarget, nodeTarget, - extension, + nodeArchiveExtension, nodeDistUrl, nodeVersion, outDir, @@ -95,11 +106,11 @@ async function packageTarget({ runtimeDir, checksums, }) { - const archiveName = `node-v${nodeVersion}-${nodeTarget}.${extension}`; + const archiveName = `node-v${nodeVersion}-${nodeTarget}.${nodeArchiveExtension}`; const archivePath = path.join(runtimeDir, archiveName); await downloadFile(`${nodeDistUrl}/${archiveName}`, archivePath); - verifyNodeArchive(archivePath, archiveName, checksums); + await verifyNodeArchive(archivePath, archiveName, checksums); const args = [ 'scripts/create-standalone-package.js', @@ -128,8 +139,13 @@ async function downloadFile(url, destination) { `Failed to download ${url}: ${response.status} ${response.statusText}`, ); } - const buffer = Buffer.from(await response.arrayBuffer()); - fs.writeFileSync(destination, buffer); + if (!response.body) { + fail(`Failed to download ${url}: response body was empty`); + } + await pipeline( + Readable.fromWeb(response.body), + fs.createWriteStream(destination), + ); } function parseChecksums(content) { @@ -143,16 +159,13 @@ function parseChecksums(content) { return checksums; } -function verifyNodeArchive(archivePath, archiveName, checksums) { +async function verifyNodeArchive(archivePath, archiveName, checksums) { const expected = checksums.get(archiveName); if (!expected) { fail(`Node.js SHASUMS256.txt does not list ${archiveName}`); } - const actual = crypto - .createHash('sha256') - .update(fs.readFileSync(archivePath)) - .digest('hex'); + const actual = await sha256File(archivePath); if (actual !== expected) { fail(`Checksum verification failed for ${archiveName}`); } @@ -160,6 +173,12 @@ function verifyNodeArchive(archivePath, archiveName, checksums) { console.log(`Verified Node.js runtime checksum for ${archiveName}`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function assertStandaloneOutput(outDir) { const checksumPath = path.join(outDir, 'SHA256SUMS'); if (!fs.existsSync(checksumPath)) { @@ -266,6 +285,5 @@ Options: } function fail(message) { - console.error(`ERROR: ${message}`); - process.exit(1); + throw new Error(`ERROR: ${message}`); } diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 7d85aed344b..e2f5c821b4f 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -11,6 +11,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -38,9 +39,14 @@ const TARGETS = new Map([ const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs']; const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -main(); +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -95,7 +101,7 @@ function main() { fs.rmSync(outputPath, { force: true }); } createArchive(targetConfig.outputExtension, outputPath, tempRoot); - writeSha256Sums(outDir); + await writeSha256Sums(outDir); console.log(`Created ${path.relative(rootDir, outputPath)}`); console.log( @@ -204,9 +210,11 @@ function copyRuntimeAssets(packageRoot) { } fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), { recursive: true, - verbatimSymlinks: true, + dereference: true, + verbatimSymlinks: false, }); } + assertNoSymlinks(libDir, 'Copied runtime assets still contain symlinks.'); for (const fileName of ROOT_REQUIRED_PATHS) { fs.copyFileSync( @@ -465,7 +473,7 @@ function createZipArchive(outputPath, cwd) { run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); } -function writeSha256Sums(outDir) { +async function writeSha256Sums(outDir) { const entries = fs .readdirSync(outDir) .filter( @@ -481,18 +489,22 @@ function writeSha256Sums(outDir) { ); } - const lines = entries.map((entry) => { + const lines = []; + for (const entry of entries) { const filePath = path.join(outDir, entry); - const hash = crypto - .createHash('sha256') - .update(fs.readFileSync(filePath)) - .digest('hex'); - return `${hash} ${entry}`; - }); + const hash = await sha256File(filePath); + lines.push(`${hash} ${entry}`); + } fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function run(command, args, options = {}) { try { execFileSync(command, args, { @@ -509,6 +521,5 @@ function run(command, args, options = {}) { } function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); + throw new Error(`Error: ${message}`); } diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 2d2c2bd63a8..4e12d27f73a 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -6,21 +6,28 @@ REM This script intentionally does not install Node.js or change npm config. setlocal enabledelayedexpansion set "SOURCE=unknown" -set "METHOD=%QWEN_INSTALL_METHOD%" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" set "MIRROR=github" -if not "%QWEN_INSTALL_MIRROR%"=="" set "MIRROR=%QWEN_INSTALL_MIRROR%" -set "BASE_URL=%QWEN_INSTALL_BASE_URL%" -set "ARCHIVE_PATH=%QWEN_INSTALL_ARCHIVE%" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" set "VERSION=latest" -if not "%QWEN_INSTALL_VERSION%"=="" set "VERSION=%QWEN_INSTALL_VERSION%" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" set "NPM_REGISTRY=https://registry.npmmirror.com" -if not "%QWEN_NPM_REGISTRY%"=="" set "NPM_REGISTRY=%QWEN_NPM_REGISTRY%" -set "INSTALL_BASE=%LOCALAPPDATA%\qwen-code" -if not "%QWEN_INSTALL_ROOT%"=="" set "INSTALL_BASE=%QWEN_INSTALL_ROOT%" -set "INSTALL_DIR=%INSTALL_BASE%\qwen-code" -if not "%QWEN_INSTALL_LIB_DIR%"=="" set "INSTALL_DIR=%QWEN_INSTALL_LIB_DIR%" -set "INSTALL_BIN_DIR=%INSTALL_BASE%\bin" -if not "%QWEN_INSTALL_BIN_DIR%"=="" set "INSTALL_BIN_DIR=%QWEN_INSTALL_BIN_DIR%" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" REM Parse flags before any network or filesystem work. :parse_args @@ -201,6 +208,25 @@ exit /b 0 :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" +call :ValidateSafeVar "METHOD" "--method" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafeVar "MIRROR" "--mirror" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafeVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafeVar "ARCHIVE_PATH" "--archive" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafeVar "VERSION" "--version" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafeVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafePathVar "INSTALL_BASE" "QWEN_INSTALL_ROOT" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafePathVar "INSTALL_DIR" "QWEN_INSTALL_LIB_DIR" +if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateSafePathVar "INSTALL_BIN_DIR" "QWEN_INSTALL_BIN_DIR" +if %ERRORLEVEL% NEQ 0 exit /b 1 + if /i "!METHOD!"=="detect" goto validate_method_ok if /i "!METHOD!"=="standalone" goto validate_method_ok if /i "!METHOD!"=="npm" goto validate_method_ok @@ -214,14 +240,58 @@ echo ERROR: --mirror must be github or aliyun. exit /b 1 :validate_mirror_ok -call :ValidateHttpsUrl "!BASE_URL!" "--base-url" +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion if %ERRORLEVEL% NEQ 0 exit /b 1 call :ValidateSource exit /b %ERRORLEVEL% -:ValidateHttpsUrl -set "URL_VALUE=%~1" +:ValidateSafeVar +set "SAFE_VALUE=!%~1!" +set "SAFE_OPTION=%~2" +call :ValidateSafeCurrentValue +exit /b %ERRORLEVEL% + +:ValidateSafeCurrentValue +if "!SAFE_VALUE!"=="" exit /b 0 +if not "!SAFE_VALUE:"=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:&=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:|=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:<=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:>=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:^^=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:%%=!"=="!SAFE_VALUE!" goto unsafe_value +if not "!SAFE_VALUE:`=!"=="!SAFE_VALUE!" goto unsafe_value +exit /b 0 + +:unsafe_value +echo ERROR: !SAFE_OPTION! contains unsafe command characters. +exit /b 1 + +:ValidateSafePathVar +set "PATH_VALUE=!%~1!" +set "PATH_OPTION=%~2" +set "SAFE_VALUE=!PATH_VALUE!" +set "SAFE_OPTION=!PATH_OPTION!" +call :ValidateSafeCurrentValue +if %ERRORLEVEL% NEQ 0 exit /b 1 +if "!PATH_VALUE!"=="" ( + echo ERROR: !PATH_OPTION! must not be empty. + exit /b 1 +) +if "!PATH_VALUE:~1,2!"==":\" exit /b 0 +if "!PATH_VALUE:~0,2!"=="\\" exit /b 0 +echo ERROR: !PATH_OPTION! must be an absolute path. +exit /b 1 + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" set "URL_OPTION=%~2" if "!URL_VALUE!"=="" exit /b 0 if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 @@ -229,6 +299,13 @@ if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 echo ERROR: !URL_OPTION! must start with https:// 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 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 + :ValidateSource if "!SOURCE!"=="unknown" exit /b 0 echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul @@ -287,7 +364,7 @@ exit /b %PS_STATUS% :DownloadFile set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST)" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -509,12 +586,28 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - ( echo @echo off echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* -) > "!INSTALL_BIN_DIR!\qwen.cmd" +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 set "PATH=!INSTALL_BIN_DIR!;!PATH!" call :CreateSourceJson @@ -619,16 +712,16 @@ exit /b 0 :CreateSourceJson if "!SOURCE!"=="unknown" exit /b 0 -set "QWEN_DIR=%USERPROFILE%\.qwen" -if not exist "%QWEN_DIR%" mkdir "%QWEN_DIR%" +set "QWEN_DIR=!USERPROFILE!\.qwen" +if not exist "!QWEN_DIR!" mkdir "!QWEN_DIR!" ( echo { echo "source": "!SOURCE!" echo } -) > "%QWEN_DIR%\source.json" +) > "!QWEN_DIR!\source.json" -echo SUCCESS: Installation source saved to %USERPROFILE%\.qwen\source.json +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json exit /b 0 :PrintFinalInstructions diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 76dc18937fb..12e61922b5d 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -51,6 +51,30 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +TEMP_DIRS=() + +cleanup_temp_dirs() { + local temp_dir + for temp_dir in "${TEMP_DIRS[@]}"; do + if [[ -n "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi + done +} + +register_temp_dir() { + local temp_dir="$1" + TEMP_DIRS+=("${temp_dir}") +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +trap cleanup_temp_dirs EXIT +trap 'cleanup_temp_dirs; exit 130' INT +trap 'cleanup_temp_dirs; exit 143' TERM + print_usage() { cat < "${wrapper_path}" < "${INSTALL_BIN_DIR}/qwen" < { 'Standalone install failed. Retry with --method npm', ); expect(script).not.toContain('ln -sf "${INSTALL_LIB_DIR}/bin/qwen"'); - expect(script).toContain('exec "${INSTALL_LIB_DIR}/bin/qwen"'); + expect(script).toContain('shell_quote()'); + expect(script).toContain('exec ${quoted_qwen_bin} "\\$@"'); + expect(script).toContain('validate_version()'); + 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('not a Qwen Code standalone install'); + expect(script).toContain( + 'unzip -q "${archive_path}" -d "${destination}" || return 1', + ); + expect(script).toContain( + 'tar -xzf "${archive_path}" -C "${destination}" || return 1', + ); + expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"'); + expect(script).toContain('TEMP_DIRS+='); expect(script).not.toContain('-print -quit'); }); @@ -137,6 +149,17 @@ describe('installation scripts', () => { expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); + expect(script).toContain( + 'if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!"', + ); + expect(script).not.toContain('%QWEN_INSTALL_ROOT%'); + expect(script).toContain(':ValidateSafeVar'); + expect(script).toContain('set "SAFE_VALUE=!%~1!"'); + expect(script).toContain(':ValidateVersion'); + expect(script).toContain( + 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', + ); + expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); expect(script).toContain( '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', ); @@ -174,6 +197,8 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('assertSymlinksStayInside'); expect(packageScript).toContain('copyDereferenced'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); + expect(packageScript).toContain('dereference: true'); + expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); @@ -182,7 +207,11 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); expect(releaseScript).toContain('SHASUMS256.txt'); expect(releaseScript).toContain('verifyNodeArchive'); - expect(releaseScript).toContain('EXPECTED_ARCHIVE_COUNT = 5'); + expect(releaseScript).toContain( + 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', + ); + expect(releaseScript).toContain('nodeArchiveExtension'); + expect(releaseScript).toContain('fs.createReadStream'); expect(releaseScript).toContain('expectedArchiveNames'); expect(releaseScript).toContain('qwen-code-${qwenTarget}'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); @@ -405,6 +434,43 @@ describe('Linux/macOS installer end-to-end', () => { }, ); + itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const installLibDir = path.join( + installRoot, + 'lib', + 'qwen-code$(touch qwen-pwned)', + ); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + QWEN_INSTALL_LIB_DIR: installLibDir, + }); + + const version = execFileSync( + path.join(installRoot, 'bin', 'qwen'), + ['--version'], + { + cwd: tmpDir, + }, + ) + .toString() + .trim(); + expect(version).toBe('0.0.0-smoke'); + expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + itOnUnix('rejects a tampered local archive', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -664,6 +730,30 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }); + + itOnWindows('rejects unsafe environment-derived install paths', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const marker = path.join(tmpDir, 'pwned.txt'); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, + }, + ), + ).toThrow(/unsafe command characters/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); function ensureMinimalDist() { @@ -848,7 +938,13 @@ function packageFakeStandalone(tmpDir, nodeArchiveOptions = {}) { return path.join(outDir, 'qwen-code-linux-x64.tar.gz'); } -function runUnixInstaller(archive, installRoot, home, method = 'standalone') { +function runUnixInstaller( + archive, + installRoot, + home, + method = 'standalone', + extraEnv = {}, +) { mkdirSync(home, { recursive: true }); try { return execFileSync( @@ -867,6 +963,7 @@ function runUnixInstaller(archive, installRoot, home, method = 'standalone') { ...process.env, HOME: home, QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, }, stdio: 'pipe', }, @@ -888,6 +985,7 @@ function runWindowsInstaller( installRoot, home, method = 'standalone', + extraEnv = {}, ) { mkdirSync(home, { recursive: true }); try { @@ -904,6 +1002,7 @@ function runWindowsInstaller( { USERPROFILE: home, QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, }, ); } catch (error) { From 0eb58a81a22d9dd5e582fe7870d0ffada24991c1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 16:27:47 +0800 Subject: [PATCH 014/129] fix(installer): avoid Windows validation parse errors --- scripts/installation/install-qwen-with-source.bat | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 4e12d27f73a..ce8340c053a 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -260,14 +260,11 @@ exit /b %ERRORLEVEL% :ValidateSafeCurrentValue if "!SAFE_VALUE!"=="" exit /b 0 -if not "!SAFE_VALUE:"=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:&=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:|=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:<=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:>=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:^^=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:%%=!"=="!SAFE_VALUE!" goto unsafe_value -if not "!SAFE_VALUE:`=!"=="!SAFE_VALUE!" goto unsafe_value +set "QWEN_VALIDATE_VALUE=!SAFE_VALUE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VALIDATE_VALUE; if ($null -eq $value) { exit 0 }; $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); if ($value.IndexOfAny($unsafe) -ge 0) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_VALUE=" +if %PS_STATUS% NEQ 0 goto unsafe_value exit /b 0 :unsafe_value From e7e3f9077d6965be091b0730886a1dfdc5d94027 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 16:56:02 +0800 Subject: [PATCH 015/129] fix(installer): simplify Windows option validation --- .../installation/install-qwen-with-source.bat | 105 +++++++++--------- scripts/tests/install-script.test.js | 6 +- 2 files changed, 56 insertions(+), 55 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index ce8340c053a..a7b1d8ea2f4 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -208,24 +208,58 @@ exit /b 0 :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" -call :ValidateSafeVar "METHOD" "--method" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafeVar "MIRROR" "--mirror" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafeVar "BASE_URL" "--base-url" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafeVar "ARCHIVE_PATH" "--archive" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafeVar "VERSION" "--version" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafeVar "NPM_REGISTRY" "--registry" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafePathVar "INSTALL_BASE" "QWEN_INSTALL_ROOT" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafePathVar "INSTALL_DIR" "QWEN_INSTALL_LIB_DIR" -if %ERRORLEVEL% NEQ 0 exit /b 1 -call :ValidateSafePathVar "INSTALL_BIN_DIR" "QWEN_INSTALL_BIN_DIR" -if %ERRORLEVEL% NEQ 0 exit /b 1 +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok if /i "!METHOD!"=="detect" goto validate_method_ok if /i "!METHOD!"=="standalone" goto validate_method_ok @@ -252,41 +286,6 @@ if %ERRORLEVEL% NEQ 0 exit /b 1 call :ValidateSource exit /b %ERRORLEVEL% -:ValidateSafeVar -set "SAFE_VALUE=!%~1!" -set "SAFE_OPTION=%~2" -call :ValidateSafeCurrentValue -exit /b %ERRORLEVEL% - -:ValidateSafeCurrentValue -if "!SAFE_VALUE!"=="" exit /b 0 -set "QWEN_VALIDATE_VALUE=!SAFE_VALUE!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VALIDATE_VALUE; if ($null -eq $value) { exit 0 }; $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); if ($value.IndexOfAny($unsafe) -ge 0) { exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_VALIDATE_VALUE=" -if %PS_STATUS% NEQ 0 goto unsafe_value -exit /b 0 - -:unsafe_value -echo ERROR: !SAFE_OPTION! contains unsafe command characters. -exit /b 1 - -:ValidateSafePathVar -set "PATH_VALUE=!%~1!" -set "PATH_OPTION=%~2" -set "SAFE_VALUE=!PATH_VALUE!" -set "SAFE_OPTION=!PATH_OPTION!" -call :ValidateSafeCurrentValue -if %ERRORLEVEL% NEQ 0 exit /b 1 -if "!PATH_VALUE!"=="" ( - echo ERROR: !PATH_OPTION! must not be empty. - exit /b 1 -) -if "!PATH_VALUE:~1,2!"==":\" exit /b 0 -if "!PATH_VALUE:~0,2!"=="\\" exit /b 0 -echo ERROR: !PATH_OPTION! must be an absolute path. -exit /b 1 - :ValidateHttpsUrlVar set "URL_VALUE=!%~1!" set "URL_OPTION=%~2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 54e145655a4..574f8909431 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -153,8 +153,10 @@ describe('installation scripts', () => { 'if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!"', ); expect(script).not.toContain('%QWEN_INSTALL_ROOT%'); - expect(script).toContain(':ValidateSafeVar'); - expect(script).toContain('set "SAFE_VALUE=!%~1!"'); + expect(script).toContain('set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!"'); + expect(script).toContain( + 'installer options contain unsafe command characters', + ); expect(script).toContain(':ValidateVersion'); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', From fee51d1d9130f68f65c931096fbe80bef960efaf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 17:38:07 +0800 Subject: [PATCH 016/129] fix(installer): harden standalone review fixes --- scripts/build-standalone-release.js | 21 +- scripts/create-standalone-package.js | 198 +++++++++----- .../installation/install-qwen-with-source.bat | 9 +- .../installation/install-qwen-with-source.sh | 86 ++++++- scripts/tests/install-script.test.js | 242 +++++++++++++++++- scripts/tests/test-setup.ts | 16 +- 6 files changed, 491 insertions(+), 81 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index efb82b062eb..b0bf2bd1706 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -14,6 +14,7 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { writeSha256Sums } from './create-standalone-package.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -44,11 +45,13 @@ const RELEASE_TARGETS = [ ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -try { - await main(); -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; +if (isMainModule()) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } } async function main() { @@ -89,12 +92,17 @@ async function main() { }); } + await writeSha256Sums(outDir); assertStandaloneOutput(outDir); } finally { fs.rmSync(runtimeDir, { recursive: true, force: true }); } } +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + async function packageTarget({ qwenTarget, nodeTarget, @@ -120,6 +128,7 @@ async function packageTarget({ archivePath, '--out-dir', outDir, + '--skip-checksums', ]; if (releaseVersion) { args.push('--version', releaseVersion); @@ -287,3 +296,5 @@ Options: function fail(message) { throw new Error(`ERROR: ${message}`); } + +export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index e2f5c821b4f..968d6da45ed 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -37,13 +37,28 @@ const TARGETS = new Map([ ]); const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs']; +const DIST_ALLOWED_ENTRIES = new Set([ + 'cli.js', + 'vendor', + 'bundled', + 'package.json', + 'README.md', + 'LICENSE', + 'locales', + 'examples', +]); +const DIST_ALLOWED_ENTRY_PATTERNS = [ + /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, +]; const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -try { - await main(); -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; +if (isMainModule()) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } } async function main() { @@ -85,7 +100,7 @@ async function main() { fs.mkdirSync(packageRoot, { recursive: true }); fs.mkdirSync(runtimeExtractDir, { recursive: true }); - copyRuntimeAssets(packageRoot); + copyRuntimeAssets(packageRoot, outDir); extractNodeArchive(nodeArchive, runtimeExtractDir); const nodeDir = path.join(packageRoot, 'node'); copyExtractedNode(runtimeExtractDir, nodeDir); @@ -101,22 +116,31 @@ async function main() { fs.rmSync(outputPath, { force: true }); } createArchive(targetConfig.outputExtension, outputPath, tempRoot); - await writeSha256Sums(outDir); + if (!args.skipChecksums) { + await writeSha256Sums(outDir); + } console.log(`Created ${path.relative(rootDir, outputPath)}`); - console.log( - `Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`, - ); + if (!args.skipChecksums) { + console.log( + `Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`, + ); + } } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } } +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + function parseArgs(argv) { const args = { help: false, outDir: undefined, nodeArchive: undefined, + skipChecksums: false, target: undefined, version: undefined, }; @@ -144,6 +168,9 @@ function parseArgs(argv) { args.version = readOptionValue(argv, index, arg); index += 1; break; + case '--skip-checksums': + args.skipChecksums = true; + break; default: fail(`Unknown option: ${arg}`); } @@ -171,6 +198,7 @@ Options: --node-archive PATH Downloaded Node.js runtime archive. --out-dir DIR Output directory. Defaults to dist/standalone. --version VERSION Qwen Code version. Defaults to package.json version. + --skip-checksums Do not update SHA256SUMS. Used by release packaging. -h, --help Show this help message.`); } @@ -200,14 +228,18 @@ function readPackageVersion() { return packageJson.version; } -function copyRuntimeAssets(packageRoot) { +function copyRuntimeAssets(packageRoot, outDir) { const libDir = path.join(packageRoot, 'lib'); + const skippedDistEntry = topLevelDistEntryForPath(outDir); fs.mkdirSync(libDir, { recursive: true }); for (const entry of fs.readdirSync(distDir)) { - if (entry === 'standalone') { + if (entry === skippedDistEntry || entry === '.DS_Store') { continue; } + if (!isAllowedDistEntry(entry)) { + fail(`Unexpected dist asset: ${path.join(distDir, entry)}`); + } fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), { recursive: true, dereference: true, @@ -223,15 +255,30 @@ function copyRuntimeAssets(packageRoot) { ); } - const distPackageJson = path.join(distDir, 'package.json'); - if (fs.existsSync(distPackageJson)) { - fs.copyFileSync(distPackageJson, path.join(packageRoot, 'package.json')); - } else { - fs.copyFileSync( - path.join(rootDir, 'package.json'), - path.join(packageRoot, 'package.json'), - ); + fs.copyFileSync( + path.join(rootDir, 'package.json'), + path.join(packageRoot, 'package.json'), + ); +} + +function topLevelDistEntryForPath(candidatePath) { + const relative = path.relative(distDir, candidatePath); + if ( + relative === '' || + relative.startsWith('..') || + path.isAbsolute(relative) + ) { + return undefined; } + + return relative.split(path.sep)[0]; +} + +function isAllowedDistEntry(entry) { + return ( + DIST_ALLOWED_ENTRIES.has(entry) || + DIST_ALLOWED_ENTRY_PATTERNS.some((pattern) => pattern.test(entry)) + ); } function extractNodeArchive(nodeArchive, extractDir) { @@ -295,64 +342,95 @@ function copyExtractedNode(extractDir, nodeDir) { // Official Unix Node.js archives include internal npm/npx symlinks. // The installer rejects symlinks in final archives, so keep safe internal - // targets by copying their referents and reject any symlink that escapes. - assertSymlinksStayInside(sourceRoot); - copyDereferenced(sourceRoot, nodeDir); - assertNoSymlinks(nodeDir, 'Copied Node.js runtime still contains symlinks.'); + // targets by copying their referents during a single checked traversal. + copyNodeRuntimeEntry(sourceRoot, nodeDir, { + realRoot: fs.realpathSync(sourceRoot), + sourceRoot, + activeDirectories: new Set(), + }); } -function copyDereferenced(source, destination) { - const stat = fs.statSync(source); +function copyNodeRuntimeEntry(source, destination, state) { + const lstat = fs.lstatSync(source); + + if (lstat.isSymbolicLink()) { + copyNodeRuntimeEntry( + resolveRuntimeSymlink(source, state), + destination, + state, + ); + return; + } + + if (lstat.isDirectory()) { + const realSource = fs.realpathSync(source); + if (state.activeDirectories.has(realSource)) { + fail( + `Node.js runtime contains a symlink cycle at ${displayRuntimePath( + state, + source, + )}`, + ); + } - if (stat.isDirectory()) { + state.activeDirectories.add(realSource); fs.mkdirSync(destination, { recursive: true }); - fs.chmodSync(destination, stat.mode); + fs.chmodSync(destination, lstat.mode); for (const entry of fs.readdirSync(source)) { - copyDereferenced(path.join(source, entry), path.join(destination, entry)); + copyNodeRuntimeEntry( + path.join(source, entry), + path.join(destination, entry), + state, + ); } + state.activeDirectories.delete(realSource); return; } - if (stat.isFile()) { + if (lstat.isFile()) { fs.copyFileSync(source, destination); - fs.chmodSync(destination, stat.mode); + fs.chmodSync(destination, lstat.mode); return; } fail(`Unsupported Node.js runtime entry type: ${source}`); } -function assertSymlinksStayInside(root) { - const realRoot = fs.realpathSync(root); +function resolveRuntimeSymlink(source, state) { + const target = fs.readlinkSync(source); + const resolvedTarget = path.resolve(path.dirname(source), target); + let realTarget; + try { + realTarget = fs.realpathSync(resolvedTarget); + } catch (error) { + const errorCode = + error && typeof error === 'object' && 'code' in error + ? error.code + : undefined; + const reason = + errorCode === 'ELOOP' ? 'a symlink cycle' : 'a missing target'; + fail( + `Node.js runtime symlink points to ${reason}: ${displayRuntimePath( + state, + source, + )} -> ${target}`, + ); + } - for (const entry of walkDirectory(root)) { - if (!fs.lstatSync(entry).isSymbolicLink()) { - continue; - } + if (!isPathInside(state.realRoot, realTarget)) { + fail( + `Node.js runtime symlink escapes the archive: ${displayRuntimePath( + state, + source, + )} -> ${target}`, + ); + } - const target = fs.readlinkSync(entry); - const resolvedTarget = path.resolve(path.dirname(entry), target); - let realTarget; - try { - realTarget = fs.realpathSync(resolvedTarget); - } catch { - fail( - `Node.js runtime symlink points to a missing target: ${path.relative( - root, - entry, - )} -> ${target}`, - ); - } + return resolvedTarget; +} - if (!isPathInside(realRoot, realTarget)) { - fail( - `Node.js runtime symlink escapes the archive: ${path.relative( - root, - entry, - )} -> ${target}`, - ); - } - } +function displayRuntimePath(state, source) { + return path.relative(state.sourceRoot, source) || '.'; } function assertNoSymlinks(root, message) { @@ -523,3 +601,5 @@ function run(command, args, options = {}) { function fail(message) { throw new Error(`Error: ${message}`); } + +export { writeSha256Sums }; diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index a7b1d8ea2f4..c8ce8cf067a 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -170,7 +170,11 @@ if !STANDALONE_STATUS! EQU 0 ( if !STANDALONE_STATUS! EQU 2 ( echo WARNING: Falling back to npm installation. call :InstallNpm - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) call :PrintFinalInstructions "" endlocal exit /b 0 @@ -246,16 +250,19 @@ if "!INSTALL_BIN_DIR!"=="" ( exit /b 1 ) if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. exit /b 1 :validate_install_base_ok if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. exit /b 1 :validate_install_dir_ok if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. exit /b 1 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 12e61922b5d..1d8c5d7d753 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -343,7 +343,7 @@ require_node() { if ! command_exists node; then log_error "Node.js was not found." print_node_help - exit 1 + return 1 fi local node_version @@ -354,13 +354,13 @@ require_node() { if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then log_error "Unable to determine Node.js version." print_node_help - exit 1 + return 1 fi if [[ "${node_major}" -lt 20 ]]; then log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required." print_node_help - exit 1 + return 1 fi log_success "Node.js ${node_version} detected." @@ -377,7 +377,7 @@ require_npm() { 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." - exit 1 + return 1 } get_npm_global_bin() { @@ -576,7 +576,16 @@ verify_checksum() { fi local expected - expected=$(grep -E "(^|[[:space:]])[*]?${archive_name}$" "${checksum_file}" | awk '{print $1}' | head -n 1) + expected=$(awk -v archive_name="${archive_name}" ' + { + name = $2 + sub(/^\*/, "", name) + if (name == archive_name) { + print $1 + exit + } + } + ' "${checksum_file}") if [[ -z "${expected}" ]]; then rm -f "${temp_checksum}" log_error "Checksum entry for ${archive_name} not found." @@ -600,11 +609,60 @@ verify_checksum() { log_success "Checksum verified for ${archive_name}." } +validate_archive_entry_path() { + local entry="$1" + + while [[ "${entry}" == ./* ]]; do + entry="${entry#./}" + done + + case "${entry}" in + ""|/*|..|../*|*/..|*/../*|*\\*) + log_error "Archive contains unsafe path: ${entry:-}" + return 1 + ;; + esac +} + +validate_archive_contents() { + local archive_path="$1" + local entries + local entry + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to inspect ${archive_path}." + return 1 + fi + if ! entries=$(unzip -Z1 "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *.tar.gz|*.tgz|*.tar.xz) + if ! entries=$(tar -tf "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + while IFS= read -r entry; do + validate_archive_entry_path "${entry}" || return 1 + done <<< "${entries}" +} + extract_archive() { local archive_path="$1" local destination="$2" mkdir -p "${destination}" || return 1 + validate_archive_contents "${archive_path}" || return 1 case "${archive_path}" in *.zip) @@ -664,6 +722,9 @@ EOF } install_standalone() { + # Return 2 only when a standalone archive is unavailable and detect mode may + # fall back to npm. Return 1 for integrity or install failures that should + # not be masked by an automatic fallback. local target="" local archive_name="" local archive_path="" @@ -798,8 +859,8 @@ install_standalone() { } install_npm() { - require_node - require_npm + require_node || return 1 + require_npm || return 1 if command_exists qwen; then local qwen_version @@ -830,7 +891,7 @@ install_npm() { 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 @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" - exit 1 + return 1 } print_final_instructions() { @@ -892,8 +953,13 @@ main() { standalone_status=$? if [[ "${standalone_status}" -eq 2 ]]; then log_warning "Falling back to npm installation." - install_npm - print_final_instructions "$(get_npm_global_bin)" + if install_npm; then + print_final_instructions "$(get_npm_global_bin)" + 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 20+ and rerun --method npm." + exit 1 + fi else log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." exit "${standalone_status}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 574f8909431..0f70578f01b 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -22,7 +22,11 @@ const { execFileSync } = await vi.importActual('node:child_process'); const crypto = await vi.importActual('node:crypto'); const { tmpdir } = await vi.importActual('node:os'); const path = await vi.importActual('node:path'); +const { pathToFileURL } = await vi.importActual('node:url'); const readScript = (path) => readFileSync(path, 'utf8'); +const standaloneReleaseScriptUrl = pathToFileURL( + path.resolve('scripts/build-standalone-release.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; @@ -70,6 +74,12 @@ describe('installation scripts', () => { expect(script).toContain('detect_target()'); expect(script).toContain('verify_checksum()'); expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain('awk -v archive_name'); + expect(script).not.toContain( + 'grep -E "(^|[[:space:]])[*]?${archive_name}$"', + ); + expect(script).toContain('validate_archive_contents()'); + expect(script).toContain('Archive contains unsafe path'); expect(script).toContain('qwen-code-${target}'); expect(script).toContain('*.tar.xz)'); expect(script).toContain('METHOD="${METHOD:-detect}"'); @@ -89,6 +99,10 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); + expect(script).toContain( + 'Return 2 only when a standalone archive is unavailable', + ); + expect(script).toContain('npm fallback also failed'); expect(script).toContain( 'unzip -q "${archive_path}" -d "${destination}" || return 1', ); @@ -157,6 +171,10 @@ describe('installation scripts', () => { expect(script).toContain( 'installer options contain unsafe command characters', ); + expect(script).toContain('[char[]](10,13,33,34'); + expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"'); + expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); + expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); expect(script).toContain(':ValidateVersion'); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', @@ -175,6 +193,7 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('QWEN_INSTALL_ROOT'); + expect(script).toContain('npm fallback also failed'); }); }); @@ -194,11 +213,15 @@ describe('standalone release packaging', () => { const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); expect(packageScript).toContain("'bundled/qc-helper/docs'"); + expect(packageScript).toContain('DIST_ALLOWED_ENTRIES'); + expect(packageScript).toContain('Unexpected dist asset'); + expect(packageScript).toContain('topLevelDistEntryForPath(outDir)'); expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); - expect(packageScript).toContain('assertSymlinksStayInside'); - expect(packageScript).toContain('copyDereferenced'); + expect(packageScript).toContain('copyNodeRuntimeEntry'); + expect(packageScript).toContain('symlink cycle'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); + expect(packageScript).toContain('--skip-checksums'); expect(packageScript).toContain('dereference: true'); expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); @@ -217,6 +240,8 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('expectedArchiveNames'); expect(releaseScript).toContain('qwen-code-${qwenTarget}'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); + expect(releaseScript).toContain('--skip-checksums'); + expect(releaseScript).toContain('writeSha256Sums(outDir)'); }); it('loads the standalone release packaging helper', () => { @@ -230,6 +255,48 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); + it('parses Node.js SHASUMS entries', async () => { + const { parseChecksums } = await import(standaloneReleaseScriptUrl); + + const checksums = parseChecksums( + [ + 'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz', + 'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip', + '', + ].join('\n'), + ); + + expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe( + 'a'.repeat(64), + ); + expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64)); + }); + + it('validates standalone release checksum output', async () => { + const { assertStandaloneOutput, RELEASE_TARGETS } = await import( + standaloneReleaseScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); + + try { + const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { + const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; + }); + writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); + + expect(() => assertStandaloneOutput(tmpDir)).not.toThrow(); + + writeFileSync( + path.join(tmpDir, 'SHA256SUMS'), + `${lines.join('\n')}\n${'b'.repeat(64)} qwen-code-extra.tar.gz\n`, + ); + expect(() => assertStandaloneOutput(tmpDir)).toThrow(/Extra/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -372,6 +439,72 @@ describe('standalone release packaging', () => { } }); + itOnUnix('rejects Node.js runtime symlink cycles', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'linux-x64', + '--node-archive', + createFakeNodeArchive(tmpDir, { + withNodeSymlinkCycle: true, + }), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/symlink cycle/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + + it('rejects unexpected dist assets', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + writeFileSync('dist/debug-cache.tmp', 'debug\n'); + + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'win-x64', + '--node-archive', + createFakeWindowsNodeArchive(tmpDir), + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Unexpected dist asset/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } else { + rmSync('dist/debug-cache.tmp', { force: true }); + } + } + }); + it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); @@ -537,6 +670,27 @@ describe('Linux/macOS installer end-to-end', () => { } }); + itOnUnix( + 'rejects standalone archives containing path traversal entries', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createTraversalStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains unsafe path/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + itOnUnix('refuses to overwrite a non-managed install directory', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -677,6 +831,54 @@ describe('Linux/macOS installer end-to-end', () => { } }, ); + + itOnUnix('preserves context when npm fallback also fails', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n'); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + let failureMessage = ''; + try { + execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-with-source.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--source', + 'smoke', + ], + { + env: { + HOME: path.join(tmpDir, 'home'), + PATH: `${fakeBin}:/usr/bin:/bin`, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + failureMessage = [ + error.message, + error.stdout?.toString() || '', + error.stderr?.toString() || '', + ].join('\n'); + } + + expect(failureMessage).toContain('Falling back to npm installation'); + expect(failureMessage).toMatch( + /Node\.js was not found|Unable to determine Node\.js version/, + ); + expect(failureMessage).toContain('npm fallback also failed'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe('Windows installer end-to-end', () => { @@ -794,6 +996,10 @@ function createFakeNodeArchive(tmpDir, options = {}) { symlinkSync(outsideTarget, path.join(fakeNodeDir, 'bin', 'npm')); } + if (options.withNodeSymlinkCycle) { + symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle')); + } + const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); execFileSync( 'tar', @@ -1061,6 +1267,38 @@ function createSymlinkStandaloneArchive(tmpDir) { return archive; } +function createTraversalStandaloneArchive(tmpDir) { + const maliciousRoot = path.join(tmpDir, 'malicious'); + const packageRoot = path.join(maliciousRoot, 'qwen-code'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true }); + writeFileSync( + path.join(packageRoot, 'bin', 'qwen'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'bin', 'qwen'), 0o755); + writeFileSync( + path.join(packageRoot, 'node', 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code' }), + ); + writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n'); + + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.zip'); + execFileSync('zip', ['-qr', archive, 'qwen-code', '../qwen-slip'], { + cwd: maliciousRoot, + stdio: 'ignore', + }); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function writeChecksumFile(outDir, archiveName) { const archive = path.join(outDir, archiveName); const hash = crypto diff --git a/scripts/tests/test-setup.ts b/scripts/tests/test-setup.ts index d4c4b4655f2..5a769fc6cf1 100644 --- a/scripts/tests/test-setup.ts +++ b/scripts/tests/test-setup.ts @@ -6,7 +6,15 @@ import { vi } from 'vitest'; -vi.mock('fs', () => ({ - ...vi.importActual('fs'), - appendFileSync: vi.fn(), -})); +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + const appendFileSync = vi.fn(); + return { + ...actual, + appendFileSync, + default: { + ...actual, + appendFileSync, + }, + }; +}); From ec654dd87c818382770a785b579318baceadf1d8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 4 May 2026 21:34:33 +0800 Subject: [PATCH 017/129] feat(installer): publish release installer assets --- .github/workflows/release.yml | 5 + README.md | 8 +- docs/users/overview.md | 8 +- docs/users/quickstart.md | 8 +- package.json | 1 + scripts/build-installation-assets.js | 125 ++++++++++++++++++ scripts/build-standalone-release.js | 8 ++ scripts/create-standalone-package.js | 20 +-- scripts/installation/INSTALLATION_GUIDE.md | 16 +++ .../installation/install-qwen-with-source.sh | 6 +- scripts/tests/install-script.test.js | 61 ++++++++- 11 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 scripts/build-installation-assets.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f3559bd52..7a1906bf682 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,6 +384,9 @@ jobs: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + - name: 'Build Installation Assets' + run: 'npm run package:installation-assets -- --out-dir dist/standalone' + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -417,6 +420,8 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ dist/standalone/qwen-code-* \ + dist/standalone/install-qwen.sh \ + dist/standalone/install-qwen.bat \ dist/standalone/SHA256SUMS \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ diff --git a/README.md b/README.md index 03e3d439507..1a3862c6042 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ requires Node.js 20 or later with npm on PATH. #### Linux / macOS ```bash -bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" +curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash ``` #### Windows @@ -59,14 +59,16 @@ bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/inst 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 -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" ``` > **Note**: It's recommended to restart your terminal after installation if > `qwen` is not immediately available on PATH. For offline installation, download > a release archive such as `qwen-code-linux-x64.tar.gz` or > `qwen-code-win-x64.zip` plus `SHA256SUMS`, then run the installer with -> `--archive PATH`. +> `--archive PATH`. The installer scripts are also published as release assets +> for each version. Pass `--version vX.Y.Z` to pin a standalone release instead +> of installing `latest`. ### Manual Installation diff --git a/docs/users/overview.md b/docs/users/overview.md index a40753d7605..c674ec4c55d 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -16,13 +16,13 @@ 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://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.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 -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" ``` > [!note] @@ -31,7 +31,9 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > immediately available on PATH. If the installation fails, please refer to > [Manual Installation](./quickstart#manual-installation) in the Quickstart > guide. For offline installation, download a release archive and run the -> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. +> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. The +> installer scripts are published as release assets for each version. Pass +> `--version vX.Y.Z` to pin a standalone release instead of installing `latest`. ### Start using Qwen Code: diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index aba0a8c07ca..29d3c8e38e7 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -25,13 +25,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://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.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 -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" ``` > [!note] @@ -39,7 +39,9 @@ powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou > It's recommended to restart your terminal after installation if `qwen` is not > immediately available on PATH. For offline installation, download a release > archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip` plus -> `SHA256SUMS`, then run the installer with `--archive PATH`. +> `SHA256SUMS`, then run the installer with `--archive PATH`. The installer +> scripts are published as release assets for each version. Pass +> `--version vX.Y.Z` to pin a standalone release instead of installing `latest`. ### Manual Installation diff --git a/package.json b/package.json index 00fd82774b2..1df6e57beb9 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", + "package:installation-assets": "node scripts/build-installation-assets.js", "package:standalone": "node scripts/create-standalone-package.js", "package:standalone:release": "node scripts/build-standalone-release.js", "release:version": "node scripts/version.js", diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js new file mode 100644 index 00000000000..deebc1a62d6 --- /dev/null +++ b/scripts/build-installation-assets.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeSha256Sums } from './create-standalone-package.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const INSTALLATION_ASSETS = [ + { + source: path.join( + rootDir, + 'scripts', + 'installation', + 'install-qwen-with-source.sh', + ), + output: 'install-qwen.sh', + mode: 0o755, + }, + { + source: path.join( + rootDir, + 'scripts', + 'installation', + 'install-qwen-with-source.bat', + ), + output: 'install-qwen.bat', + }, +]; + +if (isMainModule()) { + try { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printUsage(); + } else { + await buildInstallationAssets( + path.resolve(args.outDir || path.join(rootDir, 'dist', 'standalone')), + ); + } + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + +async function buildInstallationAssets(outDir) { + fs.mkdirSync(outDir, { recursive: true }); + + for (const asset of INSTALLATION_ASSETS) { + if (!fs.existsSync(asset.source)) { + fail(`Installation source asset not found: ${asset.source}`); + } + + const destination = path.join(outDir, asset.output); + fs.copyFileSync(asset.source, destination); + if (asset.mode !== undefined && process.platform !== 'win32') { + fs.chmodSync(destination, asset.mode); + } + } + + await writeSha256Sums(outDir); +} + +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function printUsage() { + console.log(` +Usage: + npm run package:installation-assets -- [OPTIONS] + +Options: + --out-dir PATH Output directory. Defaults to dist/standalone. +`); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { buildInstallationAssets, INSTALLATION_ASSETS }; diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index b0bf2bd1706..7a1ff516015 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -200,6 +200,7 @@ function assertStandaloneOutput(outDir) { .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) .filter(Boolean) + .filter(isStandaloneArchiveName) .sort(); const expectedArchiveNames = RELEASE_TARGETS.map( ({ qwenTarget }) => @@ -232,6 +233,13 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } +function isStandaloneArchiveName(archiveName) { + return ( + archiveName.startsWith('qwen-code-') && + (archiveName.endsWith('.tar.gz') || archiveName.endsWith('.zip')) + ); +} + function parseArgs(argv) { const args = { help: false, diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 968d6da45ed..eacca8967d4 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -552,14 +552,7 @@ function createZipArchive(outputPath, cwd) { } async function writeSha256Sums(outDir) { - const entries = fs - .readdirSync(outDir) - .filter( - (entry) => - entry.startsWith('qwen-code-') && - (entry.endsWith('.tar.gz') || entry.endsWith('.zip')), - ) - .sort(); + const entries = fs.readdirSync(outDir).filter(isReleaseChecksumAsset).sort(); if (entries.length === 0) { fail( @@ -577,6 +570,17 @@ async function writeSha256Sums(outDir) { fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } +function isReleaseChecksumAsset(entry) { + if ( + entry.startsWith('qwen-code-') && + (entry.endsWith('.tar.gz') || entry.endsWith('.zip')) + ) { + return true; + } + + return entry === 'install-qwen.sh' || entry === 'install-qwen.bat'; +} + async function sha256File(filePath) { const hash = crypto.createHash('sha256'); await pipeline(fs.createReadStream(filePath), hash); diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 21a2b52f2b3..4d784bd6267 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -34,8 +34,24 @@ GitHub releases publish these standalone archives: - `qwen-code-linux-arm64.tar.gz` - `qwen-code-linux-x64.tar.gz` - `qwen-code-win-x64.zip` +- `install-qwen.sh` +- `install-qwen.bat` - `SHA256SUMS` +The installer scripts are published as release assets so the default install +entrypoint can be distributed alongside the standalone archives: + +```bash +curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash +``` + +```bat +powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +``` + +The installer defaults to `latest`. Pass `--version vX.Y.Z` to pin the +standalone archive version. + Archive layout: ```text diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1d8c5d7d753..d6a47ae6261 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -95,9 +95,9 @@ Options: -h, --help Show this help message. Examples: - 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.sh | bash -s -- --source github - curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --method standalone + curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash + curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash -s -- --source github + curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash -s -- --method standalone ./install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz EOF } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 0f70578f01b..aa49605ccd8 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -27,6 +27,9 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; +const installationAssetsScriptUrl = pathToFileURL( + path.resolve('scripts/build-installation-assets.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; @@ -207,8 +210,12 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:standalone:release']).toBe( 'node scripts/build-standalone-release.js', ); + expect(packageJson.scripts['package:installation-assets']).toBe( + 'node scripts/build-installation-assets.js', + ); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); + expect(existsSync('scripts/build-installation-assets.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -242,6 +249,16 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); + + const installationAssetsScript = readScript( + 'scripts/build-installation-assets.js', + ); + expect(installationAssetsScript).toContain('Copyright 2025 Qwen Team'); + expect(installationAssetsScript).toContain('install-qwen-with-source.sh'); + expect(installationAssetsScript).toContain('install-qwen.sh'); + expect(installationAssetsScript).toContain('install-qwen-with-source.bat'); + expect(installationAssetsScript).toContain('install-qwen.bat'); + expect(installationAssetsScript).toContain('writeSha256Sums(outDir)'); }); it('loads the standalone release packaging helper', () => { @@ -283,7 +300,14 @@ describe('standalone release packaging', () => { const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); - writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); + writeFileSync( + path.join(tmpDir, 'SHA256SUMS'), + `${[ + ...lines, + `${'c'.repeat(64)} install-qwen.sh`, + `${'d'.repeat(64)} install-qwen.bat`, + ].join('\n')}\n`, + ); expect(() => assertStandaloneOutput(tmpDir)).not.toThrow(); @@ -297,6 +321,38 @@ describe('standalone release packaging', () => { } }); + it('builds release installation assets with checksums', async () => { + const { buildInstallationAssets } = await import( + installationAssetsScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); + + try { + writeFileSync(path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'), 'fake'); + + await buildInstallationAssets(tmpDir); + + const installSh = path.join(tmpDir, 'install-qwen.sh'); + const installBat = path.join(tmpDir, 'install-qwen.bat'); + const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); + + expect(readScript(installSh)).toBe( + readScript('scripts/installation/install-qwen-with-source.sh'), + ); + expect(readScript(installBat)).toBe( + readScript('scripts/installation/install-qwen-with-source.bat'), + ); + expect(checksums).toContain('qwen-code-linux-x64.tar.gz'); + expect(checksums).toContain('install-qwen.sh'); + expect(checksums).toContain('install-qwen.bat'); + if (process.platform !== 'win32') { + expect(lstatSync(installSh).mode & 0o111).not.toBe(0); + } + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -509,9 +565,12 @@ describe('standalone release packaging', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); + expect(workflow).toContain('npm run package:installation-assets --'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); + expect(workflow).toContain('dist/standalone/install-qwen.sh'); + expect(workflow).toContain('dist/standalone/install-qwen.bat'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); }); From ccf83e9da7eb445908778798d3b899540caf4fbc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 18:39:04 +0800 Subject: [PATCH 018/129] fix(installer): address release asset review feedback --- scripts/build-installation-assets.js | 89 +++++++++++++------- scripts/build-standalone-release.js | 8 +- scripts/create-standalone-package.js | 14 +--- scripts/release-asset-config.js | 50 ++++++++++++ scripts/tests/install-script.test.js | 116 +++++++++++++++++++++++++-- 5 files changed, 223 insertions(+), 54 deletions(-) create mode 100644 scripts/release-asset-config.js diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index deebc1a62d6..2fd453d9c56 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -7,36 +7,17 @@ */ import fs from 'node:fs'; +import crypto from 'node:crypto'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { writeSha256Sums } from './create-standalone-package.js'; +import { INSTALLATION_ASSETS } from './release-asset-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -const INSTALLATION_ASSETS = [ - { - source: path.join( - rootDir, - 'scripts', - 'installation', - 'install-qwen-with-source.sh', - ), - output: 'install-qwen.sh', - mode: 0o755, - }, - { - source: path.join( - rootDir, - 'scripts', - 'installation', - 'install-qwen-with-source.bat', - ), - output: 'install-qwen.bat', - }, -]; - if (isMainModule()) { try { const args = parseArgs(process.argv.slice(2)); @@ -57,22 +38,70 @@ function isMainModule() { return process.argv[1] && path.resolve(process.argv[1]) === __filename; } -async function buildInstallationAssets(outDir) { +async function buildInstallationAssets(outDir, options = {}) { + const { assets = INSTALLATION_ASSETS, root = rootDir } = options; fs.mkdirSync(outDir, { recursive: true }); - for (const asset of INSTALLATION_ASSETS) { - if (!fs.existsSync(asset.source)) { - fail(`Installation source asset not found: ${asset.source}`); + for (const asset of assets) { + const source = path.join(root, ...asset.sourcePath); + if (!fs.existsSync(source)) { + fail(`Installation source asset not found: ${source}`); } const destination = path.join(outDir, asset.output); - fs.copyFileSync(asset.source, destination); + fs.copyFileSync(source, destination); if (asset.mode !== undefined && process.platform !== 'win32') { fs.chmodSync(destination, asset.mode); } } await writeSha256Sums(outDir); + await assertInstallationAssetChecksums(outDir, assets); +} + +async function assertInstallationAssetChecksums( + outDir, + assets = INSTALLATION_ASSETS, +) { + const checksumPath = path.join(outDir, 'SHA256SUMS'); + if (!fs.existsSync(checksumPath)) { + fail(`SHA256SUMS was not created at ${checksumPath}`); + } + + const checksums = parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); + for (const { output } of assets) { + const expected = checksums.get(output); + if (!expected) { + fail(`Checksum entry for ${output} not found.`); + } + + const actual = await sha256File(path.join(outDir, output)); + if (actual !== expected) { + fail(`Checksum verification failed for ${output}.`); + } + } +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (match) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); } function parseArgs(argv) { @@ -122,4 +151,8 @@ function fail(message) { throw new Error(`ERROR: ${message}`); } -export { buildInstallationAssets, INSTALLATION_ASSETS }; +export { + assertInstallationAssetChecksums, + buildInstallationAssets, + INSTALLATION_ASSETS, +}; diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 7a1ff516015..fb5ac9e0f21 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -15,6 +15,7 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { writeSha256Sums } from './create-standalone-package.js'; +import { isStandaloneArchiveName } from './release-asset-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -233,13 +234,6 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } -function isStandaloneArchiveName(archiveName) { - return ( - archiveName.startsWith('qwen-code-') && - (archiveName.endsWith('.tar.gz') || archiveName.endsWith('.zip')) - ); -} - function parseArgs(argv) { const args = { help: false, diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index eacca8967d4..a5872990d7c 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -13,6 +13,7 @@ import os from 'node:os'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { isReleaseChecksumAsset } from './release-asset-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -556,7 +557,7 @@ async function writeSha256Sums(outDir) { if (entries.length === 0) { fail( - `No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`, + `No release checksum assets found in ${outDir}; refusing to write empty SHA256SUMS.`, ); } @@ -570,17 +571,6 @@ async function writeSha256Sums(outDir) { fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -function isReleaseChecksumAsset(entry) { - if ( - entry.startsWith('qwen-code-') && - (entry.endsWith('.tar.gz') || entry.endsWith('.zip')) - ) { - return true; - } - - return entry === 'install-qwen.sh' || entry === 'install-qwen.bat'; -} - async function sha256File(filePath) { const hash = crypto.createHash('sha256'); await pipeline(fs.createReadStream(filePath), hash); diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js new file mode 100644 index 00000000000..685e990308c --- /dev/null +++ b/scripts/release-asset-config.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const STANDALONE_ARCHIVE_PREFIX = 'qwen-code-'; +const STANDALONE_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip']; + +const INSTALLATION_ASSETS = [ + { + sourcePath: ['scripts', 'installation', 'install-qwen-with-source.sh'], + output: 'install-qwen.sh', + mode: 0o755, + }, + { + sourcePath: ['scripts', 'installation', 'install-qwen-with-source.bat'], + output: 'install-qwen.bat', + }, +]; + +const INSTALLATION_ASSET_NAMES = INSTALLATION_ASSETS.map( + ({ output }) => output, +); +const INSTALLATION_ASSET_NAME_SET = new Set(INSTALLATION_ASSET_NAMES); + +function isStandaloneArchiveName(fileName) { + return ( + fileName.startsWith(STANDALONE_ARCHIVE_PREFIX) && + STANDALONE_ARCHIVE_EXTENSIONS.some((extension) => + fileName.endsWith(extension), + ) + ); +} + +function isInstallationAssetName(fileName) { + return INSTALLATION_ASSET_NAME_SET.has(fileName); +} + +function isReleaseChecksumAsset(fileName) { + return isStandaloneArchiveName(fileName) || isInstallationAssetName(fileName); +} + +export { + INSTALLATION_ASSET_NAMES, + INSTALLATION_ASSETS, + isInstallationAssetName, + isReleaseChecksumAsset, + isStandaloneArchiveName, +}; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index aa49605ccd8..32cd1a0a33a 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -30,6 +30,9 @@ const standaloneReleaseScriptUrl = pathToFileURL( const installationAssetsScriptUrl = pathToFileURL( path.resolve('scripts/build-installation-assets.js'), ).href; +const releaseAssetConfigUrl = pathToFileURL( + path.resolve('scripts/release-asset-config.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; @@ -216,6 +219,7 @@ describe('standalone release packaging', () => { expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(true); + expect(existsSync('scripts/release-asset-config.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -233,6 +237,9 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); + expect(packageScript).toContain( + "import { isReleaseChecksumAsset } from './release-asset-config.js';", + ); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); @@ -249,16 +256,28 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); + expect(releaseScript).toContain( + "import { isStandaloneArchiveName } from './release-asset-config.js';", + ); const installationAssetsScript = readScript( 'scripts/build-installation-assets.js', ); expect(installationAssetsScript).toContain('Copyright 2025 Qwen Team'); - expect(installationAssetsScript).toContain('install-qwen-with-source.sh'); - expect(installationAssetsScript).toContain('install-qwen.sh'); - expect(installationAssetsScript).toContain('install-qwen-with-source.bat'); - expect(installationAssetsScript).toContain('install-qwen.bat'); expect(installationAssetsScript).toContain('writeSha256Sums(outDir)'); + expect(installationAssetsScript).toContain( + 'assertInstallationAssetChecksums(outDir, assets)', + ); + + const releaseAssetConfig = readScript('scripts/release-asset-config.js'); + expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); + expect(releaseAssetConfig).toContain('INSTALLATION_ASSETS'); + expect(releaseAssetConfig).toContain('install-qwen-with-source.sh'); + expect(releaseAssetConfig).toContain('install-qwen.sh'); + expect(releaseAssetConfig).toContain('install-qwen-with-source.bat'); + expect(releaseAssetConfig).toContain('install-qwen.bat'); + expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); + expect(releaseAssetConfig).toContain('isReleaseChecksumAsset'); }); it('loads the standalone release packaging helper', () => { @@ -272,6 +291,47 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); + it('loads the installation asset packaging helper', () => { + const output = execFileSync( + process.execPath, + ['scripts/build-installation-assets.js', '--help'], + { encoding: 'utf8' }, + ); + + expect(output).toContain('package:installation-assets'); + expect(output).toContain('--out-dir PATH'); + }); + + it('rejects invalid installation asset CLI arguments', () => { + expectCommandFailure( + ['scripts/build-installation-assets.js', '--unknown'], + /Unknown option: --unknown/, + ); + expectCommandFailure( + ['scripts/build-installation-assets.js', '--out-dir'], + /--out-dir requires a value/, + ); + }); + + it('shares release asset classification helpers', async () => { + const { + INSTALLATION_ASSET_NAMES, + isInstallationAssetName, + isReleaseChecksumAsset, + isStandaloneArchiveName, + } = await import(releaseAssetConfigUrl); + + expect(INSTALLATION_ASSET_NAMES).toEqual([ + 'install-qwen.sh', + 'install-qwen.bat', + ]); + expect(isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe(true); + expect(isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); + expect(isStandaloneArchiveName('install-qwen.sh')).toBe(false); + expect(isInstallationAssetName('install-qwen.sh')).toBe(true); + expect(isReleaseChecksumAsset('install-qwen.bat')).toBe(true); + }); + it('parses Node.js SHASUMS entries', async () => { const { parseChecksums } = await import(standaloneReleaseScriptUrl); @@ -322,9 +382,8 @@ describe('standalone release packaging', () => { }); it('builds release installation assets with checksums', async () => { - const { buildInstallationAssets } = await import( - installationAssetsScriptUrl - ); + const { assertInstallationAssetChecksums, buildInstallationAssets } = + await import(installationAssetsScriptUrl); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); try { @@ -348,11 +407,33 @@ describe('standalone release packaging', () => { if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); } + + writeFileSync(installSh, 'tampered'); + await expect(assertInstallationAssetChecksums(tmpDir)).rejects.toThrow( + /Checksum verification failed for install-qwen\.sh/, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); + it('rejects missing installation asset sources', async () => { + const { buildInstallationAssets } = await import( + installationAssetsScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-install-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); + + try { + await expect( + buildInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow(/Installation source asset not found/); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -1296,6 +1377,27 @@ function runWindowsCommand(command, env = {}) { }); } +function expectCommandFailure(args, expectedOutput) { + let caughtError; + try { + execFileSync(process.execPath, args, { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeTruthy(); + expect( + [ + caughtError?.message, + caughtError?.stdout?.toString(), + caughtError?.stderr?.toString(), + ].join('\n'), + ).toMatch(expectedOutput); +} + function createSymlinkStandaloneArchive(tmpDir) { const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); From b078ade5876c418cd3dd5b44aa74aab57ed243f0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 19:32:36 +0800 Subject: [PATCH 019/129] fix(installer): avoid prerelease installer asset links --- .github/workflows/release.yml | 2 ++ README.md | 8 +++----- docs/users/overview.md | 8 +++----- docs/users/quickstart.md | 8 +++----- scripts/installation/INSTALLATION_GUIDE.md | 15 +++++++++------ scripts/installation/install-qwen-with-source.sh | 6 +++--- 6 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a1906bf682..80abd142cda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,6 +384,8 @@ jobs: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + # Rewrites SHA256SUMS after copying installer scripts so the release + # checksum file covers both standalone archives and installer assets. - name: 'Build Installation Assets' run: 'npm run package:installation-assets -- --out-dir dist/standalone' diff --git a/README.md b/README.md index 1a3862c6042..03e3d439507 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ requires Node.js 20 or later with npm on PATH. #### Linux / macOS ```bash -curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash +bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" ``` #### Windows @@ -59,16 +59,14 @@ curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install- Works in both Command Prompt and PowerShell: ```cmd -powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +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')" ``` > **Note**: It's recommended to restart your terminal after installation if > `qwen` is not immediately available on PATH. For offline installation, download > a release archive such as `qwen-code-linux-x64.tar.gz` or > `qwen-code-win-x64.zip` plus `SHA256SUMS`, then run the installer with -> `--archive PATH`. The installer scripts are also published as release assets -> for each version. Pass `--version vX.Y.Z` to pin a standalone release instead -> of installing `latest`. +> `--archive PATH`. ### Manual Installation diff --git a/docs/users/overview.md b/docs/users/overview.md index c674ec4c55d..a40753d7605 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -16,13 +16,13 @@ available on PATH. **Linux / macOS** ```sh -curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash ``` **Windows** ```cmd -powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +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')" ``` > [!note] @@ -31,9 +31,7 @@ powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/rele > immediately available on PATH. If the installation fails, please refer to > [Manual Installation](./quickstart#manual-installation) in the Quickstart > guide. For offline installation, download a release archive and run the -> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. The -> installer scripts are published as release assets for each version. Pass -> `--version vX.Y.Z` to pin a standalone release instead of installing `latest`. +> installer with `--archive PATH`; keep `SHA256SUMS` next to the archive. ### Start using Qwen Code: diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 29d3c8e38e7..aba0a8c07ca 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -25,13 +25,13 @@ To install Qwen Code, use one of the following methods: **Linux / macOS** ```sh -curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash ``` **Windows** ```cmd -powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +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')" ``` > [!note] @@ -39,9 +39,7 @@ powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/rele > It's recommended to restart your terminal after installation if `qwen` is not > immediately available on PATH. For offline installation, download a release > archive such as `qwen-code-linux-x64.tar.gz` or `qwen-code-win-x64.zip` plus -> `SHA256SUMS`, then run the installer with `--archive PATH`. The installer -> scripts are published as release assets for each version. Pass -> `--version vX.Y.Z` to pin a standalone release instead of installing `latest`. +> `SHA256SUMS`, then run the installer with `--archive PATH`. ### Manual Installation diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 4d784bd6267..2756f14f1f1 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -38,19 +38,22 @@ GitHub releases publish these standalone archives: - `install-qwen.bat` - `SHA256SUMS` -The installer scripts are published as release assets so the default install -entrypoint can be distributed alongside the standalone archives: +The installer scripts are published as release assets so version-specific +install entrypoints can be distributed alongside the standalone archives after +that release is created: ```bash -curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash +curl -fsSL https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install-qwen.sh | bash ``` ```bat -powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" ``` -The installer defaults to `latest`. Pass `--version vX.Y.Z` to pin the -standalone archive version. +Public quick-install docs should switch to release asset URLs only after at +least one published release includes `install-qwen.sh` and `install-qwen.bat`. +Until then, keep public quick-install examples on the hosted installation URLs +used in the README and user docs. Archive layout: diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index d6a47ae6261..1d8c5d7d753 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -95,9 +95,9 @@ Options: -h, --help Show this help message. Examples: - curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash - curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash -s -- --source github - curl -fsSL https://github.com/QwenLM/qwen-code/releases/latest/download/install-qwen.sh | bash -s -- --method standalone + 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.sh | bash -s -- --source github + curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --method standalone ./install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz EOF } From 668c00694113ac9c08c200d531041e418cd41199 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 19:49:42 +0800 Subject: [PATCH 020/129] test(installer): isolate standalone dist fixture --- scripts/tests/install-script.test.js | 93 ++++++++++++---------------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 32cd1a0a33a..207ba55395a 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -14,6 +14,7 @@ const { mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -435,7 +436,7 @@ describe('standalone release packaging', () => { }); it('rejects a runtime archive without a Node executable', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -464,14 +465,12 @@ describe('standalone release packaging', () => { ).toThrow(/Node\.js runtime for .* must contain/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); it('packages a win-x64 standalone archive', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -509,14 +508,12 @@ describe('standalone release packaging', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }, 30_000); itOnUnix('dereferences safe Node.js runtime symlinks', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -538,14 +535,12 @@ describe('standalone release packaging', () => { expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -570,14 +565,12 @@ describe('standalone release packaging', () => { ).toThrow(/symlink escapes the archive/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); itOnUnix('rejects Node.js runtime symlink cycles', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -602,14 +595,12 @@ describe('standalone release packaging', () => { ).toThrow(/symlink cycle/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); it('rejects unexpected dist assets', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -634,11 +625,7 @@ describe('standalone release packaging', () => { ).toThrow(/Unexpected dist asset/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } else { - rmSync('dist/debug-cache.tmp', { force: true }); - } + restoreDist(); } }); @@ -675,7 +662,7 @@ describe('Linux/macOS installer end-to-end', () => { itOnUnix( 'installs a local standalone archive with checksum verification', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -702,15 +689,13 @@ describe('Linux/macOS installer end-to-end', () => { expect(version).toBe('0.0.0-smoke'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }, ); itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -740,14 +725,12 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); itOnUnix('rejects a tampered local archive', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -763,14 +746,12 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/Checksum verification failed/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); itOnUnix('rejects a local archive when SHA256SUMS is missing', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -786,9 +767,7 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); @@ -832,7 +811,7 @@ describe('Linux/macOS installer end-to-end', () => { ); itOnUnix('refuses to overwrite a non-managed install directory', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -850,14 +829,12 @@ describe('Linux/macOS installer end-to-end', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); itOnUnix('does not fall back to npm when detect finds a bad archive', () => { - const createdDist = ensureMinimalDist(); + const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -881,9 +858,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreDist(); } }); @@ -1101,8 +1076,13 @@ describe('Windows installer end-to-end', () => { }); function ensureMinimalDist() { - if (existsSync('dist')) { - return false; + const distPath = path.resolve('dist'); + const backupRoot = mkdtempSync(path.join(tmpdir(), 'qwen-dist-backup-')); + const backupDist = path.join(backupRoot, 'dist'); + const hadExistingDist = existsSync(distPath); + + if (hadExistingDist) { + renameSync(distPath, backupDist); } mkdirSync('dist/vendor', { recursive: true }); @@ -1112,7 +1092,14 @@ function ensureMinimalDist() { 'dist/package.json', JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - return true; + + return () => { + rmSync(distPath, { recursive: true, force: true }); + if (hadExistingDist) { + renameSync(backupDist, distPath); + } + rmSync(backupRoot, { recursive: true, force: true }); + }; } function createFakeNodeArchive(tmpDir, options = {}) { From a205e6ccdc0a7736b18ef92022360afce061f2fa Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 19:57:54 +0800 Subject: [PATCH 021/129] feat(installer): add hosted install release alias --- .github/workflows/release.yml | 1 + scripts/installation/INSTALLATION_GUIDE.md | 16 +++++++++++++++- scripts/release-asset-config.js | 6 ++++++ scripts/tests/install-script.test.js | 11 +++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80abd142cda..79b5ffc7d1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -422,6 +422,7 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ dist/standalone/qwen-code-* \ + dist/standalone/install \ dist/standalone/install-qwen.sh \ dist/standalone/install-qwen.bat \ dist/standalone/SHA256SUMS \ diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 2756f14f1f1..0ce7a581515 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -34,13 +34,27 @@ GitHub releases publish these standalone archives: - `qwen-code-linux-arm64.tar.gz` - `qwen-code-linux-x64.tar.gz` - `qwen-code-win-x64.zip` +- `install` - `install-qwen.sh` - `install-qwen.bat` - `SHA256SUMS` +Release packaging copies these from the source-tracking installer scripts: +`install` and `install-qwen.sh` come from `install-qwen-with-source.sh`, while +`install-qwen.bat` comes from `install-qwen-with-source.bat`. + The installer scripts are published as release assets so version-specific install entrypoints can be distributed alongside the standalone archives after -that release is created: +that release is created. The extensionless `install` asset is the Unix installer +alias for future hosted endpoints such as `https://qwen-code.ai/install`; it is +identical to `install-qwen.sh` and can be used interchangeably on Unix systems. +Use `install-qwen.bat` on Windows. That hosted endpoint is not available yet; +until it is live, use a concrete release version in the direct GitHub release +URL and replace `vX.Y.Z` with the actual release tag: + +```bash +curl -fsSL https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install | bash +``` ```bash curl -fsSL https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install-qwen.sh | bash diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js index 685e990308c..d345aa497a4 100644 --- a/scripts/release-asset-config.js +++ b/scripts/release-asset-config.js @@ -13,6 +13,12 @@ const INSTALLATION_ASSETS = [ output: 'install-qwen.sh', mode: 0o755, }, + // Hosted endpoint alias for install-qwen.sh; keep byte-for-byte identical. + { + sourcePath: ['scripts', 'installation', 'install-qwen-with-source.sh'], + output: 'install', + mode: 0o755, + }, { sourcePath: ['scripts', 'installation', 'install-qwen-with-source.bat'], output: 'install-qwen.bat', diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 207ba55395a..cfa9e450cb7 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -274,6 +274,7 @@ describe('standalone release packaging', () => { expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); expect(releaseAssetConfig).toContain('INSTALLATION_ASSETS'); expect(releaseAssetConfig).toContain('install-qwen-with-source.sh'); + expect(releaseAssetConfig).toContain("output: 'install'"); expect(releaseAssetConfig).toContain('install-qwen.sh'); expect(releaseAssetConfig).toContain('install-qwen-with-source.bat'); expect(releaseAssetConfig).toContain('install-qwen.bat'); @@ -324,12 +325,15 @@ describe('standalone release packaging', () => { expect(INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', + 'install', 'install-qwen.bat', ]); expect(isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe(true); expect(isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); expect(isStandaloneArchiveName('install-qwen.sh')).toBe(false); + expect(isInstallationAssetName('install')).toBe(true); expect(isInstallationAssetName('install-qwen.sh')).toBe(true); + expect(isReleaseChecksumAsset('install')).toBe(true); expect(isReleaseChecksumAsset('install-qwen.bat')).toBe(true); }); @@ -393,20 +397,25 @@ describe('standalone release packaging', () => { await buildInstallationAssets(tmpDir); const installSh = path.join(tmpDir, 'install-qwen.sh'); + const install = path.join(tmpDir, 'install'); const installBat = path.join(tmpDir, 'install-qwen.bat'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen-with-source.sh'), ); + // The hosted endpoint alias must remain byte-for-byte equivalent. + expect(readScript(install)).toBe(readScript(installSh)); expect(readScript(installBat)).toBe( readScript('scripts/installation/install-qwen-with-source.bat'), ); expect(checksums).toContain('qwen-code-linux-x64.tar.gz'); + expect(checksums).toMatch(/\sinstall\n/); expect(checksums).toContain('install-qwen.sh'); expect(checksums).toContain('install-qwen.bat'); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); + expect(lstatSync(install).mode & 0o111).not.toBe(0); } writeFileSync(installSh, 'tampered'); @@ -637,6 +646,7 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); + expect(workflow).toContain('dist/standalone/install \\'); expect(workflow).toContain('dist/standalone/install-qwen.sh'); expect(workflow).toContain('dist/standalone/install-qwen.bat'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); @@ -653,6 +663,7 @@ describe('standalone release packaging', () => { const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); expect(guide).toContain('Optional Native Modules'); + expect(guide).toContain('https://qwen-code.ai/install'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); From bbaa8ffc7593ac17ad5da8d63fdff2f40d67d489 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:14:35 +0000 Subject: [PATCH 022/129] chore: no changes - code review requested Agent-Logs-Url: https://github.com/QwenLM/qwen-code/sessions/38467aec-15b9-4b76-9139-0b2cfe40477a --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b01f2c9628d..ea5441b8051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12842,7 +12842,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } From 6534e6f971a9ecd142fa2aee0e324cb4b003b12e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 22:13:28 +0800 Subject: [PATCH 023/129] fix(installer): pin versioned installer assets --- .github/workflows/release.yml | 4 +- scripts/build-installation-assets.js | 69 +++++++++++++++++++++++++++- scripts/tests/install-script.test.js | 44 +++++++++++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80abd142cda..67117f9d5b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,7 +387,9 @@ jobs: # Rewrites SHA256SUMS after copying installer scripts so the release # checksum file covers both standalone archives and installer assets. - name: 'Build Installation Assets' - run: 'npm run package:installation-assets -- --out-dir dist/standalone' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: 'npm run package:installation-assets -- --out-dir dist/standalone --version "${RELEASE_VERSION}"' - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index 2fd453d9c56..48c5aa84340 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -26,6 +26,9 @@ if (isMainModule()) { } else { await buildInstallationAssets( path.resolve(args.outDir || path.join(rootDir, 'dist', 'standalone')), + { + version: args.version, + }, ); } } catch (error) { @@ -39,7 +42,7 @@ function isMainModule() { } async function buildInstallationAssets(outDir, options = {}) { - const { assets = INSTALLATION_ASSETS, root = rootDir } = options; + const { assets = INSTALLATION_ASSETS, root = rootDir, version } = options; fs.mkdirSync(outDir, { recursive: true }); for (const asset of assets) { @@ -49,7 +52,11 @@ async function buildInstallationAssets(outDir, options = {}) { } const destination = path.join(outDir, asset.output); - fs.copyFileSync(source, destination); + const contents = fs.readFileSync(source, 'utf8'); + fs.writeFileSync( + destination, + version ? stampInstallerVersion(contents, asset, version) : contents, + ); if (asset.mode !== undefined && process.platform !== 'win32') { fs.chmodSync(destination, asset.mode); } @@ -59,6 +66,56 @@ async function buildInstallationAssets(outDir, options = {}) { await assertInstallationAssetChecksums(outDir, assets); } +function stampInstallerVersion(contents, asset, version) { + validateReleaseVersion(version); + + const sourceName = asset.sourcePath.at(-1); + if (sourceName.endsWith('.sh')) { + const stampedDefault = replaceRequired( + contents, + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + `VERSION="\${QWEN_INSTALL_VERSION:-${version}}"`, + asset.output, + ); + return stampVersionHelpText(stampedDefault, asset.output, version); + } + + if (sourceName.endsWith('.bat')) { + const stampedDefault = replaceRequired( + contents, + 'set "VERSION=latest"', + `set "VERSION=${version}"`, + asset.output, + ); + return stampVersionHelpText(stampedDefault, asset.output, version); + } + + return contents; +} + +function replaceRequired(contents, search, replacement, output) { + if (!contents.includes(search)) { + fail(`Unable to stamp release version in ${output}`); + } + return contents.replace(search, replacement); +} + +function stampVersionHelpText(contents, output, version) { + return replaceRequired( + contents, + 'Standalone release version. Defaults to latest.', + `Standalone release version. Defaults to ${version}.`, + output, + ); +} + +function validateReleaseVersion(version) { + if (/^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$/.test(version)) { + return; + } + fail('--version must be a semver string'); +} + async function assertInstallationAssetChecksums( outDir, assets = INSTALLATION_ASSETS, @@ -108,6 +165,7 @@ function parseArgs(argv) { const args = { help: false, outDir: undefined, + version: undefined, }; for (let index = 0; index < argv.length; index += 1) { @@ -121,6 +179,11 @@ function parseArgs(argv) { args.outDir = readOptionValue(argv, index, arg); index += 1; break; + case '--version': + args.version = readOptionValue(argv, index, arg); + validateReleaseVersion(args.version); + index += 1; + break; default: fail(`Unknown option: ${arg}`); } @@ -144,6 +207,8 @@ Usage: Options: --out-dir PATH Output directory. Defaults to dist/standalone. + --version VERSION + Stamp release installers so their default version is VERSION. `); } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 207ba55395a..98a96e4c0c5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -301,6 +301,7 @@ describe('standalone release packaging', () => { expect(output).toContain('package:installation-assets'); expect(output).toContain('--out-dir PATH'); + expect(output).toContain('--version VERSION'); }); it('rejects invalid installation asset CLI arguments', () => { @@ -312,6 +313,14 @@ describe('standalone release packaging', () => { ['scripts/build-installation-assets.js', '--out-dir'], /--out-dir requires a value/, ); + expectCommandFailure( + ['scripts/build-installation-assets.js', '--version'], + /--version requires a value/, + ); + expectCommandFailure( + ['scripts/build-installation-assets.js', '--version', 'beta'], + /--version must be a semver string/, + ); }); it('shares release asset classification helpers', async () => { @@ -418,6 +427,37 @@ describe('standalone release packaging', () => { } }); + it('stamps release versions into copied installation assets', async () => { + const { assertInstallationAssetChecksums, buildInstallationAssets } = + await import(installationAssetsScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); + + try { + await buildInstallationAssets(tmpDir, { version: '0.16.0' }); + + const installSh = readScript(path.join(tmpDir, 'install-qwen.sh')); + const installBat = readScript(path.join(tmpDir, 'install-qwen.bat')); + + expect(installSh).toContain('VERSION="${QWEN_INSTALL_VERSION:-0.16.0}"'); + expect(installSh).toContain( + 'Standalone release version. Defaults to 0.16.0.', + ); + expect(installSh).toContain('--version)'); + expect(installBat).toContain('set "VERSION=0.16.0"'); + expect(installBat).toContain( + 'Standalone release version. Defaults to 0.16.0.', + ); + expect(installBat).toContain( + 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', + ); + await expect( + assertInstallationAssetChecksums(tmpDir), + ).resolves.not.toThrow(); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects missing installation asset sources', async () => { const { buildInstallationAssets } = await import( installationAssetsScriptUrl @@ -633,7 +673,9 @@ describe('standalone release packaging', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); - expect(workflow).toContain('npm run package:installation-assets --'); + expect(workflow).toContain( + 'npm run package:installation-assets -- --out-dir dist/standalone --version "${RELEASE_VERSION}"', + ); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); From 5983d116f3150d302185b355ac67661622313b02 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 5 May 2026 22:28:19 +0800 Subject: [PATCH 024/129] fix: parallelize Node.js binary downloads in standalone release build Use Promise.all instead of sequential for...of+await for the 5 independent Node.js runtime downloads, reducing CI release build time by ~4-5x. Co-authored-by: Qwen-Coder --- scripts/build-standalone-release.js | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index fb5ac9e0f21..4ab880ce076 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -81,17 +81,19 @@ async function main() { await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); - for (const target of RELEASE_TARGETS) { - await packageTarget({ - ...target, - nodeDistUrl, - nodeVersion, - outDir, - releaseVersion: args.version, - runtimeDir, - checksums, - }); - } + await Promise.all( + RELEASE_TARGETS.map((target) => + packageTarget({ + ...target, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion: args.version, + runtimeDir, + checksums, + }), + ), + ); await writeSha256Sums(outDir); assertStandaloneOutput(outDir); From 6c80ef8330927c6927cff5899d17cf5ef36f32bc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 23:08:09 +0800 Subject: [PATCH 025/129] fix(installer): address release asset review followups --- scripts/build-installation-assets.js | 49 ++-------- scripts/build-standalone-release.js | 56 ++++------- scripts/create-standalone-package.js | 52 ++++------ .../installation/install-qwen-with-source.bat | 78 ++++++++++++--- scripts/release-script-utils.js | 52 ++++++++++ scripts/tests/install-script.test.js | 98 ++++++++++++++++++- 6 files changed, 260 insertions(+), 125 deletions(-) create mode 100644 scripts/release-script-utils.js diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index 48c5aa84340..12d2552b770 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -7,18 +7,23 @@ */ import fs from 'node:fs'; -import crypto from 'node:crypto'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { writeSha256Sums } from './create-standalone-package.js'; import { INSTALLATION_ASSETS } from './release-asset-config.js'; +import { + fail, + isMainModule, + parseSha256Sums, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -37,10 +42,6 @@ if (isMainModule()) { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - async function buildInstallationAssets(outDir, options = {}) { const { assets = INSTALLATION_ASSETS, root = rootDir, version } = options; fs.mkdirSync(outDir, { recursive: true }); @@ -139,28 +140,6 @@ async function assertInstallationAssetChecksums( } } -function parseSha256Sums(content) { - const checksums = new Map(); - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (match) { - checksums.set(match[2], match[1].toLowerCase()); - } - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function parseArgs(argv) { const args = { help: false, @@ -192,14 +171,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(` Usage: @@ -212,10 +183,6 @@ Options: `); } -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { assertInstallationAssetChecksums, buildInstallationAssets, diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 4ab880ce076..01bc13f61f8 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -7,15 +7,21 @@ */ import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { writeSha256Sums } from './create-standalone-package.js'; +import { TARGETS, writeSha256Sums } from './create-standalone-package.js'; import { isStandaloneArchiveName } from './release-asset-config.js'; +import { + fail, + isMainModule, + parseSha256Sums, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -46,7 +52,7 @@ const RELEASE_TARGETS = [ ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { await main(); } catch (error) { @@ -102,10 +108,6 @@ async function main() { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - async function packageTarget({ qwenTarget, nodeTarget, @@ -161,14 +163,7 @@ async function downloadFile(url, destination) { } function parseChecksums(content) { - const checksums = new Map(); - for (const line of content.split(/\r?\n/)) { - const [hash, fileName] = line.trim().split(/\s+/, 2); - if (hash && fileName) { - checksums.set(fileName.replace(/^\*/, ''), hash); - } - } - return checksums; + return parseSha256Sums(content); } async function verifyNodeArchive(archivePath, archiveName, checksums) { @@ -185,12 +180,6 @@ async function verifyNodeArchive(archivePath, archiveName, checksums) { console.log(`Verified Node.js runtime checksum for ${archiveName}`); } -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function assertStandaloneOutput(outDir) { const checksumPath = path.join(outDir, 'SHA256SUMS'); if (!fs.existsSync(checksumPath)) { @@ -205,9 +194,8 @@ function assertStandaloneOutput(outDir) { .filter(Boolean) .filter(isStandaloneArchiveName) .sort(); - const expectedArchiveNames = RELEASE_TARGETS.map( - ({ qwenTarget }) => - `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + const expectedArchiveNames = RELEASE_TARGETS.map(({ qwenTarget }) => + standaloneArchiveName(qwenTarget), ).sort(); const missing = expectedArchiveNames.filter( (archiveName) => !archiveNames.includes(archiveName), @@ -236,6 +224,14 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } +function standaloneArchiveName(qwenTarget) { + const targetConfig = TARGETS.get(qwenTarget); + if (!targetConfig) { + fail(`No standalone package target config found for ${qwenTarget}`); + } + return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; +} + function parseArgs(argv) { const args = { help: false, @@ -276,14 +272,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(` Usage: @@ -297,8 +285,4 @@ Options: `); } -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index a5872990d7c..9d49f0e50bb 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -7,13 +7,17 @@ */ import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { isReleaseChecksumAsset } from './release-asset-config.js'; +import { + fail, + isMainModule, + readOptionValue, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -53,7 +57,7 @@ const DIST_ALLOWED_ENTRY_PATTERNS = [ ]; const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -if (isMainModule()) { +if (isMainModule(import.meta.url)) { try { await main(); } catch (error) { @@ -132,10 +136,6 @@ async function main() { } } -function isMainModule() { - return process.argv[1] && path.resolve(process.argv[1]) === __filename; -} - function parseArgs(argv) { const args = { help: false, @@ -180,14 +180,6 @@ function parseArgs(argv) { return args; } -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - function printUsage() { console.log(`Qwen Code standalone package builder @@ -552,6 +544,11 @@ function createZipArchive(outputPath, cwd) { run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); } +/** + * Rebuild SHA256SUMS from scratch by scanning outDir for all release checksum + * assets. This overwrites any existing SHA256SUMS, so callers must ensure all + * desired release assets are present in outDir before calling. + */ async function writeSha256Sums(outDir) { const entries = fs.readdirSync(outDir).filter(isReleaseChecksumAsset).sort(); @@ -561,22 +558,17 @@ async function writeSha256Sums(outDir) { ); } - const lines = []; - for (const entry of entries) { - const filePath = path.join(outDir, entry); - const hash = await sha256File(filePath); - lines.push(`${hash} ${entry}`); - } + const lines = await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(outDir, entry); + const hash = await sha256File(filePath); + return `${hash} ${entry}`; + }), + ); fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - function run(command, args, options = {}) { try { execFileSync(command, args, { @@ -592,8 +584,4 @@ function run(command, args, options = {}) { } } -function fail(message) { - throw new Error(`Error: ${message}`); -} - -export { writeSha256Sums }; +export { TARGETS, writeSha256Sums }; diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c8ce8cf067a..93568f603a6 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -481,8 +481,8 @@ if not "!ARCHIVE_PATH!"=="" ( ) ) - set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" - mkdir "!TEMP_DIR!" >nul 2>&1 + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" echo INFO: Downloading !ARCHIVE_URL! @@ -495,8 +495,8 @@ if not "!ARCHIVE_PATH!"=="" ( ) if "!TEMP_DIR!"=="" ( - set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" - mkdir "!TEMP_DIR!" >nul 2>&1 + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 ) REM Verify integrity before extraction or changing the install directory. @@ -509,6 +509,11 @@ if !ERRORLEVEL! NEQ 0 ( REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" @@ -564,8 +569,22 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 -if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 @@ -583,7 +602,7 @@ if exist "!INSTALL_DIR!" ( ) move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. exit /b 1 @@ -594,8 +613,8 @@ echo @echo off echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* ) > "!INSTALL_BIN_DIR!\qwen.cmd.new" if !ERRORLEVEL! NEQ 0 ( - if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. exit /b 1 @@ -603,14 +622,17 @@ if !ERRORLEVEL! NEQ 0 ( move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul if !ERRORLEVEL! NEQ 0 ( if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 - if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 - if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. exit /b 1 ) -if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) set "PATH=!INSTALL_BIN_DIR!;!PATH!" call :CreateSourceJson @@ -620,6 +642,38 @@ echo SUCCESS: Qwen Code standalone archive installed successfully. echo INFO: Installed to !INSTALL_DIR! exit /b 0 +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); try { foreach ($entry in $archive.Entries) { $name = $entry.FullName; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name.StartsWith('\') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)' -or $name.Contains('\')) { Write-Error ('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } finally { $archive.Dispose() }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains unsafe path entries. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js new file mode 100644 index 00000000000..656e50a03d8 --- /dev/null +++ b/scripts/release-script-utils.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (match) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { fail, isMainModule, parseSha256Sums, readOptionValue, sha256File }; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 98a96e4c0c5..9b76da50085 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -28,6 +28,9 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; +const standalonePackageScriptUrl = pathToFileURL( + path.resolve('scripts/create-standalone-package.js'), +).href; const installationAssetsScriptUrl = pathToFileURL( path.resolve('scripts/build-installation-assets.js'), ).href; @@ -167,6 +170,12 @@ describe('installation scripts', () => { expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain(':ValidateArchiveContents'); + expect(script).toContain('Archive contains unsafe path entries'); + expect(script).toContain('System.IO.Compression.FileSystem'); + expect(script).toContain('[IO.Compression.ZipFile]::OpenRead'); + expect(script).toContain('[IO.Path]::GetRandomFileName()'); + expect(script).not.toContain('qwen-code-install-%RANDOM%%RANDOM%'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); @@ -199,6 +208,8 @@ describe('installation scripts', () => { ); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('WARNING: Failed to restore previous install'); + expect(script).toContain('WARNING: Failed to remove failed install'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); }); @@ -221,6 +232,7 @@ describe('standalone release packaging', () => { expect(existsSync('scripts/build-standalone-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(true); expect(existsSync('scripts/release-asset-config.js')).toBe(true); + expect(existsSync('scripts/release-script-utils.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -235,12 +247,16 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('--skip-checksums'); expect(packageScript).toContain('dereference: true'); - expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); + expect(packageScript).toContain('Rebuild SHA256SUMS from scratch'); + expect(packageScript).toContain('Promise.all('); expect(packageScript).toContain( "import { isReleaseChecksumAsset } from './release-asset-config.js';", ); + expect(packageScript).toContain( + "import {\n fail,\n isMainModule,\n readOptionValue,\n sha256File,\n} from './release-script-utils.js';", + ); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); @@ -251,9 +267,9 @@ describe('standalone release packaging', () => { 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', ); expect(releaseScript).toContain('nodeArchiveExtension'); - expect(releaseScript).toContain('fs.createReadStream'); expect(releaseScript).toContain('expectedArchiveNames'); - expect(releaseScript).toContain('qwen-code-${qwenTarget}'); + expect(releaseScript).toContain('standaloneArchiveName(qwenTarget)'); + expect(releaseScript).toContain('TARGETS.get(qwenTarget)'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); @@ -269,6 +285,9 @@ describe('standalone release packaging', () => { expect(installationAssetsScript).toContain( 'assertInstallationAssetChecksums(outDir, assets)', ); + expect(installationAssetsScript).toContain( + "from './release-script-utils.js'", + ); const releaseAssetConfig = readScript('scripts/release-asset-config.js'); expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); @@ -279,6 +298,13 @@ describe('standalone release packaging', () => { expect(releaseAssetConfig).toContain('install-qwen.bat'); expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); expect(releaseAssetConfig).toContain('isReleaseChecksumAsset'); + + const releaseScriptUtils = readScript('scripts/release-script-utils.js'); + expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); + expect(releaseScriptUtils).toContain('function parseSha256Sums'); + expect(releaseScriptUtils).toContain('async function sha256File'); + expect(releaseScriptUtils).toContain('function readOptionValue'); + expect(releaseScriptUtils).toContain('function isMainModule'); }); it('loads the standalone release packaging helper', () => { @@ -363,11 +389,12 @@ describe('standalone release packaging', () => { const { assertStandaloneOutput, RELEASE_TARGETS } = await import( standaloneReleaseScriptUrl ); + const { TARGETS } = await import(standalonePackageScriptUrl); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); try { const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { - const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + const extension = TARGETS.get(qwenTarget).outputExtension; return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); writeFileSync( @@ -1092,6 +1119,28 @@ describe('Windows installer end-to-end', () => { } }); + itOnWindows( + 'rejects standalone archives containing path traversal entries', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createWindowsTraversalStandaloneArchive(tmpDir); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains unsafe path/); + expect(existsSync(path.join(tmpDir, 'qwen-slip'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + itOnWindows('rejects unsafe environment-derived install paths', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1237,6 +1286,47 @@ function createFakeWindowsStandaloneArchive(tmpDir) { return archive; } +function createWindowsTraversalStandaloneArchive(tmpDir) { + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + [ + "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + 'function Add-ZipEntry($zip, $name, $content) {', + ' $entry = $zip.CreateEntry($name)', + ' $writer = [IO.StreamWriter]::new($entry.Open())', + ' try { $writer.Write($content) } finally { $writer.Dispose() }', + '}', + '$zip = [IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.ZipArchiveMode]::Create)', + 'try {', + " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", + " Add-ZipEntry $zip 'qwen-code/bin/qwen.cmd' '@echo off`r`necho 0.0.0-smoke`r`n'", + " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", + ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code"}\'', + '} finally { $zip.Dispose() }', + ].join('; '), + ], + { + env: { + ...process.env, + QWEN_TEST_ZIP_ARCHIVE: archive, + }, + stdio: 'ignore', + }, + ); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( From bca4a7f0eaa7b901b6c85681f0f9c213ab7235f5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 5 May 2026 23:12:20 +0800 Subject: [PATCH 026/129] refactor(installer): share release CLI parsing --- scripts/build-installation-assets.js | 46 ++++++-------------- scripts/build-standalone-release.js | 58 +++++++------------------ scripts/create-standalone-package.js | 64 ++++++++-------------------- scripts/release-script-utils.js | 35 ++++++++++++++- scripts/tests/install-script.test.js | 44 ++++++++++++++++++- 5 files changed, 124 insertions(+), 123 deletions(-) diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index 12d2552b770..bd651fd8aff 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -14,8 +14,8 @@ import { INSTALLATION_ASSETS } from './release-asset-config.js'; import { fail, isMainModule, + parseCliArgs, parseSha256Sums, - readOptionValue, sha256File, } from './release-script-utils.js'; @@ -23,9 +23,20 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); +const CLI_OPTIONS = { + '--help': { name: 'help', type: 'boolean' }, + '-h': { name: 'help', type: 'boolean' }, + '--out-dir': { name: 'outDir' }, + '--version': { name: 'version', validate: validateReleaseVersion }, +}; + if (isMainModule(import.meta.url)) { try { - const args = parseArgs(process.argv.slice(2)); + const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { + help: false, + outDir: undefined, + version: undefined, + }); if (args.help) { printUsage(); } else { @@ -140,37 +151,6 @@ async function assertInstallationAssetChecksums( } } -function parseArgs(argv) { - const args = { - help: false, - outDir: undefined, - version: undefined, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case '--help': - case '-h': - args.help = true; - break; - case '--out-dir': - args.outDir = readOptionValue(argv, index, arg); - index += 1; - break; - case '--version': - args.version = readOptionValue(argv, index, arg); - validateReleaseVersion(args.version); - index += 1; - break; - default: - fail(`Unknown option: ${arg}`); - } - } - - return args; -} - function printUsage() { console.log(` Usage: diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 01bc13f61f8..8cbc54a2379 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -18,8 +18,8 @@ import { isStandaloneArchiveName } from './release-asset-config.js'; import { fail, isMainModule, + parseCliArgs, parseSha256Sums, - readOptionValue, sha256File, } from './release-script-utils.js'; @@ -51,6 +51,14 @@ const RELEASE_TARGETS = [ { qwenTarget: 'win-x64', nodeTarget: 'win-x64', nodeArchiveExtension: 'zip' }, ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; +const CLI_OPTIONS = { + '--help': { name: 'help', type: 'boolean' }, + '-h': { name: 'help', type: 'boolean' }, + '--node-version': { name: 'nodeVersion' }, + '--out-dir': { name: 'outDir' }, + '--runtime-dir': { name: 'runtimeDir' }, + '--version': { name: 'version' }, +}; if (isMainModule(import.meta.url)) { try { @@ -62,7 +70,13 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseArgs(process.argv.slice(2)); + const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { + help: false, + nodeVersion: undefined, + outDir: undefined, + runtimeDir: undefined, + version: undefined, + }); if (args.help) { printUsage(); return; @@ -232,46 +246,6 @@ function standaloneArchiveName(qwenTarget) { return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; } -function parseArgs(argv) { - const args = { - help: false, - nodeVersion: undefined, - outDir: undefined, - runtimeDir: undefined, - version: undefined, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case '--help': - case '-h': - args.help = true; - break; - case '--node-version': - args.nodeVersion = readOptionValue(argv, index, arg); - index += 1; - break; - case '--out-dir': - args.outDir = readOptionValue(argv, index, arg); - index += 1; - break; - case '--runtime-dir': - args.runtimeDir = readOptionValue(argv, index, arg); - index += 1; - break; - case '--version': - args.version = readOptionValue(argv, index, arg); - index += 1; - break; - default: - fail(`Unknown option: ${arg}`); - } - } - - return args; -} - function printUsage() { console.log(` Usage: diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 9d49f0e50bb..cf1a8ec9e72 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -15,7 +15,7 @@ import { isReleaseChecksumAsset } from './release-asset-config.js'; import { fail, isMainModule, - readOptionValue, + parseCliArgs, sha256File, } from './release-script-utils.js'; @@ -56,6 +56,15 @@ const DIST_ALLOWED_ENTRY_PATTERNS = [ /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, ]; const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; +const CLI_OPTIONS = { + '--help': { name: 'help', type: 'boolean' }, + '-h': { name: 'help', type: 'boolean' }, + '--target': { name: 'target' }, + '--node-archive': { name: 'nodeArchive' }, + '--out-dir': { name: 'outDir' }, + '--version': { name: 'version' }, + '--skip-checksums': { name: 'skipChecksums', type: 'boolean' }, +}; if (isMainModule(import.meta.url)) { try { @@ -67,7 +76,14 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseArgs(process.argv.slice(2)); + const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { + help: false, + nodeArchive: undefined, + outDir: undefined, + skipChecksums: false, + target: undefined, + version: undefined, + }); if (args.help) { printUsage(); @@ -136,50 +152,6 @@ async function main() { } } -function parseArgs(argv) { - const args = { - help: false, - outDir: undefined, - nodeArchive: undefined, - skipChecksums: false, - target: undefined, - version: undefined, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case '--help': - case '-h': - args.help = true; - break; - case '--target': - args.target = readOptionValue(argv, index, arg); - index += 1; - break; - case '--node-archive': - args.nodeArchive = readOptionValue(argv, index, arg); - index += 1; - break; - case '--out-dir': - args.outDir = readOptionValue(argv, index, arg); - index += 1; - break; - case '--version': - args.version = readOptionValue(argv, index, arg); - index += 1; - break; - case '--skip-checksums': - args.skipChecksums = true; - break; - default: - fail(`Unknown option: ${arg}`); - } - } - - return args; -} - function printUsage() { console.log(`Qwen Code standalone package builder diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index 656e50a03d8..aaffc097062 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -23,6 +23,32 @@ function readOptionValue(argv, index, optionName) { return value; } +function parseCliArgs(argv, options, defaults = {}) { + const args = { ...defaults }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const option = options[arg]; + if (!option) { + fail(`Unknown option: ${arg}`); + } + + if (option.type === 'boolean') { + args[option.name] = true; + continue; + } + + const value = readOptionValue(argv, index, arg); + if (option.validate) { + option.validate(value); + } + args[option.name] = value; + index += 1; + } + + return args; +} + function parseSha256Sums(content) { const checksums = new Map(); for (const line of content.split(/\r?\n/)) { @@ -49,4 +75,11 @@ function fail(message) { throw new Error(`ERROR: ${message}`); } -export { fail, isMainModule, parseSha256Sums, readOptionValue, sha256File }; +export { + fail, + isMainModule, + parseCliArgs, + parseSha256Sums, + readOptionValue, + sha256File, +}; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9b76da50085..01e4e3f83ee 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -37,6 +37,9 @@ const installationAssetsScriptUrl = pathToFileURL( const releaseAssetConfigUrl = pathToFileURL( path.resolve('scripts/release-asset-config.js'), ).href; +const releaseScriptUtilsUrl = pathToFileURL( + path.resolve('scripts/release-script-utils.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; @@ -255,8 +258,12 @@ describe('standalone release packaging', () => { "import { isReleaseChecksumAsset } from './release-asset-config.js';", ); expect(packageScript).toContain( - "import {\n fail,\n isMainModule,\n readOptionValue,\n sha256File,\n} from './release-script-utils.js';", + "import {\n fail,\n isMainModule,\n parseCliArgs,\n sha256File,\n} from './release-script-utils.js';", + ); + expect(packageScript).toContain( + 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', ); + expect(packageScript).not.toContain('function parseArgs'); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); @@ -276,6 +283,10 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain( "import { isStandaloneArchiveName } from './release-asset-config.js';", ); + expect(releaseScript).toContain( + 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', + ); + expect(releaseScript).not.toContain('function parseArgs'); const installationAssetsScript = readScript( 'scripts/build-installation-assets.js', @@ -288,6 +299,10 @@ describe('standalone release packaging', () => { expect(installationAssetsScript).toContain( "from './release-script-utils.js'", ); + expect(installationAssetsScript).toContain( + 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', + ); + expect(installationAssetsScript).not.toContain('function parseArgs'); const releaseAssetConfig = readScript('scripts/release-asset-config.js'); expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); @@ -301,12 +316,39 @@ describe('standalone release packaging', () => { const releaseScriptUtils = readScript('scripts/release-script-utils.js'); expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); + expect(releaseScriptUtils).toContain('function parseCliArgs'); expect(releaseScriptUtils).toContain('function parseSha256Sums'); expect(releaseScriptUtils).toContain('async function sha256File'); expect(releaseScriptUtils).toContain('function readOptionValue'); expect(releaseScriptUtils).toContain('function isMainModule'); }); + it('parses release script CLI options through the shared helper', async () => { + const { parseCliArgs } = await import(releaseScriptUtilsUrl); + + const args = parseCliArgs( + ['--name', 'qwen', '--flag', '-h'], + { + '--name': { name: 'name' }, + '--flag': { name: 'flag', type: 'boolean' }, + '-h': { name: 'help', type: 'boolean' }, + }, + { flag: false, help: false, name: undefined }, + ); + + expect(args).toEqual({ + flag: true, + help: true, + name: 'qwen', + }); + expect(() => parseCliArgs(['--unknown'], {}, {})).toThrow( + /Unknown option: --unknown/, + ); + expect(() => + parseCliArgs(['--name'], { '--name': { name: 'name' } }, {}), + ).toThrow(/--name requires a value/); + }); + it('loads the standalone release packaging helper', () => { const output = execFileSync( process.execPath, From 3570f93af9f9c1e3cdbe58528db0972984c1de8a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 6 May 2026 21:19:19 +0800 Subject: [PATCH 027/129] fix(installer): address release asset review followups - sh: reject CR/LF in archive entry names before the literal `..` glob so a `..\r` entry cannot bypass path validation. - bat: prefer Tls12+Tls13 in PowerShell helpers, fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. - bat: document the implicit `:ValidateOptions` dependency next to the qwen.cmd wrapper writer so loosening the validator stays a conscious choice. - build-standalone-release: surface the `xz-utils` host requirement for Linux Node downloads in `--help`. - release-script-utils: support `--key=value` form in `parseCliArgs`. - tests: cover the new CRLF message, TLS string, and `--key=value` parsing; register process-level signal/exit handlers in `ensureMinimalDist` so a crashed test still restores `dist/`. --- scripts/build-standalone-release.js | 4 + .../installation/install-qwen-with-source.bat | 10 ++- .../installation/install-qwen-with-source.sh | 9 +++ scripts/release-script-utils.js | 28 ++++++- scripts/tests/install-script.test.js | 75 ++++++++++++++++++- 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 8cbc54a2379..bc34de2c56d 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -256,6 +256,10 @@ Options: --out-dir PATH Output directory. Defaults to dist/standalone. --runtime-dir PATH Temporary Node.js runtime download directory. --node-version VERSION Node.js version to download. Defaults to current Node. + +Host requirements: + Linux Node.js runtimes are downloaded as tar.xz archives, so the host + needs xz support (Ubuntu/Debian: xz-utils; Alpine: xz; macOS/Windows: built-in). `); } diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 93568f603a6..c494a10292c 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -359,7 +359,8 @@ exit /b 0 :UrlExists set "QWEN_CHECK_URL=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 set "PS_STATUS=%ERRORLEVEL%" set "QWEN_CHECK_URL=" exit /b %PS_STATUS% @@ -367,7 +368,8 @@ exit /b %PS_STATUS% :DownloadFile set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -608,6 +610,10 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) +rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated +rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels +rem (`!`) and other shell-metacharacters in those values; if that validator is ever +rem loosened, the wrapper write below becomes a command injection sink. ( echo @echo off echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1d8c5d7d753..b64a9d0d674 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -616,6 +616,15 @@ validate_archive_entry_path() { entry="${entry#./}" done + # Reject entries containing CR/LF so a `..\r` or `..\n` entry cannot + # bypass the literal `..` glob below. + case "${entry}" in + *$'\r'*|*$'\n'*) + log_error "Archive contains unsafe path with control character: ${entry}" + return 1 + ;; + esac + case "${entry}" in ""|/*|..|../*|*/..|*/../*|*\\*) log_error "Archive contains unsafe path: ${entry:-}" diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index aaffc097062..4aca1312606 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -28,22 +28,44 @@ function parseCliArgs(argv, options, defaults = {}) { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - const option = options[arg]; + + let key = arg; + let inlineValue; + if (arg.startsWith('--')) { + const equalsIndex = arg.indexOf('='); + if (equalsIndex > -1) { + key = arg.slice(0, equalsIndex); + inlineValue = arg.slice(equalsIndex + 1); + } + } + + const option = options[key]; if (!option) { fail(`Unknown option: ${arg}`); } if (option.type === 'boolean') { + if (inlineValue !== undefined) { + fail(`${key} does not accept a value`); + } args[option.name] = true; continue; } - const value = readOptionValue(argv, index, arg); + let value; + if (inlineValue !== undefined) { + if (inlineValue === '') { + fail(`${key} requires a value`); + } + value = inlineValue; + } else { + value = readOptionValue(argv, index, key); + index += 1; + } if (option.validate) { option.validate(value); } args[option.name] = value; - index += 1; } return args; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 01e4e3f83ee..775d7eef0b6 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -93,6 +93,9 @@ describe('installation scripts', () => { ); expect(script).toContain('validate_archive_contents()'); expect(script).toContain('Archive contains unsafe path'); + expect(script).toContain( + 'Archive contains unsafe path with control character', + ); expect(script).toContain('qwen-code-${target}'); expect(script).toContain('*.tar.xz)'); expect(script).toContain('METHOD="${METHOD:-detect}"'); @@ -200,7 +203,10 @@ describe('installation scripts', () => { ); expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); expect(script).toContain( - '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', + ); + expect(script).toContain( + '$request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', ); expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); @@ -347,6 +353,28 @@ describe('standalone release packaging', () => { expect(() => parseCliArgs(['--name'], { '--name': { name: 'name' } }, {}), ).toThrow(/--name requires a value/); + + const equalsArgs = parseCliArgs( + ['--name=qwen', '--flag'], + { + '--name': { name: 'name' }, + '--flag': { name: 'flag', type: 'boolean' }, + }, + { flag: false, name: undefined }, + ); + expect(equalsArgs).toEqual({ flag: true, name: 'qwen' }); + + expect(() => + parseCliArgs( + ['--flag=true'], + { '--flag': { name: 'flag', type: 'boolean' } }, + {}, + ), + ).toThrow(/--flag does not accept a value/); + + expect(() => + parseCliArgs(['--name='], { '--name': { name: 'name' } }, {}), + ).toThrow(/--name requires a value/); }); it('loads the standalone release packaging helper', () => { @@ -1208,7 +1236,41 @@ describe('Windows installer end-to-end', () => { }); }); +// Tracks pending dist/ backups so a crashed test cannot leave the working tree +// without dist/. process.on('exit') runs synchronous handlers, which is enough +// for renameSync; SIGINT/SIGTERM force re-entry through 'exit'. +const pendingDistBackups = new Set(); +let distBackupHandlersRegistered = false; + +function registerDistBackupSafetyNet() { + if (distBackupHandlersRegistered) { + return; + } + distBackupHandlersRegistered = true; + + const drain = () => { + for (const restore of pendingDistBackups) { + try { + restore(); + } catch { + // best-effort restore; nothing we can do at exit + } + } + pendingDistBackups.clear(); + }; + + process.on('exit', drain); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { + drain(); + process.exit(1); + }); + } +} + function ensureMinimalDist() { + registerDistBackupSafetyNet(); + const distPath = path.resolve('dist'); const backupRoot = mkdtempSync(path.join(tmpdir(), 'qwen-dist-backup-')); const backupDist = path.join(backupRoot, 'dist'); @@ -1226,13 +1288,22 @@ function ensureMinimalDist() { JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - return () => { + let restored = false; + const restore = () => { + if (restored) { + return; + } + restored = true; + pendingDistBackups.delete(restore); rmSync(distPath, { recursive: true, force: true }); if (hadExistingDist) { renameSync(backupDist, distPath); } rmSync(backupRoot, { recursive: true, force: true }); }; + + pendingDistBackups.add(restore); + return restore; } function createFakeNodeArchive(tmpDir, options = {}) { From 51c14f419f1ff0485cbd09d21cb8932a9965cd5f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 11:54:30 +0800 Subject: [PATCH 028/129] fix(installer): unblock Windows CI for standalone install path Three CI failures and a few review followups in one pass. - ensureMinimalDist places its dist/ backup beside dist/ instead of under os.tmpdir(). On Windows GitHub runners the workspace lives on D: while os.tmpdir() is on C:, so renameSync raised EXDEV for every test that needed to swap dist/ in. - create-standalone-package.js and the matching test fixture build win-x64 zips with [IO.Compression.ZipFile]::CreateFromDirectory. Compress-Archive emits backslash entry names that the .bat installer's path-traversal guard then rejected, so every freshly built archive failed the standalone install path on Windows. - :ValidateArchiveContents normalizes entry separators to '/' before checking for '..', absolute paths, and drive prefixes - archives from any Windows zip tool still install while real traversal entries remain rejected. - createWindowsTraversalStandaloneArchive runs PowerShell via -File instead of a single -Command line; the joined-with-'; ' form had a function definition the runner's PowerShell refused to parse. Drive-by review followups: - replaceRequired uses replaceAll so a future duplicate placeholder cannot silently keep the trailing copy as 'latest'. - :ValidateOptions runs the unsafe-character check on SOURCE alongside the other variables. - build-installation-assets.js drops a dead INSTALLATION_ASSETS re-export; consumers already import from release-asset-config.js. - .gitignore covers the new sibling .qwen-dist-backup-* directory. --- .gitignore | 3 + scripts/build-installation-assets.js | 10 ++- scripts/create-standalone-package.js | 7 +- .../installation/install-qwen-with-source.bat | 12 +++- scripts/tests/install-script.test.js | 68 ++++++++++++------- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index f6ea62c7f8d..d6769a5f937 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ Thumbs.db # Ignore built ts files dist +# Backup of dist/ created by script tests (see ensureMinimalDist) +.qwen-dist-backup-* + # Docker folder to help skip auth refreshes .docker diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index bd651fd8aff..cceb69f643c 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -109,7 +109,9 @@ function replaceRequired(contents, search, replacement, output) { if (!contents.includes(search)) { fail(`Unable to stamp release version in ${output}`); } - return contents.replace(search, replacement); + // Use replaceAll so a future installer that adds a duplicate placeholder + // doesn't silently leave the trailing occurrence as `latest`. + return contents.replaceAll(search, replacement); } function stampVersionHelpText(contents, output, version) { @@ -163,8 +165,4 @@ Options: `); } -export { - assertInstallationAssetChecksums, - buildInstallationAssets, - INSTALLATION_ASSETS, -}; +export { assertInstallationAssetChecksums, buildInstallationAssets }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index cf1a8ec9e72..31006ac58f8 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -493,6 +493,11 @@ function createArchive(outputExtension, outputPath, cwd) { function createZipArchive(outputPath, cwd) { if (process.platform === 'win32') { + // Use [IO.Compression.ZipFile]::CreateFromDirectory rather than + // Compress-Archive: the latter writes Windows-style backslash + // separators into ZIP entry names, which then trip the .bat + // installer's path-traversal guard against backslashes. + // CreateFromDirectory writes spec-compliant forward slashes. run( 'powershell', [ @@ -500,7 +505,7 @@ function createZipArchive(outputPath, cwd) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force', + 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_OUTPUT_PATH) { Remove-Item -LiteralPath $env:QWEN_OUTPUT_PATH -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_PACKAGE_ROOT, $env:QWEN_OUTPUT_PATH, [IO.Compression.CompressionLevel]::Optimal, $true)', ], { env: { diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c494a10292c..2b94b14f2c7 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -221,7 +221,8 @@ set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_VALIDATE_METHOD=" set "QWEN_VALIDATE_MIRROR=" @@ -232,6 +233,7 @@ set "QWEN_VALIDATE_NPM_REGISTRY=" set "QWEN_VALIDATE_INSTALL_BASE=" set "QWEN_VALIDATE_INSTALL_DIR=" set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" if %PS_STATUS% NEQ 0 ( echo ERROR: installer options contain unsafe command characters. exit /b 1 @@ -659,7 +661,13 @@ exit /b 0 :ValidateArchiveContents set "QWEN_ARCHIVE_FILE=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); try { foreach ($entry in $archive.Entries) { $name = $entry.FullName; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name.StartsWith('\') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)' -or $name.Contains('\')) { Write-Error ('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } finally { $archive.Dispose() }" +REM Normalize backslashes to forward slashes before checking. Some Windows +REM zip producers (including PowerShell's Compress-Archive) emit entries +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'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); try { foreach ($entry in $archive.Entries) { $name = $entry.FullName -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { Write-Error ('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } finally { $archive.Dispose() }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_ARCHIVE_FILE=" if %PS_STATUS% NEQ 0 echo ERROR: Archive contains unsafe path entries. diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 775d7eef0b6..7bc1086aeed 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1272,7 +1272,13 @@ function ensureMinimalDist() { registerDistBackupSafetyNet(); const distPath = path.resolve('dist'); - const backupRoot = mkdtempSync(path.join(tmpdir(), 'qwen-dist-backup-')); + // Backup root must live on the same volume as dist/ so that renameSync + // is atomic. On Windows GitHub runners the workspace lives on D: while + // os.tmpdir() returns a path on C:; renaming across drives raises + // EXDEV. Keeping the backup as a sibling of dist/ avoids that. + const backupRoot = mkdtempSync( + path.join(path.dirname(distPath), '.qwen-dist-backup-'), + ); const backupDist = path.join(backupRoot, 'dist'); const hadExistingDist = existsSync(distPath); @@ -1404,30 +1410,39 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { mkdirSync(outDir, { recursive: true }); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + // PowerShell's `-Command` parser is fragile for multi-line scripts that + // include function definitions and quoted entry names. Joining with + // `; ` produces lines like `function f() {; ...; }; }` that older + // PowerShell versions reject. Write the script to a .ps1 file and run + // `-File` instead, which uses the same parser as a real script. + const scriptPath = path.join(tmpDir, 'create-traversal-archive.ps1'); + writeFileSync( + scriptPath, + [ + "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + 'function Add-ZipEntry($zip, $name, $content) {', + ' $entry = $zip.CreateEntry($name)', + ' $writer = [System.IO.StreamWriter]::new($entry.Open())', + ' try { $writer.Write($content) } finally { $writer.Dispose() }', + '}', + '$zip = [System.IO.Compression.ZipFile]::Open(', + ' $env:QWEN_TEST_ZIP_ARCHIVE,', + ' [System.IO.Compression.ZipArchiveMode]::Create', + ')', + 'try {', + " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", + ' Add-ZipEntry $zip \'qwen-code/bin/qwen.cmd\' "@echo off`r`necho 0.0.0-smoke`r`n"', + " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", + ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code"}\'', + '} finally { $zip.Dispose() }', + '', + ].join('\r\n'), + ); + execFileSync( 'powershell', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - [ - "$ErrorActionPreference = 'Stop'", - 'Add-Type -AssemblyName System.IO.Compression.FileSystem', - 'function Add-ZipEntry($zip, $name, $content) {', - ' $entry = $zip.CreateEntry($name)', - ' $writer = [IO.StreamWriter]::new($entry.Open())', - ' try { $writer.Write($content) } finally { $writer.Dispose() }', - '}', - '$zip = [IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.ZipArchiveMode]::Create)', - 'try {', - " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", - " Add-ZipEntry $zip 'qwen-code/bin/qwen.cmd' '@echo off`r`necho 0.0.0-smoke`r`n'", - " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", - ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code"}\'', - '} finally { $zip.Dispose() }', - ].join('; '), - ], + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { env: { ...process.env, @@ -1442,6 +1457,11 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { + // Mirror create-standalone-package.js: use CreateFromDirectory so + // entry names use forward slashes and match what the production + // builder ships. Compress-Archive would write backslashes, which + // the .bat installer's ValidateArchiveContents normalizes but the + // production archive shouldn't depend on that leniency. execFileSync( 'powershell', [ @@ -1449,7 +1469,7 @@ function createZipForTest(archive, cwd, entry) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force', + 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_TEST_ZIP_ENTRY, $env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.CompressionLevel]::Optimal, $true)', ], { env: { From 44ac2297cf226186505e719e6b529c08c18cc321 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 15:57:54 +0800 Subject: [PATCH 029/129] fix(installer): address release asset review findings --- package-lock.json | 1 + scripts/build-installation-assets.js | 23 +++++++- scripts/build-standalone-release.js | 31 +++++++++-- scripts/create-standalone-package.js | 3 +- .../installation/install-qwen-with-source.bat | 2 +- scripts/tests/install-script.test.js | 53 +++++++++++++++++++ 6 files changed, 104 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea5441b8051..b01f2c9628d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12842,6 +12842,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js index cceb69f643c..de71e3ca37e 100644 --- a/scripts/build-installation-assets.js +++ b/scripts/build-installation-assets.js @@ -140,15 +140,34 @@ async function assertInstallationAssetChecksums( } const checksums = parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); + if (checksums.size === 0) { + fail(`SHA256SUMS did not contain any checksum entries.`); + } + for (const { output } of assets) { const expected = checksums.get(output); if (!expected) { fail(`Checksum entry for ${output} not found.`); } + } + + for (const [entry, expected] of checksums) { + const filePath = path.join(outDir, entry); + const relativePath = path.relative(outDir, filePath); + if ( + relativePath === '' || + relativePath.startsWith('..') || + path.isAbsolute(relativePath) + ) { + fail(`Invalid checksum entry path: ${entry}.`); + } + if (!fs.existsSync(filePath)) { + fail(`Checksum target for ${entry} not found.`); + } - const actual = await sha256File(path.join(outDir, output)); + const actual = await sha256File(filePath); if (actual !== expected) { - fail(`Checksum verification failed for ${output}.`); + fail(`Checksum verification failed for ${entry}.`); } } } diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index bc34de2c56d..7cfe52f8c97 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -101,9 +101,9 @@ async function main() { await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); - await Promise.all( - RELEASE_TARGETS.map((target) => - packageTarget({ + const targetResults = await Promise.allSettled( + RELEASE_TARGETS.map(async (target) => { + await packageTarget({ ...target, nodeDistUrl, nodeVersion, @@ -111,9 +111,23 @@ async function main() { releaseVersion: args.version, runtimeDir, checksums, - }), - ), + }); + return target.qwenTarget; + }), ); + const failures = targetResults.flatMap((result, index) => + result.status === 'rejected' + ? [ + `${RELEASE_TARGETS[index].qwenTarget}: ${formatErrorReason( + result.reason, + )}`, + ] + : [], + ); + + if (failures.length > 0) { + fail(`Failed to package standalone target(s): ${failures.join('; ')}`); + } await writeSha256Sums(outDir); assertStandaloneOutput(outDir); @@ -159,6 +173,13 @@ async function packageTarget({ }); } +function formatErrorReason(reason) { + if (reason instanceof Error) { + return reason.message; + } + return String(reason); +} + async function downloadFile(url, destination) { console.log(`Downloading ${url}`); const response = await fetch(url); diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 31006ac58f8..1e3bf404d05 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -55,6 +55,7 @@ const DIST_ALLOWED_ENTRIES = new Set([ const DIST_ALLOWED_ENTRY_PATTERNS = [ /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, ]; +const DIST_IGNORED_ENTRIES = new Set(['.DS_Store', 'esbuild.json']); const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; const CLI_OPTIONS = { '--help': { name: 'help', type: 'boolean' }, @@ -199,7 +200,7 @@ function copyRuntimeAssets(packageRoot, outDir) { fs.mkdirSync(libDir, { recursive: true }); for (const entry of fs.readdirSync(distDir)) { - if (entry === skippedDistEntry || entry === '.DS_Store') { + if (entry === skippedDistEntry || DIST_IGNORED_ENTRIES.has(entry)) { continue; } if (!isAllowedDistEntry(entry)) { diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 2b94b14f2c7..50c1740dfcc 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -115,7 +115,7 @@ if /i "%~1"=="--registry" ( if /i "%~1"=="-h" goto usage if /i "%~1"=="--help" goto usage -echo ERROR: Unknown option: %~1 +echo ERROR: Unknown option. echo. goto usage_error diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 7bc1086aeed..d088795540d 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -215,6 +215,8 @@ describe('installation scripts', () => { expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); + expect(script).toContain('ERROR: Unknown option.'); + expect(script).not.toContain('ERROR: Unknown option: %~1'); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('WARNING: Failed to restore previous install'); @@ -286,6 +288,7 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); + expect(releaseScript).toContain('Promise.allSettled('); expect(releaseScript).toContain( "import { isStandaloneArchiveName } from './release-asset-config.js';", ); @@ -519,6 +522,18 @@ describe('standalone release packaging', () => { await expect(assertInstallationAssetChecksums(tmpDir)).rejects.toThrow( /Checksum verification failed for install-qwen\.sh/, ); + + writeFileSync( + installSh, + readScript('scripts/installation/install-qwen-with-source.sh'), + ); + writeFileSync( + path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'), + 'tampered', + ); + await expect(assertInstallationAssetChecksums(tmpDir)).rejects.toThrow( + /Checksum verification failed for qwen-code-linux-x64\.tar\.gz/, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -766,6 +781,44 @@ describe('standalone release packaging', () => { } }); + it('ignores non-runtime esbuild metadata in dist', () => { + const restoreDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + + try { + const outDir = path.join(tmpDir, 'out'); + writeFileSync('dist/esbuild.json', '{}\n'); + + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + 'win-x64', + '--node-archive', + createFakeWindowsNodeArchive(tmpDir), + '--out-dir', + outDir, + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ); + + const archive = path.join(outDir, 'qwen-code-win-x64.zip'); + const extractDir = path.join(tmpDir, 'extract'); + mkdirSync(extractDir, { recursive: true }); + extractZipForTest(archive, extractDir); + + expect( + existsSync(path.join(extractDir, 'qwen-code', 'lib', 'esbuild.json')), + ).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreDist(); + } + }, 30_000); + it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); From f9d9a3b6be071c04001a52d999fe8371b2a565c8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 16:33:32 +0800 Subject: [PATCH 030/129] fix(installer): keep installer entrypoint hosted --- .github/workflows/release.yml | 9 - package.json | 1 - scripts/build-installation-assets.js | 187 --------------- scripts/create-standalone-package.js | 10 +- scripts/installation/INSTALLATION_GUIDE.md | 30 +-- scripts/release-asset-config.js | 33 +-- scripts/tests/install-script.test.js | 252 ++++----------------- 7 files changed, 73 insertions(+), 449 deletions(-) delete mode 100644 scripts/build-installation-assets.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67117f9d5b0..28f3559bd52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,13 +384,6 @@ jobs: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' - # Rewrites SHA256SUMS after copying installer scripts so the release - # checksum file covers both standalone archives and installer assets. - - name: 'Build Installation Assets' - env: - RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' - run: 'npm run package:installation-assets -- --out-dir dist/standalone --version "${RELEASE_VERSION}"' - - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -424,8 +417,6 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ dist/standalone/qwen-code-* \ - dist/standalone/install-qwen.sh \ - dist/standalone/install-qwen.bat \ dist/standalone/SHA256SUMS \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ diff --git a/package.json b/package.json index 1df6e57beb9..00fd82774b2 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", - "package:installation-assets": "node scripts/build-installation-assets.js", "package:standalone": "node scripts/create-standalone-package.js", "package:standalone:release": "node scripts/build-standalone-release.js", "release:version": "node scripts/version.js", diff --git a/scripts/build-installation-assets.js b/scripts/build-installation-assets.js deleted file mode 100644 index de71e3ca37e..00000000000 --- a/scripts/build-installation-assets.js +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env node - -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { writeSha256Sums } from './create-standalone-package.js'; -import { INSTALLATION_ASSETS } from './release-asset-config.js'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const rootDir = path.resolve(__dirname, '..'); - -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--out-dir': { name: 'outDir' }, - '--version': { name: 'version', validate: validateReleaseVersion }, -}; - -if (isMainModule(import.meta.url)) { - try { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - outDir: undefined, - version: undefined, - }); - if (args.help) { - printUsage(); - } else { - await buildInstallationAssets( - path.resolve(args.outDir || path.join(rootDir, 'dist', 'standalone')), - { - version: args.version, - }, - ); - } - } catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - } -} - -async function buildInstallationAssets(outDir, options = {}) { - const { assets = INSTALLATION_ASSETS, root = rootDir, version } = options; - fs.mkdirSync(outDir, { recursive: true }); - - for (const asset of assets) { - const source = path.join(root, ...asset.sourcePath); - if (!fs.existsSync(source)) { - fail(`Installation source asset not found: ${source}`); - } - - const destination = path.join(outDir, asset.output); - const contents = fs.readFileSync(source, 'utf8'); - fs.writeFileSync( - destination, - version ? stampInstallerVersion(contents, asset, version) : contents, - ); - if (asset.mode !== undefined && process.platform !== 'win32') { - fs.chmodSync(destination, asset.mode); - } - } - - await writeSha256Sums(outDir); - await assertInstallationAssetChecksums(outDir, assets); -} - -function stampInstallerVersion(contents, asset, version) { - validateReleaseVersion(version); - - const sourceName = asset.sourcePath.at(-1); - if (sourceName.endsWith('.sh')) { - const stampedDefault = replaceRequired( - contents, - 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', - `VERSION="\${QWEN_INSTALL_VERSION:-${version}}"`, - asset.output, - ); - return stampVersionHelpText(stampedDefault, asset.output, version); - } - - if (sourceName.endsWith('.bat')) { - const stampedDefault = replaceRequired( - contents, - 'set "VERSION=latest"', - `set "VERSION=${version}"`, - asset.output, - ); - return stampVersionHelpText(stampedDefault, asset.output, version); - } - - return contents; -} - -function replaceRequired(contents, search, replacement, output) { - if (!contents.includes(search)) { - fail(`Unable to stamp release version in ${output}`); - } - // Use replaceAll so a future installer that adds a duplicate placeholder - // doesn't silently leave the trailing occurrence as `latest`. - return contents.replaceAll(search, replacement); -} - -function stampVersionHelpText(contents, output, version) { - return replaceRequired( - contents, - 'Standalone release version. Defaults to latest.', - `Standalone release version. Defaults to ${version}.`, - output, - ); -} - -function validateReleaseVersion(version) { - if (/^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$/.test(version)) { - return; - } - fail('--version must be a semver string'); -} - -async function assertInstallationAssetChecksums( - outDir, - assets = INSTALLATION_ASSETS, -) { - const checksumPath = path.join(outDir, 'SHA256SUMS'); - if (!fs.existsSync(checksumPath)) { - fail(`SHA256SUMS was not created at ${checksumPath}`); - } - - const checksums = parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); - if (checksums.size === 0) { - fail(`SHA256SUMS did not contain any checksum entries.`); - } - - for (const { output } of assets) { - const expected = checksums.get(output); - if (!expected) { - fail(`Checksum entry for ${output} not found.`); - } - } - - for (const [entry, expected] of checksums) { - const filePath = path.join(outDir, entry); - const relativePath = path.relative(outDir, filePath); - if ( - relativePath === '' || - relativePath.startsWith('..') || - path.isAbsolute(relativePath) - ) { - fail(`Invalid checksum entry path: ${entry}.`); - } - if (!fs.existsSync(filePath)) { - fail(`Checksum target for ${entry} not found.`); - } - - const actual = await sha256File(filePath); - if (actual !== expected) { - fail(`Checksum verification failed for ${entry}.`); - } - } -} - -function printUsage() { - console.log(` -Usage: - npm run package:installation-assets -- [OPTIONS] - -Options: - --out-dir PATH Output directory. Defaults to dist/standalone. - --version VERSION - Stamp release installers so their default version is VERSION. -`); -} - -export { assertInstallationAssetChecksums, buildInstallationAssets }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 1e3bf404d05..0277b4f5b81 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -11,7 +11,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isReleaseChecksumAsset } from './release-asset-config.js'; +import { isStandaloneArchiveName } from './release-asset-config.js'; import { fail, isMainModule, @@ -523,12 +523,12 @@ function createZipArchive(outputPath, cwd) { } /** - * Rebuild SHA256SUMS from scratch by scanning outDir for all release checksum - * assets. This overwrites any existing SHA256SUMS, so callers must ensure all - * desired release assets are present in outDir before calling. + * Rebuild SHA256SUMS from scratch by scanning outDir for standalone release + * archives. This overwrites any existing SHA256SUMS, so callers must ensure + * all desired archives are present in outDir before calling. */ async function writeSha256Sums(outDir) { - const entries = fs.readdirSync(outDir).filter(isReleaseChecksumAsset).sort(); + const entries = fs.readdirSync(outDir).filter(isStandaloneArchiveName).sort(); if (entries.length === 0) { fail( diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 2756f14f1f1..a3487cd556b 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -34,26 +34,30 @@ GitHub releases publish these standalone archives: - `qwen-code-linux-arm64.tar.gz` - `qwen-code-linux-x64.tar.gz` - `qwen-code-win-x64.zip` -- `install-qwen.sh` -- `install-qwen.bat` - `SHA256SUMS` -The installer scripts are published as release assets so version-specific -install entrypoints can be distributed alongside the standalone archives after -that release is created: +The installer scripts (`install-qwen-with-source.sh`, +`install-qwen-with-source.bat`) are not republished per release. They are +served from a hosted installation endpoint and accept `--version` to pin a +specific standalone release. This keeps the public install command on a stable +hosted entrypoint while still allowing version pinning, rather than using +per-release installer URLs. + +Latest hosted entrypoints used today: ```bash -curl -fsSL https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install-qwen.sh | bash +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.sh | bash -s -- --version vX.Y.Z ``` -```bat -powershell -Command "Invoke-WebRequest 'https://github.com/QwenLM/qwen-code/releases/download/vX.Y.Z/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +$installer = Join-Path $env:TEMP 'install-qwen.bat' +Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer +& $installer --version vX.Y.Z ``` -Public quick-install docs should switch to release asset URLs only after at -least one published release includes `install-qwen.sh` and `install-qwen.bat`. -Until then, keep public quick-install examples on the hosted installation URLs -used in the README and user docs. +`QWEN_INSTALL_VERSION` is the equivalent environment variable when arguments +cannot be passed through. Archive layout: @@ -185,7 +189,7 @@ Use `--base-url` for private mirrors. The URL must contain base URLs must use `https://`. For Aliyun OSS/CDN, release publishing must upload byte-identical artifacts to -both the versioned directory, for example `v0.16.0/`, and the `latest/` +both the versioned directory, for example `vX.Y.Z/`, and the `latest/` directory used by the default installer path. ## Supported Source Values diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js index 685e990308c..2cd34e4a2d7 100644 --- a/scripts/release-asset-config.js +++ b/scripts/release-asset-config.js @@ -7,23 +7,6 @@ const STANDALONE_ARCHIVE_PREFIX = 'qwen-code-'; const STANDALONE_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip']; -const INSTALLATION_ASSETS = [ - { - sourcePath: ['scripts', 'installation', 'install-qwen-with-source.sh'], - output: 'install-qwen.sh', - mode: 0o755, - }, - { - sourcePath: ['scripts', 'installation', 'install-qwen-with-source.bat'], - output: 'install-qwen.bat', - }, -]; - -const INSTALLATION_ASSET_NAMES = INSTALLATION_ASSETS.map( - ({ output }) => output, -); -const INSTALLATION_ASSET_NAME_SET = new Set(INSTALLATION_ASSET_NAMES); - function isStandaloneArchiveName(fileName) { return ( fileName.startsWith(STANDALONE_ARCHIVE_PREFIX) && @@ -33,18 +16,4 @@ function isStandaloneArchiveName(fileName) { ); } -function isInstallationAssetName(fileName) { - return INSTALLATION_ASSET_NAME_SET.has(fileName); -} - -function isReleaseChecksumAsset(fileName) { - return isStandaloneArchiveName(fileName) || isInstallationAssetName(fileName); -} - -export { - INSTALLATION_ASSET_NAMES, - INSTALLATION_ASSETS, - isInstallationAssetName, - isReleaseChecksumAsset, - isStandaloneArchiveName, -}; +export { isStandaloneArchiveName }; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index d088795540d..adc6aea1bf0 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -31,9 +31,6 @@ const standaloneReleaseScriptUrl = pathToFileURL( const standalonePackageScriptUrl = pathToFileURL( path.resolve('scripts/create-standalone-package.js'), ).href; -const installationAssetsScriptUrl = pathToFileURL( - path.resolve('scripts/build-installation-assets.js'), -).href; const releaseAssetConfigUrl = pathToFileURL( path.resolve('scripts/release-asset-config.js'), ).href; @@ -236,12 +233,13 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:standalone:release']).toBe( 'node scripts/build-standalone-release.js', ); - expect(packageJson.scripts['package:installation-assets']).toBe( - 'node scripts/build-installation-assets.js', - ); + // Per-release installer publishing was removed in favor of a stable hosted + // entrypoint with --version pinning, so no package:installation-assets + // script should exist. + expect(packageJson.scripts['package:installation-assets']).toBeUndefined(); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); - expect(existsSync('scripts/build-installation-assets.js')).toBe(true); + expect(existsSync('scripts/build-installation-assets.js')).toBe(false); expect(existsSync('scripts/release-asset-config.js')).toBe(true); expect(existsSync('scripts/release-script-utils.js')).toBe(true); @@ -263,7 +261,7 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('Rebuild SHA256SUMS from scratch'); expect(packageScript).toContain('Promise.all('); expect(packageScript).toContain( - "import { isReleaseChecksumAsset } from './release-asset-config.js';", + "import { isStandaloneArchiveName } from './release-asset-config.js';", ); expect(packageScript).toContain( "import {\n fail,\n isMainModule,\n parseCliArgs,\n sha256File,\n} from './release-script-utils.js';", @@ -297,31 +295,14 @@ describe('standalone release packaging', () => { ); expect(releaseScript).not.toContain('function parseArgs'); - const installationAssetsScript = readScript( - 'scripts/build-installation-assets.js', - ); - expect(installationAssetsScript).toContain('Copyright 2025 Qwen Team'); - expect(installationAssetsScript).toContain('writeSha256Sums(outDir)'); - expect(installationAssetsScript).toContain( - 'assertInstallationAssetChecksums(outDir, assets)', - ); - expect(installationAssetsScript).toContain( - "from './release-script-utils.js'", - ); - expect(installationAssetsScript).toContain( - 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', - ); - expect(installationAssetsScript).not.toContain('function parseArgs'); - const releaseAssetConfig = readScript('scripts/release-asset-config.js'); expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); - expect(releaseAssetConfig).toContain('INSTALLATION_ASSETS'); - expect(releaseAssetConfig).toContain('install-qwen-with-source.sh'); - expect(releaseAssetConfig).toContain('install-qwen.sh'); - expect(releaseAssetConfig).toContain('install-qwen-with-source.bat'); - expect(releaseAssetConfig).toContain('install-qwen.bat'); expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); - expect(releaseAssetConfig).toContain('isReleaseChecksumAsset'); + // Per-release installer publishing was removed; the config no longer + // exports installer-asset helpers. + expect(releaseAssetConfig).not.toContain('INSTALLATION_ASSETS'); + expect(releaseAssetConfig).not.toContain('isInstallationAssetName'); + expect(releaseAssetConfig).not.toContain('isReleaseChecksumAsset'); const releaseScriptUtils = readScript('scripts/release-script-utils.js'); expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); @@ -391,54 +372,18 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); - it('loads the installation asset packaging helper', () => { - const output = execFileSync( - process.execPath, - ['scripts/build-installation-assets.js', '--help'], - { encoding: 'utf8' }, - ); - - expect(output).toContain('package:installation-assets'); - expect(output).toContain('--out-dir PATH'); - expect(output).toContain('--version VERSION'); - }); + it('exposes only standalone archive classification', async () => { + const config = await import(releaseAssetConfigUrl); - it('rejects invalid installation asset CLI arguments', () => { - expectCommandFailure( - ['scripts/build-installation-assets.js', '--unknown'], - /Unknown option: --unknown/, - ); - expectCommandFailure( - ['scripts/build-installation-assets.js', '--out-dir'], - /--out-dir requires a value/, - ); - expectCommandFailure( - ['scripts/build-installation-assets.js', '--version'], - /--version requires a value/, - ); - expectCommandFailure( - ['scripts/build-installation-assets.js', '--version', 'beta'], - /--version must be a semver string/, + expect(config.isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe( + true, ); - }); - - it('shares release asset classification helpers', async () => { - const { - INSTALLATION_ASSET_NAMES, - isInstallationAssetName, - isReleaseChecksumAsset, - isStandaloneArchiveName, - } = await import(releaseAssetConfigUrl); - - expect(INSTALLATION_ASSET_NAMES).toEqual([ - 'install-qwen.sh', - 'install-qwen.bat', - ]); - expect(isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe(true); - expect(isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); - expect(isStandaloneArchiveName('install-qwen.sh')).toBe(false); - expect(isInstallationAssetName('install-qwen.sh')).toBe(true); - expect(isReleaseChecksumAsset('install-qwen.bat')).toBe(true); + expect(config.isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); + expect(config.isStandaloneArchiveName('install-qwen.sh')).toBe(false); + // Per-release installer publishing helpers must no longer be exported. + expect(config.INSTALLATION_ASSET_NAMES).toBeUndefined(); + expect(config.isInstallationAssetName).toBeUndefined(); + expect(config.isReleaseChecksumAsset).toBeUndefined(); }); it('parses Node.js SHASUMS entries', async () => { @@ -470,14 +415,7 @@ describe('standalone release packaging', () => { const extension = TARGETS.get(qwenTarget).outputExtension; return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); - writeFileSync( - path.join(tmpDir, 'SHA256SUMS'), - `${[ - ...lines, - `${'c'.repeat(64)} install-qwen.sh`, - `${'d'.repeat(64)} install-qwen.bat`, - ].join('\n')}\n`, - ); + writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); expect(() => assertStandaloneOutput(tmpDir)).not.toThrow(); @@ -491,100 +429,30 @@ describe('standalone release packaging', () => { } }); - it('builds release installation assets with checksums', async () => { - const { assertInstallationAssetChecksums, buildInstallationAssets } = - await import(installationAssetsScriptUrl); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); - - try { - writeFileSync(path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'), 'fake'); - - await buildInstallationAssets(tmpDir); - - const installSh = path.join(tmpDir, 'install-qwen.sh'); - const installBat = path.join(tmpDir, 'install-qwen.bat'); - const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); - - expect(readScript(installSh)).toBe( - readScript('scripts/installation/install-qwen-with-source.sh'), - ); - expect(readScript(installBat)).toBe( - readScript('scripts/installation/install-qwen-with-source.bat'), - ); - expect(checksums).toContain('qwen-code-linux-x64.tar.gz'); - expect(checksums).toContain('install-qwen.sh'); - expect(checksums).toContain('install-qwen.bat'); - if (process.platform !== 'win32') { - expect(lstatSync(installSh).mode & 0o111).not.toBe(0); - } - - writeFileSync(installSh, 'tampered'); - await expect(assertInstallationAssetChecksums(tmpDir)).rejects.toThrow( - /Checksum verification failed for install-qwen\.sh/, - ); - - writeFileSync( - installSh, - readScript('scripts/installation/install-qwen-with-source.sh'), - ); - writeFileSync( - path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'), - 'tampered', - ); - await expect(assertInstallationAssetChecksums(tmpDir)).rejects.toThrow( - /Checksum verification failed for qwen-code-linux-x64\.tar\.gz/, - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('stamps release versions into copied installation assets', async () => { - const { assertInstallationAssetChecksums, buildInstallationAssets } = - await import(installationAssetsScriptUrl); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); - - try { - await buildInstallationAssets(tmpDir, { version: '0.16.0' }); - - const installSh = readScript(path.join(tmpDir, 'install-qwen.sh')); - const installBat = readScript(path.join(tmpDir, 'install-qwen.bat')); - - expect(installSh).toContain('VERSION="${QWEN_INSTALL_VERSION:-0.16.0}"'); - expect(installSh).toContain( - 'Standalone release version. Defaults to 0.16.0.', - ); - expect(installSh).toContain('--version)'); - expect(installBat).toContain('set "VERSION=0.16.0"'); - expect(installBat).toContain( - 'Standalone release version. Defaults to 0.16.0.', - ); - expect(installBat).toContain( - 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', - ); - await expect( - assertInstallationAssetChecksums(tmpDir), - ).resolves.not.toThrow(); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('rejects missing installation asset sources', async () => { - const { buildInstallationAssets } = await import( - installationAssetsScriptUrl + it('installer scripts honor --version for hosted entrypoints', () => { + // The hosted entrypoint flow relies on the installer scripts accepting a + // --version flag (and QWEN_INSTALL_VERSION env var) so that + // curl URL | bash -s -- --version vX.Y.Z + // and the equivalent Windows incantation can pin a specific standalone + // release without per-release installer assets. + const installShellSource = readScript( + 'scripts/installation/install-qwen-with-source.sh', + ); + expect(installShellSource).toContain( + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', ); - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-install-root-')); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-assets-')); + expect(installShellSource).toContain('--version)'); + expect(installShellSource).toContain('--version requires a value'); - try { - await expect( - buildInstallationAssets(tmpDir, { root: tmpRoot }), - ).rejects.toThrow(/Installation source asset not found/); - } finally { - rmSync(tmpRoot, { recursive: true, force: true }); - rmSync(tmpDir, { recursive: true, force: true }); - } + const installBatchSource = readScript( + 'scripts/installation/install-qwen-with-source.bat', + ); + expect(installBatchSource).toContain('set "VERSION=latest"'); + expect(installBatchSource).toContain( + 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', + ); + expect(installBatchSource).toContain('"%~1"=="--version"'); + expect(installBatchSource).toContain('--version requires a value'); }); it('rejects a runtime archive without a Node executable', () => { @@ -823,14 +691,15 @@ describe('standalone release packaging', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); - expect(workflow).toContain( - 'npm run package:installation-assets -- --out-dir dist/standalone --version "${RELEASE_VERSION}"', - ); + // Per-release installer publishing was removed in favor of a stable hosted + // entrypoint, so the release workflow no longer builds or uploads installer + // scripts as release assets. + expect(workflow).not.toContain('package:installation-assets'); + expect(workflow).not.toContain('install-qwen.sh'); + expect(workflow).not.toContain('install-qwen.bat'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); - expect(workflow).toContain('dist/standalone/install-qwen.sh'); - expect(workflow).toContain('dist/standalone/install-qwen.bat'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); }); @@ -1682,27 +1551,6 @@ function runWindowsCommand(command, env = {}) { }); } -function expectCommandFailure(args, expectedOutput) { - let caughtError; - try { - execFileSync(process.execPath, args, { - encoding: 'utf8', - stdio: 'pipe', - }); - } catch (error) { - caughtError = error; - } - - expect(caughtError).toBeTruthy(); - expect( - [ - caughtError?.message, - caughtError?.stdout?.toString(), - caughtError?.stderr?.toString(), - ].join('\n'), - ).toMatch(expectedOutput); -} - function createSymlinkStandaloneArchive(tmpDir) { const packageRoot = path.join(tmpDir, 'malicious', 'qwen-code'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); From 27a1bbee1ad4233f13b31d8b98886d0ebf56fb7e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 17:15:57 +0800 Subject: [PATCH 031/129] fix(installer): reject stale hosted assets --- scripts/build-hosted-installation-assets.js | 20 ++++++++++++++++++ scripts/tests/install-script.test.js | 23 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index bc35ab3306c..2280cac3986 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -32,6 +32,13 @@ const HOSTED_INSTALLATION_ASSETS = [ output: 'install-qwen.bat', }, ]; +const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( + ({ output }) => output, +); +const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ + ...HOSTED_INSTALLATION_ASSET_NAMES, + 'SHA256SUMS', +]); const CLI_OPTIONS = { '--help': { name: 'help', type: 'boolean' }, @@ -78,6 +85,7 @@ Options: async function buildHostedInstallationAssets(outDir, options = {}) { const root = options.root || rootDir; fs.mkdirSync(outDir, { recursive: true }); + assertNoUnexpectedHostedFiles(outDir); for (const asset of HOSTED_INSTALLATION_ASSETS) { const source = path.join(root, ...asset.sourcePath); @@ -97,6 +105,17 @@ async function buildHostedInstallationAssets(outDir, options = {}) { await assertHostedInstallationAssetChecksums(outDir); } +function assertNoUnexpectedHostedFiles(outDir) { + const unexpected = fs + .readdirSync(outDir) + .filter((entryName) => !HOSTED_INSTALLATION_OUTPUT_NAMES.has(entryName)) + .sort(); + + if (unexpected.length > 0) { + fail(`Unexpected hosted installer asset: ${unexpected.join(', ')}`); + } +} + function assertHostedInstallerSource(source, output) { const contents = fs.readFileSync(source, 'utf8'); if (!contents.includes('--version')) { @@ -149,6 +168,7 @@ async function assertHostedInstallationAssetChecksums(outDir) { export { HOSTED_INSTALLATION_ASSETS, + HOSTED_INSTALLATION_ASSET_NAMES, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, }; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c2b55b34174..8272e76cb12 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -485,6 +485,7 @@ describe('standalone release packaging', () => { it('stages hosted installation assets with checksums', async () => { const { + HOSTED_INSTALLATION_ASSET_NAMES, HOSTED_INSTALLATION_ASSETS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, @@ -498,10 +499,13 @@ describe('standalone release packaging', () => { const installBat = path.join(tmpDir, 'install-qwen.bat'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); - expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual([ + expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', 'install-qwen.bat', ]); + expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( + HOSTED_INSTALLATION_ASSET_NAMES, + ); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen-with-source.sh'), ); @@ -525,6 +529,23 @@ describe('standalone release packaging', () => { } }); + it('rejects stale hosted installation assets in the output directory', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + + try { + writeFileSync(path.join(tmpDir, 'install'), 'stale alias'); + + await expect(buildHostedInstallationAssets(tmpDir)).rejects.toThrow( + /Unexpected hosted installer asset: install/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); From 51778f9fb8d81098558031edc27d65d5acf0341b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 17:47:53 +0800 Subject: [PATCH 032/129] fix(installer): refine hosted asset staging --- scripts/build-hosted-installation-assets.js | 39 ++++++++++---------- scripts/installation/INSTALLATION_GUIDE.md | 12 ++++--- scripts/tests/install-script.test.js | 40 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 2280cac3986..d0061e1b7d5 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -35,6 +35,13 @@ const HOSTED_INSTALLATION_ASSETS = [ const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, ); +const HOSTED_INSTALLER_REQUIRED_FRAGMENTS = [ + '--version', + 'QWEN_INSTALL_VERSION', + 'latest', +]; +// SHA256SUMS is allowed in an existing output directory because every staging +// run rewrites it from scratch after copying the hosted installer assets. const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ ...HOSTED_INSTALLATION_ASSET_NAMES, 'SHA256SUMS', @@ -118,31 +125,22 @@ function assertNoUnexpectedHostedFiles(outDir) { function assertHostedInstallerSource(source, output) { const contents = fs.readFileSync(source, 'utf8'); - if (!contents.includes('--version')) { - fail(`${output} must support --version for pinned hosted installs`); - } - - if ( - output.endsWith('.sh') && - !contents.includes('VERSION="${QWEN_INSTALL_VERSION:-latest}"') - ) { - fail(`${output} must default to latest for the hosted entrypoint`); - } - - if ( - output.endsWith('.bat') && - (!contents.includes('set "VERSION=latest"') || - !contents.includes( - 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', - )) - ) { - fail(`${output} must default to latest for the hosted entrypoint`); + const missing = HOSTED_INSTALLER_REQUIRED_FRAGMENTS.filter( + (fragment) => !contents.includes(fragment), + ); + if (missing.length > 0) { + fail( + `${output} is missing hosted installer behavior: ${missing.join(', ')}`, + ); } } async function writeHostedSha256Sums(outDir) { const lines = []; - for (const { output } of HOSTED_INSTALLATION_ASSETS) { + const assets = [...HOSTED_INSTALLATION_ASSETS].sort((left, right) => + left.output.localeCompare(right.output), + ); + for (const { output } of assets) { const hash = await sha256File(path.join(outDir, output)); lines.push(`${hash} ${output}`); } @@ -169,6 +167,7 @@ async function assertHostedInstallationAssetChecksums(outDir) { export { HOSTED_INSTALLATION_ASSETS, HOSTED_INSTALLATION_ASSET_NAMES, + HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, }; diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index e5637e58b73..0a43de23a04 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -70,11 +70,13 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged files should be uploaded byte-for-byte to the hosted installation -path, for example `installation/install-qwen.sh` and -`installation/install-qwen.bat`. The staging command also writes `SHA256SUMS` -for upload verification. The hosted installers intentionally default to -`latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone release. +The staged `install-qwen.sh` and `install-qwen.bat` files map to the fixed +hosted URLs shown above. Upload their contents byte-for-byte to +`installation/install-qwen.sh` and `installation/install-qwen.bat`; the staging +command also writes `SHA256SUMS` for upload verification. The hosted installers +intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to +pin a standalone release. OSS/CDN upload automation is still a follow-up release +operation; until then, release operators must sync these staged files manually. Archive layout: diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 8272e76cb12..a6a89ff6dd7 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -487,6 +487,7 @@ describe('standalone release packaging', () => { const { HOSTED_INSTALLATION_ASSET_NAMES, HOSTED_INSTALLATION_ASSETS, + HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, } = await import(hostedInstallationScriptUrl); @@ -498,6 +499,7 @@ describe('standalone release packaging', () => { const installSh = path.join(tmpDir, 'install-qwen.sh'); const installBat = path.join(tmpDir, 'install-qwen.bat'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); + const checksumLines = checksums.trim().split('\n'); expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', @@ -506,6 +508,11 @@ describe('standalone release packaging', () => { expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, ); + expect(HOSTED_INSTALLER_REQUIRED_FRAGMENTS).toEqual([ + '--version', + 'QWEN_INSTALL_VERSION', + 'latest', + ]); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen-with-source.sh'), ); @@ -513,6 +520,8 @@ describe('standalone release packaging', () => { readScript('scripts/installation/install-qwen-with-source.bat'), ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); + const checksumNames = checksumLines.map((line) => line.split(' ')[1]); + expect(checksumNames).toEqual([...checksumNames].sort()); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); expect(checksums).not.toMatch(/ {2}install$/m); @@ -529,6 +538,36 @@ describe('standalone release packaging', () => { } }); + it('rejects hosted installer sources missing pinned install behavior', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-with-source.sh'), + '#!/usr/bin/env bash\nVERSION="${QWEN_INSTALL_VERSION:-latest}"\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-with-source.bat'), + '@echo off\r\nset "VERSION=latest"\r\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow( + /install-qwen\.sh is missing hosted installer behavior: --version/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects stale hosted installation assets in the output directory', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl @@ -808,6 +847,7 @@ describe('standalone release packaging', () => { expect(guide).toContain('package:hosted-installation'); expect(guide).toContain('installation/install-qwen.sh'); expect(guide).toContain('installation/install-qwen.bat'); + expect(guide).toContain('release operators must sync these staged files'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); From 2e4086aa4a00d3b63146b906c26a7a300e315cf5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 19:03:22 +0800 Subject: [PATCH 033/129] fix(installer): tighten hosted default-version check, flag legacy URL - Replace the loose `latest` fragment check with per-format regex patterns in HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS so an unrelated occurrence of `latest` (comment, help text) cannot satisfy the staging guard. The patterns still tolerate whitespace variation, only the default-version assignment itself must be intact. - Add a "Hosted endpoint status" callout in INSTALLATION_GUIDE.md before the curl examples. The documented `--version` flow does not work against the OSS URL today because it currently serves the legacy NVM-based installer; the callout points users at a local checkout until the next release sync. - Tests: drop `latest` from the fragments equality assertion, add positive and negative regex coverage, add a failure-path case for sources whose default version is not `latest`, and pin the new guide markers so the callout cannot silently disappear. --- scripts/build-hosted-installation-assets.js | 17 +++++- scripts/installation/INSTALLATION_GUIDE.md | 7 +++ scripts/tests/install-script.test.js | 65 ++++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index d0061e1b7d5..9d1c21335dd 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -38,8 +38,15 @@ const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( const HOSTED_INSTALLER_REQUIRED_FRAGMENTS = [ '--version', 'QWEN_INSTALL_VERSION', - 'latest', ]; +// Narrow regexes that pin the default-version assignment to `latest`. +// Substring matching alone would let the word "latest" leak in via comments +// or help text even when the actual default has been changed. The patterns +// allow whitespace flexibility but require the literal default value. +const HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS = { + 'install-qwen.sh': /VERSION\s*=\s*"\$\{QWEN_INSTALL_VERSION:-latest\}"/, + 'install-qwen.bat': /set\s+"VERSION=latest"/, +}; // SHA256SUMS is allowed in an existing output directory because every staging // run rewrites it from scratch after copying the hosted installer assets. const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ @@ -133,6 +140,13 @@ function assertHostedInstallerSource(source, output) { `${output} is missing hosted installer behavior: ${missing.join(', ')}`, ); } + + const defaultPattern = HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS[output]; + if (defaultPattern && !defaultPattern.test(contents)) { + fail( + `${output} default install version must be 'latest' for the hosted entrypoint`, + ); + } } async function writeHostedSha256Sums(outDir) { @@ -167,6 +181,7 @@ async function assertHostedInstallationAssetChecksums(outDir) { export { HOSTED_INSTALLATION_ASSETS, HOSTED_INSTALLATION_ASSET_NAMES, + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 0a43de23a04..2a5c561785d 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -43,6 +43,13 @@ specific standalone release. This keeps the public install command on a stable hosted entrypoint while still allowing version pinning, rather than using per-release installer URLs. +> **Hosted endpoint status**: Until the hosted endpoint is re-synced after the +> next release, the URL below still serves the legacy NVM-based installer, +> which does not honor `--version` or `QWEN_INSTALL_VERSION` in the way +> documented here. To get the standalone-archive-first behavior immediately, +> run `install-qwen-with-source.sh` from a local checkout of this repository. +> The `--version` examples below describe the post-sync behavior. + Latest hosted entrypoints used today: ```bash diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a6a89ff6dd7..faf0caedbcd 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -487,6 +487,7 @@ describe('standalone release packaging', () => { const { HOSTED_INSTALLATION_ASSET_NAMES, HOSTED_INSTALLATION_ASSETS, + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, @@ -511,8 +512,29 @@ describe('standalone release packaging', () => { expect(HOSTED_INSTALLER_REQUIRED_FRAGMENTS).toEqual([ '--version', 'QWEN_INSTALL_VERSION', - 'latest', ]); + // The default-version regex pins `latest` semantically rather than as a + // loose substring, so a stray `latest` in a comment cannot satisfy it. + expect( + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + ), + ).toBe(true); + expect( + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( + '# defaults to latest', + ), + ).toBe(false); + expect( + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( + 'set "VERSION=latest"', + ), + ).toBe(true); + expect( + HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( + 'rem defaults to latest', + ), + ).toBe(false); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen-with-source.sh'), ); @@ -568,6 +590,42 @@ describe('standalone release packaging', () => { } }); + it('rejects hosted installer sources whose default version is not latest', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + // Both fragments are present, but the default version was changed to + // something other than `latest`. The default-version pattern guard + // catches this, even though loose substring matching would not. + writeFileSync( + path.join(sourceDir, 'install-qwen-with-source.sh'), + '#!/usr/bin/env bash\n' + + '# Defaults to latest unless --version is passed.\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-with-source.bat'), + '@echo off\r\nset "VERSION=stable"\r\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow( + /install-qwen\.sh default install version must be 'latest'/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects stale hosted installation assets in the output directory', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl @@ -848,6 +906,11 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen.sh'); expect(guide).toContain('installation/install-qwen.bat'); expect(guide).toContain('release operators must sync these staged files'); + // The hosted-endpoint status callout must keep flagging the transition + // window so users do not assume the documented --version flow works + // before the next OSS sync. + expect(guide).toContain('Hosted endpoint status'); + expect(guide).toContain('legacy NVM-based installer'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); From 4100b8e2393723af8b7f9e4e184d804cb122966a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 20:09:30 +0800 Subject: [PATCH 034/129] feat(installer): verify installation release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `npm run verify:installation-release` and wires it into the release workflow after `Build Standalone Archives`, so a broken release directory fails CI before publishing. Local mode (`--dir PATH`) checks: - All five `qwen-code-{platform}.{ext}` standalone archives exist. - `SHA256SUMS` covers exactly those five — missing or unexpected entries fail. - Each archive's actual SHA256 matches its `SHA256SUMS` entry. Remote mode (`--base-url URL`) checks: - `SHA256SUMS` is downloadable, parseable, and contains exactly the expected archive entries. - Each archive URL is reachable via HEAD, with a 1-byte ranged GET fallback for hosts that disable HEAD. Hosted installer scripts (`install-qwen.sh` / `install-qwen.bat`) are intentionally out of scope here — they are served from the hosted endpoint prepared by `package:hosted-installation` (PR #3853), not from the GitHub Release surface this verifier targets. --- .github/workflows/release.yml | 3 + package.json | 1 + scripts/tests/install-script.test.js | 284 +++++++++++++++++++++++++ scripts/verify-installation-release.js | 212 ++++++++++++++++++ 4 files changed, 500 insertions(+) create mode 100644 scripts/verify-installation-release.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f3559bd52..9fbb3fbdbe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,6 +384,9 @@ jobs: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + - name: 'Verify Installation Release Assets' + run: 'npm run verify:installation-release -- --dir dist/standalone' + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- diff --git a/package.json b/package.json index 01dfb9ccec6..e8a4a29da9e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "package:hosted-installation": "node scripts/build-hosted-installation-assets.js", "package:standalone": "node scripts/create-standalone-package.js", "package:standalone:release": "node scripts/build-standalone-release.js", + "verify:installation-release": "node scripts/verify-installation-release.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index faf0caedbcd..ad65933111d 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -34,6 +34,9 @@ const standalonePackageScriptUrl = pathToFileURL( const hostedInstallationScriptUrl = pathToFileURL( path.resolve('scripts/build-hosted-installation-assets.js'), ).href; +const installationReleaseVerificationScriptUrl = pathToFileURL( + path.resolve('scripts/verify-installation-release.js'), +).href; const releaseAssetConfigUrl = pathToFileURL( path.resolve('scripts/release-asset-config.js'), ).href; @@ -239,6 +242,9 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:hosted-installation']).toBe( 'node scripts/build-hosted-installation-assets.js', ); + expect(packageJson.scripts['verify:installation-release']).toBe( + 'node scripts/verify-installation-release.js', + ); // Per-release installer publishing was removed in favor of a stable hosted // entrypoint with --version pinning, so no package:installation-assets // script should exist. @@ -248,6 +254,7 @@ describe('standalone release packaging', () => { expect(existsSync('scripts/build-hosted-installation-assets.js')).toBe( true, ); + expect(existsSync('scripts/verify-installation-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(false); expect(existsSync('scripts/release-asset-config.js')).toBe(true); expect(existsSync('scripts/release-script-utils.js')).toBe(true); @@ -312,6 +319,25 @@ describe('standalone release packaging', () => { expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); expect(hostedInstallScript).not.toContain("output: 'install'"); + const releaseVerifyScript = readScript( + 'scripts/verify-installation-release.js', + ); + expect(releaseVerifyScript).toContain('Copyright 2025 Qwen Team'); + expect(releaseVerifyScript).toContain('verifyReleaseDirectory'); + expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); + expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); + expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES'); + // The verifier targets only standalone archives + SHA256SUMS; hosted + // installer scripts have their own staging path and are intentionally + // not part of the GitHub release surface. Asserting absence of the + // alias / installer-asset *helper functions* is enough — comments may + // legitimately reference the hosted filenames as context. + expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES'); + expect(releaseVerifyScript).not.toContain('isReleaseChecksumAsset'); + expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches'); + expect(releaseVerifyScript).not.toContain('assertInstallAliasBuffersMatch'); + expect(releaseVerifyScript).not.toContain('assertUnixInstallersExecutable'); + const releaseAssetConfig = readScript('scripts/release-asset-config.js'); expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); @@ -400,6 +426,59 @@ describe('standalone release packaging', () => { expect(output).toContain('--out-dir PATH'); }); + it('loads the installation release verification helper', () => { + const output = execFileSync( + process.execPath, + ['scripts/verify-installation-release.js', '--help'], + { encoding: 'utf8' }, + ); + + expect(output).toContain('verify:installation-release'); + expect(output).toContain('--dir PATH'); + expect(output).toContain('--base-url URL'); + }); + + it('rejects invalid installation release verification CLI arguments', () => { + const expectFail = (args, expectedOutput) => { + let caughtError; + try { + execFileSync(process.execPath, args, { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + caughtError = error; + } + expect(caughtError).toBeTruthy(); + expect( + [ + caughtError?.message, + caughtError?.stdout?.toString(), + caughtError?.stderr?.toString(), + ].join('\n'), + ).toMatch(expectedOutput); + }; + + expectFail( + ['scripts/verify-installation-release.js', '--unknown'], + /Unknown option: --unknown/, + ); + expectFail( + ['scripts/verify-installation-release.js', '--dir'], + /--dir requires a value/, + ); + expectFail( + [ + 'scripts/verify-installation-release.js', + '--dir', + '/tmp', + '--base-url', + 'https://example.com/r/', + ], + /Pass --dir or --base-url, not both/, + ); + }); + it('exposes only standalone archive classification', async () => { const config = await import(releaseAssetConfigUrl); @@ -643,6 +722,166 @@ describe('standalone release packaging', () => { } }); + it('verifies release asset directory contents and checksums', 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); + await expect(verifyReleaseDirectory(tmpDir)).resolves.not.toThrow(); + + // Tampering an archive must be caught by the per-asset hash check. + appendFileSync( + path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]), + 'tamper', + ); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + new RegExp( + `Checksum verification failed for ${EXPECTED_STANDALONE_ARCHIVE_NAMES[0].replace(/\./g, '\\.')}`, + ), + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects missing release archives and unexpected checksum entries', 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); + rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Missing release asset: qwen-code-/, + ); + + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeStandaloneReleaseChecksums(tmpDir, [ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'qwen-code-extra.tar.gz', + ]); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects a release directory without SHA256SUMS', async () => { + const { verifyReleaseDirectory } = await import( + installationReleaseVerificationScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /SHA256SUMS was not found at /, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('verifies release asset URLs from SHA256SUMS', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = standaloneChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const fetchedUrls = []; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + fetchedUrls.push([url, options.method || 'GET']); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 200 }); + }, + }), + ).resolves.not.toThrow(); + + expect(fetchedUrls).toContainEqual([ + 'https://example.com/qwen-code/v0.0.0/SHA256SUMS', + 'GET', + ]); + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + expect(fetchedUrls).toContainEqual([ + `https://example.com/qwen-code/v0.0.0/${assetName}`, + 'HEAD', + ]); + } + // Hosted installer scripts must not be fetched: the verifier targets + // GitHub release assets only. + for (const [url] of fetchedUrls) { + expect(url).not.toMatch(/install-qwen\.(sh|bat)$/); + expect(url).not.toMatch(/\/install$/); + } + }); + + it('falls back to ranged GET when remote HEAD is unavailable', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = standaloneChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const observedMethods = []; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const method = options.method || 'GET'; + observedMethods.push(method); + if (method === 'HEAD') { + return new Response(null, { status: 405 }); + } + // Ranged GET fallback succeeds. + return new Response(null, { status: 206 }); + }, + }), + ).resolves.not.toThrow(); + + expect(observedMethods).toContain('HEAD'); + expect(observedMethods).toContain('GET'); + }); + + it('rejects a release base URL with no archives reachable', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = standaloneChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 404 }); + }, + }), + ).rejects.toThrow(/Release asset URL is not available/); + }); + + it('rejects a release base URL that is not http(s)', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( + /--base-url must use http or https/, + ); + }); + it('rejects a runtime archive without a Node executable', () => { const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -889,6 +1128,15 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); + // The verify step must run after the build step so a broken release + // directory is caught before publishing. + expect(workflow).toContain( + 'npm run verify:installation-release -- --dir dist/standalone', + ); + const buildIndex = workflow.indexOf('npm run package:standalone:release'); + const verifyIndex = workflow.indexOf('npm run verify:installation-release'); + expect(buildIndex).toBeGreaterThan(-1); + expect(verifyIndex).toBeGreaterThan(buildIndex); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -1818,3 +2066,39 @@ function writeChecksumFile(outDir, archiveName) { .digest('hex'); writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); } + +// Writes a synthetic standalone release directory: each archive name in +// `archiveNames` becomes a small file whose content equals the asset name, +// and SHA256SUMS is regenerated to match. +function writeStandaloneReleaseAssets(outDir, archiveNames) { + mkdirSync(outDir, { recursive: true }); + for (const assetName of archiveNames) { + writeFileSync(path.join(outDir, assetName), `${assetName}\n`); + } + writeStandaloneReleaseChecksums(outDir, archiveNames); +} + +function writeStandaloneReleaseChecksums(outDir, archiveNames) { + const lines = archiveNames.map((assetName) => { + const filePath = path.join(outDir, assetName); + // Allow callers to list a not-yet-written archive name (e.g. an + // "unexpected extra" entry) without requiring the file to exist. + const hash = existsSync(filePath) + ? crypto.createHash('sha256').update(readFileSync(filePath)).digest('hex') + : 'a'.repeat(64); + return `${hash} ${assetName}`; + }); + writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +function standaloneChecksumContent(archiveNames) { + return `${archiveNames + .map( + (assetName) => + `${crypto + .createHash('sha256') + .update(`${assetName}\n`) + .digest('hex')} ${assetName}`, + ) + .join('\n')}\n`; +} diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js new file mode 100644 index 00000000000..48101c10a26 --- /dev/null +++ b/scripts/verify-installation-release.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { isStandaloneArchiveName } from './release-asset-config.js'; +import { + fail, + isMainModule, + parseCliArgs, + parseSha256Sums, + sha256File, +} from './release-script-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, +); +// Release artifacts that the installer chain expects in a GitHub Release. +// Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served +// from a separate hosted endpoint and are intentionally not part of this set; +// they have their own staging path in `package:hosted-installation`. +const EXPECTED_RELEASE_ASSET_NAMES = [ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'SHA256SUMS', +]; + +const CLI_OPTIONS = { + '--help': { name: 'help', type: 'boolean' }, + '-h': { name: 'help', type: 'boolean' }, + '--dir': { name: 'dir' }, + '--base-url': { name: 'baseUrl' }, +}; + +if (isMainModule(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +async function main() { + const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { + help: false, + dir: undefined, + baseUrl: undefined, + }); + if (args.help) { + printUsage(); + return; + } + if (args.dir && args.baseUrl) { + fail('Pass --dir or --base-url, not both.'); + } + if (args.baseUrl) { + await verifyReleaseBaseUrl(args.baseUrl); + return; + } + await verifyReleaseDirectory( + path.resolve(args.dir || path.join(rootDir, 'dist', 'standalone')), + ); +} + +function printUsage() { + console.log(`Usage: npm run verify:installation-release -- [options] + +Verifies that an installation release directory or release URL contains the +expected standalone archives and a SHA256SUMS file that covers them with +matching content hashes. + +Options: + --dir PATH Verify a local release directory. Defaults to dist/standalone. + --base-url URL Verify a remote release URL (e.g. a GitHub release download + prefix). Cannot be combined with --dir. + -h, --help Show this help message. +`); +} + +async function verifyReleaseDirectory(dir) { + const checksums = readReleaseChecksums(dir); + assertExpectedChecksumEntries(checksums); + + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + const assetPath = path.join(dir, assetName); + if (!fs.existsSync(assetPath)) { + fail(`Missing release asset: ${assetName}`); + } + + const actual = await sha256File(assetPath); + if (actual !== checksums.get(assetName)) { + fail(`Checksum verification failed for ${assetName}`); + } + } + + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`, + ); +} + +async function verifyReleaseBaseUrl(baseUrl, options = {}) { + const { fetchImpl = fetch } = options; + const normalizedBaseUrl = normalizeHttpBaseUrl(baseUrl); + const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); + const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); + assertExpectedChecksumEntries(checksums); + + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + await assertRemoteAssetAvailable( + new URL(assetName, normalizedBaseUrl).toString(), + fetchImpl, + ); + } + + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release asset URLs at ${baseUrl}`, + ); +} + +function readReleaseChecksums(dir) { + const checksumPath = path.join(dir, 'SHA256SUMS'); + if (!fs.existsSync(checksumPath)) { + fail(`SHA256SUMS was not found at ${checksumPath}`); + } + + return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); +} + +function assertExpectedChecksumEntries(checksums) { + const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES); + const missing = EXPECTED_STANDALONE_ARCHIVE_NAMES.filter( + (assetName) => !checksums.has(assetName), + ); + const extra = Array.from(checksums.keys()).filter( + (assetName) => + isStandaloneArchiveName(assetName) && !expected.has(assetName), + ); + + if (missing.length > 0) { + fail(`Missing release asset checksum: ${missing.join(', ')}`); + } + if (extra.length > 0) { + fail(`Unexpected release asset checksum: ${extra.join(', ')}`); + } +} + +async function assertRemoteAssetAvailable(url, fetchImpl) { + let response = await fetchImpl(url, { method: 'HEAD' }); + if (response.ok) { + await response.body?.cancel?.(); + return; + } + await response.body?.cancel?.(); + + // Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET + // so the verifier can still confirm reachability without downloading the + // full archive. + response = await fetchImpl(url, { + headers: { + Range: 'bytes=0-0', + }, + }); + if (!response.ok) { + fail(`Release asset URL is not available: ${url}`); + } + await response.body?.cancel?.(); +} + +async function fetchText(url, fetchImpl) { + const response = await fetchImpl(url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + return response.text(); +} + +function normalizeHttpBaseUrl(baseUrl) { + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + fail(`--base-url must be a valid URL: ${baseUrl}`); + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + fail(`--base-url must use http or https: ${baseUrl}`); + } + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); +} + +export { + EXPECTED_RELEASE_ASSET_NAMES, + EXPECTED_STANDALONE_ARCHIVE_NAMES, + verifyReleaseBaseUrl, + verifyReleaseDirectory, +}; From 362bf588f85b52007b7d95eb0364b2da83118fb3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 21:08:48 +0800 Subject: [PATCH 035/129] fix(installer): tighten verifier base-url + clarify test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small refinements from the second review pass: - normalizeHttpsBaseUrl rejects everything except https, since real release URLs are always HTTPS. Accepting http previously would let an operator silently target a stale or attacker-controlled mirror. - Drop EXPECTED_RELEASE_ASSET_NAMES from the public exports; it was only used internally for the verification log line. - Rename the test helper standaloneChecksumContent to placeholderChecksumContent and document that the hashes in its output are placeholders — the remote verifier does not download archives or compare hashes, it only validates that SHA256SUMS lists the expected names and that each archive URL is reachable. The non-https rejection test now also covers `http://` in addition to the existing `file://` case. --- scripts/tests/install-script.test.js | 24 ++++++++++++++++++------ scripts/verify-installation-release.js | 12 +++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index ad65933111d..5b9c6a7e763 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -789,7 +789,7 @@ describe('standalone release packaging', () => { it('verifies release asset URLs from SHA256SUMS', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); - const checksumContent = standaloneChecksumContent( + const checksumContent = placeholderChecksumContent( EXPECTED_STANDALONE_ARCHIVE_NAMES, ); const fetchedUrls = []; @@ -827,7 +827,7 @@ describe('standalone release packaging', () => { it('falls back to ranged GET when remote HEAD is unavailable', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); - const checksumContent = standaloneChecksumContent( + const checksumContent = placeholderChecksumContent( EXPECTED_STANDALONE_ARCHIVE_NAMES, ); const observedMethods = []; @@ -856,7 +856,7 @@ describe('standalone release packaging', () => { it('rejects a release base URL with no archives reachable', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); - const checksumContent = standaloneChecksumContent( + const checksumContent = placeholderChecksumContent( EXPECTED_STANDALONE_ARCHIVE_NAMES, ); @@ -872,14 +872,22 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/Release asset URL is not available/); }); - it('rejects a release base URL that is not http(s)', async () => { + it('rejects a release base URL that is not https', async () => { const { verifyReleaseBaseUrl } = await import( installationReleaseVerificationScriptUrl ); + // file:// must be rejected as a URL the verifier cannot reach safely. await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( - /--base-url must use http or https/, + /--base-url must use https/, ); + + // Plain http must also be rejected even though it is technically a valid + // URL — release URLs are always HTTPS, and accepting http would let an + // operator silently target a stale or attacker-controlled mirror. + await expect( + verifyReleaseBaseUrl('http://example.com/release/'), + ).rejects.toThrow(/--base-url must use https/); }); it('rejects a runtime archive without a Node executable', () => { @@ -2091,7 +2099,11 @@ function writeStandaloneReleaseChecksums(outDir, archiveNames) { writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -function standaloneChecksumContent(archiveNames) { +// Generates a SHA256SUMS-formatted string for the given archive names. The +// hash values are placeholders — the remote verifier (verifyReleaseBaseUrl) +// only checks that SHA256SUMS lists the expected entries and that each +// archive URL is reachable; it does not download archives or compare hashes. +function placeholderChecksumContent(archiveNames) { return `${archiveNames .map( (assetName) => diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 48101c10a26..227c3a5085c 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -112,7 +112,7 @@ async function verifyReleaseDirectory(dir) { async function verifyReleaseBaseUrl(baseUrl, options = {}) { const { fetchImpl = fetch } = options; - const normalizedBaseUrl = normalizeHttpBaseUrl(baseUrl); + const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl); const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); assertExpectedChecksumEntries(checksums); @@ -188,15 +188,18 @@ async function fetchText(url, fetchImpl) { return response.text(); } -function normalizeHttpBaseUrl(baseUrl) { +function normalizeHttpsBaseUrl(baseUrl) { let parsed; try { parsed = new URL(baseUrl); } catch { fail(`--base-url must be a valid URL: ${baseUrl}`); } - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - fail(`--base-url must use http or https: ${baseUrl}`); + // Real release URLs are always HTTPS. Tests use injected fetchImpl, so + // they don't need a real protocol. Rejecting non-https early prevents an + // operator from accidentally pointing the verifier at a plain-http mirror. + if (parsed.protocol !== 'https:') { + fail(`--base-url must use https: ${baseUrl}`); } if (!parsed.pathname.endsWith('/')) { parsed.pathname = `${parsed.pathname}/`; @@ -205,7 +208,6 @@ function normalizeHttpBaseUrl(baseUrl) { } export { - EXPECTED_RELEASE_ASSET_NAMES, EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl, verifyReleaseDirectory, From 1917316b12ae791a569fc77774ce4d154437137b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 13:50:33 +0800 Subject: [PATCH 036/129] fix(installer): address standalone review follow-ups --- scripts/build-standalone-release.js | 15 +- scripts/create-standalone-package.js | 8 +- .../installation/install-qwen-with-source.bat | 51 +++++- .../installation/install-qwen-with-source.sh | 13 +- scripts/tests/install-script.test.js | 149 +++++++++++++++++- 5 files changed, 216 insertions(+), 20 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 7cfe52f8c97..bb8963658e8 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -82,7 +82,9 @@ async function main() { return; } - const nodeVersion = args.nodeVersion || process.versions.node; + const nodeVersion = normalizeNodeVersion( + args.nodeVersion || process.versions.node, + ); const outDir = path.resolve( args.outDir || path.join(rootDir, 'dist', 'standalone'), ); @@ -136,6 +138,10 @@ async function main() { } } +function normalizeNodeVersion(version) { + return version.replace(/^v/i, ''); +} + async function packageTarget({ qwenTarget, nodeTarget, @@ -284,4 +290,9 @@ Host requirements: `); } -export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; +export { + assertStandaloneOutput, + normalizeNodeVersion, + parseChecksums, + RELEASE_TARGETS, +}; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 0277b4f5b81..bd188d48a7e 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -221,10 +221,10 @@ function copyRuntimeAssets(packageRoot, outDir) { ); } - fs.copyFileSync( - path.join(rootDir, 'package.json'), - path.join(packageRoot, 'package.json'), - ); + const packageJsonPath = fs.existsSync(path.join(distDir, 'package.json')) + ? path.join(distDir, 'package.json') + : path.join(rootDir, 'package.json'); + fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json')); } function topLevelDistEntryForPath(candidatePath) { diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 50c1740dfcc..f7713d0a6c6 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -306,8 +306,10 @@ 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 +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if !ERRORLEVEL! EQU 0 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 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 @@ -512,7 +514,11 @@ if !ERRORLEVEL! NEQ 0 ( REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" -mkdir "!EXTRACT_DIR!" >nul 2>&1 +call :EnsureDir "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) call :ValidateArchiveContents "!ARCHIVE_FILE!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 @@ -548,10 +554,22 @@ if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( exit /b 1 ) -if not exist "!INSTALL_BASE!" mkdir "!INSTALL_BASE!" -if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" +call :EnsureDir "!INSTALL_BASE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureDir "!INSTALL_BIN_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" -if not exist "!INSTALL_PARENT!" mkdir "!INSTALL_PARENT!" +call :EnsureDir "!INSTALL_PARENT!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) REM Stage into .new and keep .old so failed upgrades can roll back. set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" @@ -659,6 +677,18 @@ if "!TEMP_DIR!"=="" ( ) exit /b 0 +:EnsureDir +set "REQUIRED_DIR=%~1" +if exist "!REQUIRED_DIR!\NUL" exit /b 0 +if exist "!REQUIRED_DIR!" ( + echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! + exit /b 1 +) +mkdir "!REQUIRED_DIR!" >nul 2>&1 +if !ERRORLEVEL! EQU 0 exit /b 0 +echo ERROR: Failed to create directory: !REQUIRED_DIR! +exit /b 1 + :ValidateArchiveContents set "QWEN_ARCHIVE_FILE=%~1" REM Normalize backslashes to forward slashes before checking. Some Windows @@ -699,7 +729,11 @@ exit /b %PS_STATUS% :EnsureManagedInstallDir set "MANAGED_DIR=%~1" if not exist "!MANAGED_DIR!" exit /b 0 -if exist "!MANAGED_DIR!\manifest.json" exit /b 0 +set "QWEN_MANAGED_DIR=!MANAGED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_MANAGED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. @@ -784,7 +818,8 @@ exit /b 0 if "!SOURCE!"=="unknown" exit /b 0 set "QWEN_DIR=!USERPROFILE!\.qwen" -if not exist "!QWEN_DIR!" mkdir "!QWEN_DIR!" +call :EnsureDir "!QWEN_DIR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 ( echo { diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index b64a9d0d674..3e338d2a447 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -708,7 +708,7 @@ ensure_managed_install_dir() { return 0 fi - if [[ -f "${install_dir}/manifest.json" ]]; then + if is_qwen_standalone_install_dir "${install_dir}"; then return 0 fi @@ -717,6 +717,17 @@ ensure_managed_install_dir() { return 1 } +is_qwen_standalone_install_dir() { + local install_dir="$1" + local manifest_path="${install_dir}/manifest.json" + + [[ -f "${manifest_path}" ]] || return 1 + grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 + grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 + [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 + [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 +} + write_unix_wrapper() { local wrapper_path="$1" local qwen_bin="$2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index adc6aea1bf0..c7ed739d010 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -112,6 +112,13 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); + expect(script).toContain('is_qwen_standalone_install_dir()'); + expect(script).toContain( + '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"', + ); + expect(script).toContain( + '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"', + ); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', ); @@ -195,6 +202,13 @@ describe('installation scripts', () => { expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); expect(script).toContain(':ValidateVersion'); + expect(script).not.toContain('^v*'); + expect(script).toContain('/C:"^v[0-9]'); + expect(script).toContain(':EnsureDir'); + expect(script).toContain('Failed to create directory'); + expect(script).toContain('ConvertFrom-Json'); + expect(script).toContain("$data.name -ne '@qwen-code/qwen-code'"); + expect(script).toContain("$data.target -notmatch '^win-(x64|arm64)$'"); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); @@ -249,7 +263,10 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('DIST_ALLOWED_ENTRIES'); expect(packageScript).toContain('Unexpected dist asset'); expect(packageScript).toContain('topLevelDistEntryForPath(outDir)'); - expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); + expect(packageScript).toContain("path.join(distDir, 'package.json')"); + expect(packageScript).toContain( + "fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json'))", + ); expect(packageScript).toContain('validateNodeRuntime'); expect(packageScript).toContain('copyNodeRuntimeEntry'); expect(packageScript).toContain('symlink cycle'); @@ -273,6 +290,8 @@ describe('standalone release packaging', () => { const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); + expect(releaseScript).toContain('normalizeNodeVersion('); + expect(releaseScript).toContain("version.replace(/^v/i, '')"); expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); expect(releaseScript).toContain('SHASUMS256.txt'); expect(releaseScript).toContain('verifyNodeArchive'); @@ -372,6 +391,13 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); + it('normalizes Node.js versions passed to the release helper', async () => { + const { normalizeNodeVersion } = await import(standaloneReleaseScriptUrl); + + expect(normalizeNodeVersion('v20.19.0')).toBe('20.19.0'); + expect(normalizeNodeVersion('20.19.0')).toBe('20.19.0'); + }); + it('exposes only standalone archive classification', async () => { const config = await import(releaseAssetConfigUrl); @@ -523,6 +549,13 @@ describe('standalone release packaging', () => { expect( existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')), ).toBe(true); + const packagedPackageJson = JSON.parse( + readScript(path.join(extractDir, 'qwen-code', 'package.json')), + ); + expect(packagedPackageJson).toEqual({ + name: '@qwen-code/qwen-code', + version: '0.0.0', + }); expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain( 'qwen-code-win-x64.zip', ); @@ -894,6 +927,36 @@ describe('Linux/macOS installer end-to-end', () => { } }); + itOnUnix( + 'refuses to overwrite a directory with an unrelated manifest', + () => { + const restoreDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'lib', 'qwen-code'); + mkdirSync(installDir, { recursive: true }); + writeFileSync( + path.join(installDir, 'manifest.json'), + JSON.stringify({ name: 'other-app', target: 'linux-x64' }), + ); + writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); + + expect(() => + runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), + ).toThrow(/not a Qwen Code standalone install/); + expect(readScript(path.join(installDir, 'important.txt'))).toBe( + 'keep me\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreDist(); + } + }, + ); + itOnUnix('does not fall back to npm when detect finds a bad archive', () => { const restoreDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1111,6 +1174,45 @@ describe('Windows installer end-to-end', () => { } }); + itOnWindows('rejects a local archive when SHA256SUMS is missing', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/SHA256SUMS not found; cannot verify archive/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnWindows('rejects a local archive missing required entries', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir, { + includeNode: false, + }); + + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/qwen-code\\node\\node.exe/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnWindows( 'rejects standalone archives containing path traversal entries', () => { @@ -1133,6 +1235,34 @@ describe('Windows installer end-to-end', () => { }, ); + itOnWindows( + 'refuses to overwrite a directory with an unrelated manifest', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'qwen-code'); + mkdirSync(installDir, { recursive: true }); + writeFileSync( + path.join(installDir, 'manifest.json'), + JSON.stringify({ name: 'other-app', target: 'win-x64' }), + ); + writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); + + expect(() => + runWindowsInstaller(archive, installRoot, path.join(tmpDir, 'home')), + ).toThrow(/not a Qwen Code standalone install/); + expect(readScript(path.join(installDir, 'important.txt'))).toBe( + 'keep me\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + itOnWindows('rejects unsafe environment-derived install paths', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1304,7 +1434,11 @@ function createFakeWindowsNodeArchive(tmpDir) { return archive; } -function createFakeWindowsStandaloneArchive(tmpDir) { +function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { + const { + includeNode = true, + manifest = { name: '@qwen-code/qwen-code', target: 'win-x64' }, + } = options; const packageRoot = path.join(tmpDir, 'qwen-code'); const outDir = path.join(tmpDir, 'out'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); @@ -1315,10 +1449,15 @@ function createFakeWindowsStandaloneArchive(tmpDir) { path.join(packageRoot, 'bin', 'qwen.cmd'), ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), ); - writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); + if (includeNode) { + writeFileSync( + path.join(packageRoot, 'node', 'node.exe'), + 'fake node.exe\n', + ); + } writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify(manifest), ); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); @@ -1356,7 +1495,7 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", ' Add-ZipEntry $zip \'qwen-code/bin/qwen.cmd\' "@echo off`r`necho 0.0.0-smoke`r`n"', " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", - ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code"}\'', + ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code","target":"win-x64"}\'', '} finally { $zip.Dispose() }', '', ].join('\r\n'), From 1502f72852a6fa011fd24f4797b9b0da4107d1bd Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 15:55:57 +0800 Subject: [PATCH 037/129] fix(installer): repair Windows installer tests --- scripts/installation/install-qwen-with-source.bat | 10 ++++++---- scripts/tests/install-script.test.js | 8 +++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index f7713d0a6c6..3c7e2e6105a 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -679,13 +679,15 @@ exit /b 0 :EnsureDir set "REQUIRED_DIR=%~1" -if exist "!REQUIRED_DIR!\NUL" exit /b 0 -if exist "!REQUIRED_DIR!" ( +set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_REQUIRED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 +if !PS_STATUS! EQU 2 ( echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! exit /b 1 ) -mkdir "!REQUIRED_DIR!" >nul 2>&1 -if !ERRORLEVEL! EQU 0 exit /b 0 echo ERROR: Failed to create directory: !REQUIRED_DIR! exit /b 1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c7ed739d010..353b807b484 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1487,10 +1487,8 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { ' $writer = [System.IO.StreamWriter]::new($entry.Open())', ' try { $writer.Write($content) } finally { $writer.Dispose() }', '}', - '$zip = [System.IO.Compression.ZipFile]::Open(', - ' $env:QWEN_TEST_ZIP_ARCHIVE,', - ' [System.IO.Compression.ZipArchiveMode]::Create', - ')', + 'if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }', + '$zip = [System.IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [System.IO.Compression.ZipArchiveMode]::Create)', 'try {', " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", ' Add-ZipEntry $zip \'qwen-code/bin/qwen.cmd\' "@echo off`r`necho 0.0.0-smoke`r`n"', @@ -1509,7 +1507,7 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { ...process.env, QWEN_TEST_ZIP_ARCHIVE: archive, }, - stdio: 'ignore', + stdio: 'pipe', }, ); writeChecksumFile(outDir, path.basename(archive)); From fddbfdc53fb92565e62d6db004499f5a12894cbf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 15:58:07 +0800 Subject: [PATCH 038/129] fix(release): tighten standalone asset checks --- .github/workflows/release.yml | 3 ++- scripts/create-standalone-package.js | 2 +- scripts/release-asset-config.js | 2 ++ scripts/tests/install-script.test.js | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f35e282766..a0fa5ff2e2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -418,7 +418,8 @@ jobs: gh release create "${RELEASE_TAG}" \ dist/cli.js \ - dist/standalone/qwen-code-* \ + dist/standalone/qwen-code-*.tar.gz \ + dist/standalone/qwen-code-*.zip \ dist/standalone/SHA256SUMS \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index bd188d48a7e..b7d1ce46535 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -532,7 +532,7 @@ async function writeSha256Sums(outDir) { if (entries.length === 0) { fail( - `No release checksum assets found in ${outDir}; refusing to write empty SHA256SUMS.`, + `No standalone archive files found in ${outDir}; refusing to write empty SHA256SUMS.`, ); } diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js index 2cd34e4a2d7..86615a24eaf 100644 --- a/scripts/release-asset-config.js +++ b/scripts/release-asset-config.js @@ -5,6 +5,8 @@ */ const STANDALONE_ARCHIVE_PREFIX = 'qwen-code-'; +// Keep this extension allowlist in sync with the standalone packager target +// output extensions and the release workflow upload globs. const STANDALONE_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip']; function isStandaloneArchiveName(fileName) { diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 353b807b484..61fbc513459 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -732,7 +732,8 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain('install-qwen.bat'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); - expect(workflow).toContain('dist/standalone/qwen-code-*'); + expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); + expect(workflow).toContain('dist/standalone/qwen-code-*.zip'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); }); From 1f1f5cd5f54771ee519472bd927d37b2f1f2d46f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 16:18:15 +0800 Subject: [PATCH 039/129] fix(installer): stabilize Windows managed install checks --- scripts/installation/install-qwen-with-source.bat | 3 +-- scripts/tests/install-script.test.js | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 3c7e2e6105a..60ffdc490c8 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -730,9 +730,8 @@ exit /b %PS_STATUS% :EnsureManagedInstallDir set "MANAGED_DIR=%~1" -if not exist "!MANAGED_DIR!" exit /b 0 set "QWEN_MANAGED_DIR=!MANAGED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" set "PS_STATUS=!ERRORLEVEL!" set "QWEN_MANAGED_DIR=" if !PS_STATUS! EQU 0 exit /b 0 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 61fbc513459..3d5d17e6a13 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1482,6 +1482,7 @@ function createWindowsTraversalStandaloneArchive(tmpDir) { scriptPath, [ "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.IO.Compression', 'Add-Type -AssemblyName System.IO.Compression.FileSystem', 'function Add-ZipEntry($zip, $name, $content) {', ' $entry = $zip.CreateEntry($name)', From 7d4fc1301c60098e0bd7453ca30533b84cac35b0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 16:36:53 +0800 Subject: [PATCH 040/129] test(installer): relax Windows installer timeout --- scripts/tests/install-script.test.js | 160 +++++++++++++++------------ 1 file changed, 90 insertions(+), 70 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 3d5d17e6a13..3a13996eb2e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -41,6 +41,7 @@ const releaseScriptUtilsUrl = pathToFileURL( // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; +const WINDOWS_INSTALLER_TEST_TIMEOUT = 15_000; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { @@ -1154,65 +1155,78 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }, + WINDOWS_INSTALLER_TEST_TIMEOUT, ); - itOnWindows('rejects a tampered local archive', () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows( + 'rejects a tampered local archive', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - appendFileSync(archive, 'tamper'); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + appendFileSync(archive, 'tamper'); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/Checksum verification failed/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + WINDOWS_INSTALLER_TEST_TIMEOUT, + ); - itOnWindows('rejects a local archive when SHA256SUMS is missing', () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows( + 'rejects a local archive when SHA256SUMS is missing', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/SHA256SUMS not found; cannot verify archive/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/SHA256SUMS not found; cannot verify archive/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + WINDOWS_INSTALLER_TEST_TIMEOUT, + ); - itOnWindows('rejects a local archive missing required entries', () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows( + 'rejects a local archive missing required entries', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir, { - includeNode: false, - }); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir, { + includeNode: false, + }); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/qwen-code\\node\\node.exe/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/qwen-code\\node\\node.exe/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + WINDOWS_INSTALLER_TEST_TIMEOUT, + ); itOnWindows( 'rejects standalone archives containing path traversal entries', @@ -1234,6 +1248,7 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }, + WINDOWS_INSTALLER_TEST_TIMEOUT, ); itOnWindows( @@ -1262,31 +1277,36 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }, + WINDOWS_INSTALLER_TEST_TIMEOUT, ); - itOnWindows('rejects unsafe environment-derived install paths', () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows( + 'rejects unsafe environment-derived install paths', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - const marker = path.join(tmpDir, 'pwned.txt'); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const marker = path.join(tmpDir, 'pwned.txt'); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - 'standalone', - { - QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, - }, - ), - ).toThrow(/unsafe command characters/); - expect(existsSync(marker)).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, + }, + ), + ).toThrow(/unsafe command characters/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + WINDOWS_INSTALLER_TEST_TIMEOUT, + ); }); // Tracks pending dist/ backups so a crashed test cannot leave the working tree From a244a2e6023a920dd23ad4937534bf62bdaab82a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 19:29:56 +0800 Subject: [PATCH 041/129] fix(test): escape release asset regex --- scripts/tests/install-script.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 806dc8825b0..7d5db62a566 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -765,7 +765,7 @@ describe('standalone release packaging', () => { ); await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( new RegExp( - `Checksum verification failed for ${EXPECTED_STANDALONE_ARCHIVE_NAMES[0].replace(/\./g, '\\.')}`, + `Checksum verification failed for ${escapeRegExp(EXPECTED_STANDALONE_ARCHIVE_NAMES[0])}`, ), ); } finally { @@ -2279,3 +2279,7 @@ function placeholderChecksumContent(archiveNames) { ) .join('\n')}\n`; } + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} From 9bde745247b2fd17a5d0facf529a0af965628d9e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 20:09:41 +0800 Subject: [PATCH 042/129] test(cli): avoid POSIX node path in relaunch test --- packages/cli/src/utils/relaunch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 1d137bced24..83349dbb9e9 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -111,7 +111,7 @@ describe('relaunchAppInChildProcess', () => { process.execArgv = [...originalExecArgv]; process.argv = [...originalArgv]; - process.execPath = '/usr/bin/node'; + process.execPath = originalExecPath; processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('PROCESS_EXIT_CALLED'); From 728b35fd41a4952c05c490586cc5f02715c4413f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 20:43:33 +0800 Subject: [PATCH 043/129] fix(installer): align npm fallback node gate with engines --- scripts/installation/INSTALLATION_GUIDE.md | 6 +++--- scripts/installation/install-qwen-with-source.bat | 12 ++++++------ scripts/installation/install-qwen-with-source.sh | 10 +++++----- scripts/tests/install-script.test.js | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 2a5c561785d..131f7af3a43 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -16,7 +16,7 @@ The installers are intentionally lightweight: `%USERPROFILE%\.qwen\source.json` when `--source` is provided. Standalone archives include a private Node.js runtime, so users do not need a -local Node.js installation on the standalone path. Node.js 20 or newer and npm +local Node.js installation on the standalone path. Node.js 22 or newer and npm are only required when the installer falls back to npm or when `--method npm` is used. @@ -248,7 +248,7 @@ unreadable source files are ignored. ## Manual Installation -If source tracking is not needed and Node.js 20 or newer is already available: +If source tracking is not needed and Node.js 22 or newer is already available: ```bash npm install -g @qwen-code/qwen-code@latest @@ -269,7 +269,7 @@ fails so that automation can detect the missing artifact. ### Node.js Missing or Too Old -This only blocks npm installation. Install or activate Node.js 20 or newer, then +This only blocks npm installation. Install or activate Node.js 22 or newer, then rerun the installer with `--method npm` or let `detect` fall back again. ### npm Missing diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 60ffdc490c8..5af4a53e62e 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -172,7 +172,7 @@ if !STANDALONE_STATUS! EQU 2 ( call :InstallNpm if !ERRORLEVEL! NEQ 0 ( echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. - echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. exit /b !ERRORLEVEL! ) call :PrintFinalInstructions "" @@ -745,7 +745,7 @@ where node >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( echo ERROR: Node.js was not found. echo. - echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Node.js 22 or newer is required before installing Qwen Code with npm. echo Please install Node.js from https://nodejs.org/ and rerun this installer. exit /b 1 ) @@ -753,7 +753,7 @@ if %ERRORLEVEL% NEQ 0 ( for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" if "%NODE_VERSION%"=="" ( echo ERROR: Unable to determine Node.js version. - echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Node.js 22 or newer is required before installing Qwen Code with npm. exit /b 1 ) @@ -761,12 +761,12 @@ for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( echo ERROR: Unable to determine Node.js version. - echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Node.js 22 or newer is required before installing Qwen Code with npm. exit /b 1 ) -if %NODE_MAJOR_NUM% LSS 20 ( - echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 20 or newer is required. +if %NODE_MAJOR_NUM% LSS 22 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. echo Please install Node.js from https://nodejs.org/ and rerun this installer. exit /b 1 ) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 3e338d2a447..ad758dca5a4 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -318,7 +318,7 @@ print_header() { print_node_help() { echo "" - echo "Node.js 20 or newer is required before installing Qwen Code with npm." + echo "Node.js 22 or newer is required before installing Qwen Code with npm." echo "" echo "Install Node.js, then rerun this installer:" case "$(uname -s 2>/dev/null || echo unknown)" in @@ -335,7 +335,7 @@ print_node_help() { ;; esac echo "" - echo "If you already use a Node version manager, activate Node.js 20+" + echo "If you already use a Node version manager, activate Node.js 22+" echo "in this shell before rerunning the installer." } @@ -357,8 +357,8 @@ require_node() { return 1 fi - if [[ "${node_major}" -lt 20 ]]; then - log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required." + if [[ "${node_major}" -lt 22 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 22 or newer is required." print_node_help return 1 fi @@ -977,7 +977,7 @@ main() { print_final_instructions "$(get_npm_global_bin)" 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 20+ and rerun --method npm." + log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm." exit 1 fi else diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 7d5db62a566..944da46042e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -70,7 +70,7 @@ describe('installation scripts', () => { expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', ); - expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Node.js 22 or newer is required'); expect(script).toContain( 'npm install -g @qwen-code/qwen-code@latest --registry', ); @@ -159,7 +159,7 @@ describe('installation scripts', () => { expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', ); - expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Node.js 22 or newer is required'); expect(script).toContain('Please install Node.js'); expect(script).toContain( 'npm install -g @qwen-code/qwen-code@latest --registry', @@ -1469,8 +1469,8 @@ describe('Linux/macOS installer end-to-end', () => { '#!/usr/bin/env sh', 'if [ "$1" = "-p" ]; then', ' case "$2" in', - ' *split*) echo 20 ;;', - ' *) echo 20.19.0 ;;', + ' *split*) echo 22 ;;', + ' *) echo 22.0.0 ;;', ' esac', ' exit 0', 'fi', From bb8b81043fcae081b63128d8787ae65e59efa243 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 20:59:17 +0800 Subject: [PATCH 044/129] test(installer): allow Windows archive validation more time --- scripts/tests/install-script.test.js | 67 +++++++++++++++------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 944da46042e..1bfb86aa3c1 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -47,6 +47,7 @@ const releaseScriptUtilsUrl = pathToFileURL( // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; +// Windows CI can spend several seconds inside PowerShell zip operations. const WINDOWS_INSTALLER_TEST_TIMEOUT = 15_000; describe('installation scripts', () => { @@ -923,39 +924,43 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); - it('rejects a runtime archive without a Node executable', () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + it( + 'rejects a runtime archive without a Node executable', + () => { + const restoreDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); - try { - const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; - const fakeRuntimeArchive = - process.platform === 'win32' - ? createBadWindowsNodeArchive(tmpDir) - : createBadUnixNodeArchive(tmpDir); + try { + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createBadWindowsNodeArchive(tmpDir) + : createBadUnixNodeArchive(tmpDir); - expect(() => - execFileSync( - 'node', - [ - 'scripts/create-standalone-package.js', - '--target', - target, - '--node-archive', - fakeRuntimeArchive, - '--out-dir', - path.join(tmpDir, 'out'), - '--version', - '0.0.0-test', - ], - { stdio: 'pipe' }, - ), - ).toThrow(/Node\.js runtime for .* must contain/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); - } - }); + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Node\.js runtime for .* must contain/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreDist(); + } + }, + WINDOWS_INSTALLER_TEST_TIMEOUT, + ); it('packages a win-x64 standalone archive', () => { const restoreDist = ensureMinimalDist(); From d2acf7f86f462cb74478cb4aff980ecd9bb47b85 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 21:22:09 +0800 Subject: [PATCH 045/129] fix(installer): remove stale node 20 installer references --- docs/users/overview.md | 2 +- .../get-release-version-python-sdk.test.js | 2 +- scripts/tests/install-script.test.js | 24 +++++++++---------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/users/overview.md b/docs/users/overview.md index a40753d7605..0159304df77 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -10,7 +10,7 @@ ### 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** diff --git a/scripts/tests/get-release-version-python-sdk.test.js b/scripts/tests/get-release-version-python-sdk.test.js index 9c7976bd585..b9d374664c9 100644 --- a/scripts/tests/get-release-version-python-sdk.test.js +++ b/scripts/tests/get-release-version-python-sdk.test.js @@ -52,7 +52,7 @@ function makeExecError(message, { stderr = '', stdout = '', status } = {}) { function makeTimeoutError(command) { const error = new Error(`Command failed: ${command}\nSIGTERM`); - // Real Node.js execSync timeout shape (verified on Node 20+): + // Real Node.js execSync timeout shape (verified on Node 22+): // killed=undefined, signal='SIGTERM', code='ETIMEDOUT' error.code = 'ETIMEDOUT'; error.signal = 'SIGTERM'; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 1bfb86aa3c1..5ddcc29ffed 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -66,7 +66,7 @@ describe('installation scripts', () => { expect(script).not.toContain('.npm-global'); expect(script).not.toMatch(/^\s*exec\s+qwen\s*$/m); expect(script).not.toContain('--print-env'); - expect(script).not.toContain('brew install node@20'); + expect(script).not.toMatch(/brew install node@\d+/); expect(script).toContain('brew install node'); expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', @@ -439,8 +439,8 @@ describe('standalone release packaging', () => { it('normalizes Node.js versions passed to the release helper', async () => { const { normalizeNodeVersion } = await import(standaloneReleaseScriptUrl); - expect(normalizeNodeVersion('v20.19.0')).toBe('20.19.0'); - expect(normalizeNodeVersion('20.19.0')).toBe('20.19.0'); + expect(normalizeNodeVersion('v22.0.0')).toBe('22.0.0'); + expect(normalizeNodeVersion('22.0.0')).toBe('22.0.0'); }); it('loads the hosted installation asset staging helper', () => { @@ -526,16 +526,14 @@ describe('standalone release packaging', () => { const checksums = parseChecksums( [ - 'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz', - 'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip', + 'a'.repeat(64) + ' node-v22.0.0-linux-x64.tar.xz', + 'b'.repeat(64) + ' *node-v22.0.0-win-x64.zip', '', ].join('\n'), ); - expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe( - 'a'.repeat(64), - ); - expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64)); + expect(checksums.get('node-v22.0.0-linux-x64.tar.xz')).toBe('a'.repeat(64)); + expect(checksums.get('node-v22.0.0-win-x64.zip')).toBe('b'.repeat(64)); }); it('validates standalone release checksum output', async () => { @@ -1850,7 +1848,7 @@ function ensureMinimalDist() { } function createFakeNodeArchive(tmpDir, options = {}) { - const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64'); + const fakeNodeDir = path.join(tmpDir, 'node-v22.0.0-linux-x64'); mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true }); writeFileSync( path.join(fakeNodeDir, 'bin', 'node'), @@ -1874,7 +1872,7 @@ function createFakeNodeArchive(tmpDir, options = {}) { symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle')); } - const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); + const archive = path.join(tmpDir, 'node-v22.0.0-linux-x64.tar.gz'); execFileSync( 'tar', ['-czf', archive, '-C', tmpDir, path.basename(fakeNodeDir)], @@ -1910,11 +1908,11 @@ function createBadWindowsNodeArchive(tmpDir) { } function createFakeWindowsNodeArchive(tmpDir) { - const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64'); + const fakeNodeDir = path.join(tmpDir, 'node-v22.0.0-win-x64'); mkdirSync(fakeNodeDir, { recursive: true }); writeFileSync(path.join(fakeNodeDir, 'node.exe'), 'fake node.exe\n'); - const archive = path.join(tmpDir, 'node-v20.0.0-win-x64.zip'); + const archive = path.join(tmpDir, 'node-v22.0.0-win-x64.zip'); createZipForTest(archive, tmpDir, path.basename(fakeNodeDir)); return archive; } From 725a06e0c47f565b372b9e4ec7b86025b4aa2fae Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 21:24:32 +0800 Subject: [PATCH 046/129] docs(installer): clarify hosted endpoint sync requirement --- docs/users/overview.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/users/overview.md b/docs/users/overview.md index 0159304df77..b2cbf4dc234 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -9,7 +9,8 @@ ### Install Qwen Code: -The recommended installer uses a standalone archive when one is available for +After the hosted installer endpoint is synced with the latest installer assets, +the recommended installer uses a standalone archive when one is available for your platform. If it falls back to npm, Node.js 22 or later with npm must be available on PATH. From a10bd1c0a8e8606fa96c011e9e4b6bd248159843 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 21:39:47 +0800 Subject: [PATCH 047/129] refactor(installer): reuse standaloneArchiveName in release verifier The verify-installation-release script was duplicating the archive name derivation logic with a hardcoded ternary instead of reusing the standaloneArchiveName helper from build-standalone-release. Export the helper and import it so the extension mapping lives in one place. --- scripts/build-standalone-release.js | 1 + scripts/verify-installation-release.js | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index bb8963658e8..f91837434c4 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -295,4 +295,5 @@ export { normalizeNodeVersion, parseChecksums, RELEASE_TARGETS, + standaloneArchiveName, }; diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 979cc8387a0..de02fa25458 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -9,7 +9,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { + RELEASE_TARGETS, + standaloneArchiveName, +} from './build-standalone-release.js'; import { isStandaloneArchiveName } from './release-asset-config.js'; import { fail, @@ -24,8 +27,7 @@ const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( - ({ qwenTarget }) => - `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + ({ qwenTarget }) => standaloneArchiveName(qwenTarget), ); // Release artifacts that the installer chain expects in a GitHub Release. // Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served From d0bd8c6639f8890c28370a191021d00b8c65a53f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 12 May 2026 00:27:23 +0800 Subject: [PATCH 048/129] fix(scripts): address release verifier review feedback --- scripts/build-standalone-release.js | 18 ++- scripts/create-standalone-package.js | 2 + .../installation/install-qwen-with-source.bat | 13 +- .../installation/install-qwen-with-source.sh | 2 + scripts/release-script-utils.js | 7 +- scripts/tests/install-script.test.js | 135 +++++++++++++----- scripts/verify-installation-release.js | 62 ++++++-- 7 files changed, 178 insertions(+), 61 deletions(-) diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index f91837434c4..60d84aed873 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -27,6 +27,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); +// RELEASE_TARGETS must stay in sync with TARGETS in create-standalone-package.js; +// every release qwenTarget should map to a package target and output extension. const RELEASE_TARGETS = [ { qwenTarget: 'darwin-arm64', @@ -98,7 +100,7 @@ async function main() { const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`; try { - fs.mkdirSync(outDir, { recursive: true }); + cleanOutputDirectory(outDir); const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt'); await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); @@ -186,6 +188,11 @@ function formatErrorReason(reason) { return String(reason); } +function cleanOutputDirectory(outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); +} + async function downloadFile(url, destination) { console.log(`Downloading ${url}`); const response = await fetch(url); @@ -227,12 +234,9 @@ function assertStandaloneOutput(outDir) { fail(`Standalone SHA256SUMS was not created at ${checksumPath}`); } - const archiveNames = fs - .readFileSync(checksumPath, 'utf8') - .split(/\r?\n/) - .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) - .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) - .filter(Boolean) + const archiveNames = Array.from( + parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')).keys(), + ) .filter(isStandaloneArchiveName) .sort(); const expectedArchiveNames = RELEASE_TARGETS.map(({ qwenTarget }) => diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index b7d1ce46535..8cef13304b5 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -24,6 +24,8 @@ const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); const distDir = path.join(rootDir, 'dist'); +// TARGETS must stay in sync with RELEASE_TARGETS in build-standalone-release.js; +// every release target should have a package target and output extension here. const TARGETS = new Map([ [ 'darwin-arm64', diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 5af4a53e62e..cdc93127c14 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -699,10 +699,19 @@ 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'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); try { foreach ($entry in $archive.Entries) { $name = $entry.FullName -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { Write-Error ('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } finally { $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); foreach ($entry in $archive.Entries) { $name = $entry.FullName -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% NEQ 0 echo ERROR: Archive contains unsafe path entries. +if %PS_STATUS% EQU 0 exit /b 0 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. exit /b %PS_STATUS% :RemoveInstalledDirWithWarning diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index ad758dca5a4..b392e4bec7b 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -722,6 +722,8 @@ is_qwen_standalone_install_dir() { local manifest_path="${install_dir}/manifest.json" [[ -f "${manifest_path}" ]] || return 1 + # Manifest format is produced by writeManifest in create-standalone-package.js. + # Keep these grep checks in sync if that JSON layout changes. grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index 4aca1312606..bf7d1027bd3 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -73,16 +73,17 @@ function parseCliArgs(argv, options, defaults = {}) { function parseSha256Sums(content) { const checksums = new Map(); - for (const line of content.split(/\r?\n/)) { + for (const [index, line] of content.split(/\r?\n/).entries()) { const trimmed = line.trim(); if (!trimmed) { continue; } const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (match) { - checksums.set(match[2], match[1].toLowerCase()); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); } + checksums.set(match[2], match[1].toLowerCase()); } return checksums; } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 5ddcc29ffed..9fef543fccc 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -121,6 +121,7 @@ describe('installation scripts', () => { expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); expect(script).toContain('is_qwen_standalone_install_dir()'); + expect(script).toContain('Manifest format is produced by writeManifest'); expect(script).toContain( '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"', ); @@ -190,6 +191,11 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code-win-x64.zip'); expect(script).toContain(':ValidateArchiveContents'); expect(script).toContain('Archive contains unsafe path entries'); + expect(script).toContain( + 'Archive could not be inspected before extraction', + ); + expect(script).toContain('if %PS_STATUS% EQU 1'); + expect(script).toContain('if %PS_STATUS% EQU 2'); expect(script).toContain('System.IO.Compression.FileSystem'); expect(script).toContain('[IO.Compression.ZipFile]::OpenRead'); expect(script).toContain('[IO.Path]::GetRandomFileName()'); @@ -287,6 +293,7 @@ describe('standalone release packaging', () => { ); expect(packageScript).toContain('validateNodeRuntime'); expect(packageScript).toContain('copyNodeRuntimeEntry'); + expect(packageScript).toContain('TARGETS must stay in sync with'); expect(packageScript).toContain('symlink cycle'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('--skip-checksums'); @@ -320,6 +327,10 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('expectedArchiveNames'); expect(releaseScript).toContain('standaloneArchiveName(qwenTarget)'); expect(releaseScript).toContain('TARGETS.get(qwenTarget)'); + expect(releaseScript).toContain( + 'RELEASE_TARGETS must stay in sync with TARGETS', + ); + expect(releaseScript).toContain('cleanOutputDirectory(outDir)'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); @@ -536,6 +547,19 @@ describe('standalone release packaging', () => { expect(checksums.get('node-v22.0.0-win-x64.zip')).toBe('b'.repeat(64)); }); + it('rejects malformed SHA256SUMS entries', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + + expect(() => + parseSha256Sums( + [ + `${'a'.repeat(64)} qwen-code-linux-x64.tar.gz`, + `${'b'.repeat(63)} qwen-code-win-x64.zip`, + ].join('\n'), + ), + ).toThrow(/Malformed SHA256SUMS line 2/); + }); + it('validates standalone release checksum output', async () => { const { assertStandaloneOutput, RELEASE_TARGETS } = await import( standaloneReleaseScriptUrl @@ -546,7 +570,7 @@ describe('standalone release packaging', () => { try { const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { const extension = TARGETS.get(qwenTarget).outputExtension; - return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; + return `${'A'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); @@ -798,6 +822,23 @@ describe('standalone release packaging', () => { await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( /Unexpected release asset: qwen-code-stale\.tar\.gz/, ); + rmSync(path.join(tmpDir, 'qwen-code-stale.tar.gz'), { force: true }); + + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeFileSync(path.join(tmpDir, 'payload.bin'), 'unexpected'); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected release asset: payload\.bin/, + ); + rmSync(path.join(tmpDir, 'payload.bin'), { force: true }); + + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + appendFileSync( + path.join(tmpDir, 'SHA256SUMS'), + `${'c'.repeat(64)} payload.bin\n`, + ); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected release asset checksum: payload\.bin/, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -825,18 +866,26 @@ describe('standalone release packaging', () => { EXPECTED_STANDALONE_ARCHIVE_NAMES, ); const fetchedUrls = []; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url, options = {}) => { - fetchedUrls.push([url, options.method || 'GET']); - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - return new Response(null, { status: 200 }); - }, - }), - ).resolves.not.toThrow(); + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + fetchedUrls.push([url, options.method || 'GET']); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 200 }); + }, + }), + ).resolves.not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('checks URL reachability only'), + ); + } finally { + warnSpy.mockRestore(); + } expect(fetchedUrls).toContainEqual([ 'https://example.com/qwen-code/v0.0.0/SHA256SUMS', @@ -863,23 +912,28 @@ describe('standalone release packaging', () => { EXPECTED_STANDALONE_ARCHIVE_NAMES, ); const observedMethods = []; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url, options = {}) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - const method = options.method || 'GET'; - observedMethods.push(method); - if (method === 'HEAD') { - return new Response(null, { status: 405 }); - } - // Ranged GET fallback succeeds. - return new Response(null, { status: 206 }); - }, - }), - ).resolves.not.toThrow(); + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const method = options.method || 'GET'; + observedMethods.push(method); + if (method === 'HEAD') { + return new Response(null, { status: 405 }); + } + // Ranged GET fallback succeeds. + return new Response(null, { status: 206 }); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } expect(observedMethods).toContain('HEAD'); expect(observedMethods).toContain('GET'); @@ -891,17 +945,22 @@ describe('standalone release packaging', () => { const checksumContent = placeholderChecksumContent( EXPECTED_STANDALONE_ARCHIVE_NAMES, ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - return new Response(null, { status: 404 }); - }, - }), - ).rejects.toThrow(/Release asset URL is not available/); + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 404 }); + }, + }), + ).rejects.toThrow(/All 5 release asset URLs are unavailable/); + } finally { + warnSpy.mockRestore(); + } }); it('rejects a release base URL that is not https', async () => { diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index de02fa25458..3c5f3d51024 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -13,7 +13,6 @@ import { RELEASE_TARGETS, standaloneArchiveName, } from './build-standalone-release.js'; -import { isStandaloneArchiveName } from './release-asset-config.js'; import { fail, isMainModule, @@ -26,6 +25,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); +// This import-time computation intentionally asserts release/package target +// consistency via standaloneArchiveName(); keep RELEASE_TARGETS backed by TARGETS. const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( ({ qwenTarget }) => standaloneArchiveName(qwenTarget), ); @@ -120,13 +121,11 @@ async function verifyReleaseBaseUrl(baseUrl, options = {}) { const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); assertExpectedChecksumEntries(checksums); + console.warn( + 'WARNING: Remote release verification checks URL reachability only; it does not download archive bodies or verify archive hashes. Run --dir against downloaded assets for checksum verification.', + ); - for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { - await assertRemoteAssetAvailable( - new URL(assetName, normalizedBaseUrl).toString(), - fetchImpl, - ); - } + await assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl); console.log( `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release asset URLs at ${baseUrl}`, @@ -148,8 +147,7 @@ function assertExpectedChecksumEntries(checksums) { (assetName) => !checksums.has(assetName), ); const extra = Array.from(checksums.keys()).filter( - (assetName) => - isStandaloneArchiveName(assetName) && !expected.has(assetName), + (assetName) => !expected.has(assetName), ); if (missing.length > 0) { @@ -161,10 +159,9 @@ function assertExpectedChecksumEntries(checksums) { } function assertExpectedArchiveFiles(dir) { - const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES); + const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES); const extra = fs .readdirSync(dir) - .filter(isStandaloneArchiveName) .filter((assetName) => !expected.has(assetName)) .sort(); @@ -173,6 +170,42 @@ function assertExpectedArchiveFiles(dir) { } } +async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { + const results = await Promise.allSettled( + EXPECTED_STANDALONE_ARCHIVE_NAMES.map(async (assetName) => { + await assertRemoteAssetAvailable( + new URL(assetName, normalizedBaseUrl).toString(), + fetchImpl, + ); + return assetName; + }), + ); + const failures = results.flatMap((result, index) => + result.status === 'rejected' + ? [ + { + assetName: EXPECTED_STANDALONE_ARCHIVE_NAMES[index], + reason: formatErrorReason(result.reason), + }, + ] + : [], + ); + + if (failures.length === 0) { + return; + } + if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) { + fail( + `All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`, + ); + } + fail( + `Unavailable release asset URL(s): ${failures + .map(({ assetName, reason }) => `${assetName} (${reason})`) + .join('; ')}`, + ); +} + async function assertRemoteAssetAvailable(url, fetchImpl) { let response = await fetchImpl(url, { method: 'HEAD' }); if (response.ok) { @@ -195,6 +228,13 @@ async function assertRemoteAssetAvailable(url, fetchImpl) { await response.body?.cancel?.(); } +function formatErrorReason(reason) { + if (reason instanceof Error) { + return reason.message.replace(/^ERROR:\s*/, ''); + } + return String(reason); +} + async function fetchText(url, fetchImpl) { const response = await fetchImpl(url); if (!response.ok) { From 926c10a604283cc0e2bbf5c11c18977927fb13cb Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 00:29:22 +0800 Subject: [PATCH 049/129] feat(installer): add standalone archive installer with multi-platform release workflow - Add standalone archive installer (bat/sh) that downloads platform binaries from GitHub/Aliyun without requiring Node.js or npm on the target machine - Add fork-friendly release-test workflow for manual GitHub Release creation covering all 5 platforms (darwin-arm64/x64, linux-arm64/x64, win-x64) - Add OSS upload/mirror tools for staging and release distribution - Update .gitignore to exclude generated build artifacts (release-staging/, hosted-staging/) - Fix Windows PowerShell test command in copy-release-to-latest tool --- .github/workflows/release-test.yml | 63 ++++ .gitignore | 7 +- scripts/create-standalone-package.js | 43 ++- .../installation/install-qwen-with-source.bat | 218 +++++++++++--- .../installation/install-qwen-with-source.sh | 282 ++++++++++++++++-- scripts/tests/install-script.test.js | 9 +- tools/.env.example | 15 + tools/.gitignore | 2 + tools/copy-release-to-latest.mjs | 138 +++++++++ tools/test-upload-one.mjs | 149 +++++++++ tools/upload-release-mirror.mjs | 189 ++++++++++++ tools/upload-staging.mjs | 192 ++++++++++++ 12 files changed, 1244 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/release-test.yml create mode 100644 tools/.env.example create mode 100644 tools/.gitignore create mode 100644 tools/copy-release-to-latest.mjs create mode 100644 tools/test-upload-one.mjs create mode 100644 tools/upload-release-mirror.mjs create mode 100644 tools/upload-staging.mjs diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml new file mode 100644 index 00000000000..e8674a2da93 --- /dev/null +++ b/.github/workflows/release-test.yml @@ -0,0 +1,63 @@ +name: 'Release Test (Fork)' + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version, e.g. v0.0.0-pr3828-test.1' + required: true + type: 'string' + ref: + description: 'Branch or commit SHA to build from' + required: true + type: 'string' + default: 'main' + +jobs: + build_and_release: + name: 'Build Standalone Archives and Release' + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + with: + ref: '${{ github.event.inputs.ref }}' + + - name: 'Setup Node.js' + uses: 'actions/setup-node@v4' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build bundle' + run: 'npm run build && npm run bundle' + + - name: 'Build Standalone Archives (all platforms)' + env: + RELEASE_VERSION: '${{ github.event.inputs.version }}' + run: | + VERSION="${RELEASE_VERSION#v}" + npm run package:standalone:release -- --version "${VERSION}" --out-dir dist/standalone + + - name: 'Verify release assets' + run: 'npm run verify:installation-release -- --dir dist/standalone' + + - name: 'Create GitHub Release' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ github.event.inputs.version }}' + run: | + gh release create "${RELEASE_TAG}" \ + dist/standalone/qwen-code-*.tar.gz \ + dist/standalone/qwen-code-*.zip \ + dist/standalone/SHA256SUMS \ + --target '${{ github.event.inputs.ref }}' \ + --title "Test Release ${RELEASE_TAG}" \ + --notes "Installer test build for PR #3828. Not for production use." \ + --prerelease diff --git a/.gitignore b/.gitignore index 6110defc8a9..c6edf4256d2 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,9 @@ tmp/ # code graph skills .venv -.codegraph \ No newline at end of file +.codegraph +# Standalone release build artifacts (generated by scripts/build-standalone-release.js) +release-staging/ + +# Hosted installation assets staging (generated by scripts/build-hosted-installation-assets.js) +hosted-staging/ diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 8cef13304b5..e600cffaa6f 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -491,7 +491,48 @@ function createArchive(outputExtension, outputPath, cwd) { return; } - run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); + // On macOS Sequoia+, every file inherits an immovable `com.apple.provenance` + // xattr that bsdtar embeds into pax extended headers. Linux GNU tar then + // emits one `Ignoring unknown extended header keyword` warning per file at + // extract time. bsdtar's `--no-mac-metadata` is silently ignored in older + // libarchive (3.5.x), and `xattr -d com.apple.provenance` is rejected by + // SIP. The reliable fix is to use GNU tar, which does not write xattrs + // unless `--xattrs` is passed. + const tarBin = pickTarBinary(); + run(tarBin, ['-czf', outputPath, '-C', cwd, 'qwen-code']); +} + +function pickTarBinary() { + if (process.platform !== 'darwin') return 'tar'; + // Try common gtar paths (homebrew arm/intel + gnubin shim). + const candidates = [ + '/opt/homebrew/bin/gtar', + '/usr/local/bin/gtar', + '/opt/homebrew/opt/gnu-tar/libexec/gnubin/tar', + ]; + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + // continue + } + } + // PATH lookup via /bin/sh -c "command -v gtar". + try { + const out = execFileSync('/bin/sh', ['-c', 'command -v gtar'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim(); + if (out) return out; + } catch { + // not found + } + console.warn( + 'WARNING: GNU tar (gtar) not found on macOS. Falling back to bsdtar; ' + + 'archives will include com.apple.provenance pax headers that emit ' + + 'noisy warnings on Linux extract. Install with: brew install gnu-tar', + ); + return 'tar'; } function createZipArchive(outputPath, cwd) { diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index cdc93127c14..6584591da71 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -8,8 +8,10 @@ setlocal enabledelayedexpansion set "SOURCE=unknown" set "METHOD=" if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" -set "MIRROR=github" +set "MIRROR=auto" if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "NO_MODIFY_PATH=0" +if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" set "BASE_URL=" if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" set "ARCHIVE_PATH=" @@ -112,6 +114,11 @@ if /i "%~1"=="--registry" ( shift goto parse_args ) +if /i "%~1"=="--no-modify-path" ( + set "NO_MODIFY_PATH=1" + shift + goto parse_args +) if /i "%~1"=="-h" goto usage if /i "%~1"=="--help" goto usage @@ -142,6 +149,35 @@ if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! echo. +REM Discover all qwen executables on disk BEFORE we install. We can't +REM reliably simulate the user's PATH ordering, so enumerate well-known +REM per-tool bin directories plus everything `where qwen` returns. +set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" +del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for %%c in ( + "!USERPROFILE!\.opencode\bin\qwen.cmd" + "!APPDATA!\npm\qwen.cmd" + "!USERPROFILE!\.bun\bin\qwen.cmd" + "!LOCALAPPDATA!\bun\bin\qwen.cmd" + "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" +) +set "PRE_INSTALL_QWENS_LIST=" +if exist "!PRE_INSTALL_QWENS_FILE!" ( + for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( + if "!PRE_INSTALL_QWENS_LIST!"=="" ( + set "PRE_INSTALL_QWENS_LIST=%%i" + ) else ( + echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 + if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" + ) + ) + del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +) + REM Dispatch after validation; detect falls back to npm only when unavailable. if /i "!METHOD!"=="standalone" ( call :InstallStandalone @@ -200,12 +236,16 @@ 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: github or aliyun. +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. exit /b 0 @@ -279,7 +319,8 @@ exit /b 1 :validate_method_ok if /i "!MIRROR!"=="github" goto validate_mirror_ok if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok -echo ERROR: --mirror must be github or aliyun. +if /i "!MIRROR!"=="auto" goto validate_mirror_ok +echo ERROR: --mirror must be auto, github, or aliyun. exit /b 1 :validate_mirror_ok @@ -341,6 +382,36 @@ if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 set "VERSION_PATH=v!VERSION_PATH!" exit /b 0 +:GithubBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_GH_BASE_URL +if /i "%~1"=="latest" ( + set "QWEN_GH_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" +) else ( + set "QWEN_GH_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/%~1" +) +exit /b 0 + +:AliyunBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_OSS_BASE_URL +set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" +exit /b 0 + +:RaceMirrorHead +rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url +rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH +rem fallback) keeps the PowerShell snippet small; a true parallel race adds a +rem lot of escaping for marginal speedup since OSS HEAD is sub-second when +rem reachable. Default fallback: github. +set "QWEN_RACE_TIMEOUT=%~1" +set "QWEN_RACE_GH_URL=%~2" +set "QWEN_RACE_OSS_URL=%~3" +set "QWEN_RACE_RESULT=github" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_TIMEOUT=" +set "QWEN_RACE_GH_URL=" +set "QWEN_RACE_OSS_URL=" +exit /b 0 + :StandaloneBaseUrl if not "!BASE_URL!"=="" ( set "STANDALONE_BASE_URL=!BASE_URL!" @@ -348,19 +419,43 @@ if not "!BASE_URL!"=="" ( ) call :ReleaseVersionPath -if /i "!MIRROR!"=="aliyun" ( - set "STANDALONE_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/!VERSION_PATH!" - exit /b 0 + +if /i "!MIRROR!"=="auto" ( + call :GithubBaseUrlForVersion "!VERSION_PATH!" + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" + set "MIRROR=!QWEN_RACE_RESULT!" + echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! + set "QWEN_GH_BASE_URL=" + set "QWEN_OSS_BASE_URL=" + set "QWEN_RACE_RESULT=" ) -if /i "!VERSION_PATH!"=="latest" ( - set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" +if /i "!MIRROR!"=="aliyun" ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" + set "QWEN_OSS_BASE_URL=" exit /b 0 ) -set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/!VERSION_PATH!" +call :GithubBaseUrlForVersion "!VERSION_PATH!" +set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" +set "QWEN_GH_BASE_URL=" exit /b 0 +:MaybeUpdateUserPath +rem args: %~1=install_bin_dir +rem Prepend the install dir to the user-level PATH (HKCU\Environment) via +rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is +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.'" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_NEW_BIN=" +exit /b %PS_STATUS% + :UrlExists set "QWEN_CHECK_URL=%~1" rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. @@ -372,8 +467,10 @@ exit /b %PS_STATUS% :DownloadFile set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" +rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). +rem ProgressPreference defaults to 'Continue' which renders a bar in interactive +rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -842,7 +939,12 @@ exit /b 0 :PrintFinalInstructions set "EXTRA_BIN=%~1" -if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" + +set "INSTALLED_BIN=" +if not "!EXTRA_BIN!"=="" ( + set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" + set "PATH=!EXTRA_BIN!;!PATH!" +) echo. echo =========================================== @@ -850,35 +952,83 @@ echo Installation completed! echo =========================================== echo. -where qwen >nul 2>&1 -if %ERRORLEVEL% EQU 0 ( - for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" - echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! +set "INSTALLED_VERSION=unknown" +if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( + for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" +) + +if not "!INSTALLED_BIN!"=="" ( + echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! +) else ( + echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! +) + +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!" + ) + ) + ) +) + +if defined OTHER_QWENS ( echo. - echo You can now run: qwen + 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 INFO: Run qwen in your project directory to start an interactive session. + if /i "!NO_MODIFY_PATH!"=="1" ( + echo Skipped user PATH update because --no-modify-path is set. + echo To make this install win, add this to your user PATH manually: + echo !EXTRA_BIN! + ) else ( + call :MaybeUpdateUserPath "!EXTRA_BIN!" + echo. + echo If you prefer not to modify user PATH, rerun with --no-modify-path + echo and pick one of: + echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) + echo - invoke directly: "!INSTALLED_BIN!" + ) exit /b 0 ) -echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. -echo. -echo Restart your command prompt, then run: qwen -if not "!EXTRA_BIN!"=="" ( +where qwen >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. echo. - echo Or add this directory to PATH: - echo !EXTRA_BIN! - echo Then run: - echo qwen + echo Restart your command prompt, then run: qwen + if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 + ) + + for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" + if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen + ) exit /b 0 ) -for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" -if not "!NPM_PREFIX!"=="" ( - echo. - echo Or add this npm global directory to PATH: - echo !NPM_PREFIX! - echo Then run: - echo qwen -) +echo. +echo You can now run: qwen +echo. +echo INFO: Run qwen in your project directory to start an interactive session. exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index b392e4bec7b..0682d7e5a6c 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -85,13 +85,16 @@ Options: -s, --source SOURCE Record the installation source. --method METHOD Install method: detect, standalone, or npm. Defaults to QWEN_INSTALL_METHOD or detect. - --mirror MIRROR Standalone archive mirror: github or aliyun. - Defaults to QWEN_INSTALL_MIRROR or github. + --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. + Defaults to QWEN_INSTALL_MIRROR or auto, which picks + whichever responds first via a HEAD probe. --base-url URL Override standalone archive base URL. --archive PATH Install from a local standalone archive. --version VERSION Standalone release version. Defaults to latest. --registry REGISTRY npm registry to use for npm fallback. Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com + --no-modify-path Do not append PATH to the user's shell rc file even + when a shadowing 'qwen' is detected. -h, --help Show this help message. Examples: @@ -104,10 +107,11 @@ EOF SOURCE="unknown" METHOD="${QWEN_INSTALL_METHOD:-}" -MIRROR="${QWEN_INSTALL_MIRROR:-github}" +MIRROR="${QWEN_INSTALL_MIRROR:-auto}" BASE_URL="${QWEN_INSTALL_BASE_URL:-}" ARCHIVE_PATH="${QWEN_INSTALL_ARCHIVE:-}" VERSION="${QWEN_INSTALL_VERSION:-latest}" +NO_MODIFY_PATH="${QWEN_NO_MODIFY_PATH:-0}" NPM_REGISTRY="${QWEN_NPM_REGISTRY:-https://registry.npmmirror.com}" INSTALL_ROOT="${QWEN_INSTALL_ROOT:-${HOME:-}/.local}" if [[ -n "${QWEN_INSTALL_LIB_DIR:-}" ]]; then @@ -196,10 +200,10 @@ validate_options() { esac case "${MIRROR}" in - github|aliyun) + auto|github|aliyun) ;; *) - log_error "--mirror must be github or aliyun." + log_error "--mirror must be auto, github, or aliyun." exit 1 ;; esac @@ -273,6 +277,10 @@ while [[ $# -gt 0 ]]; do NPM_REGISTRY="$2" shift 2 ;; + --no-modify-path) + NO_MODIFY_PATH=1 + shift + ;; -h|--help) print_usage exit 0 @@ -478,6 +486,109 @@ release_version_path() { esac } +# When a shadowing 'qwen' is detected, append a PATH prepend to the user's +# shell rc file at the very end. Putting it at the END means our prepend runs +# AFTER any earlier PATH munging in the rc file (e.g., other tools' shell +# init), so our installed_bin wins. Idempotent via a marker comment. +maybe_update_shell_path() { + local install_bin_dir="$1" + + [[ "${NO_MODIFY_PATH:-0}" == "1" ]] && return 0 + [[ -z "${install_bin_dir}" ]] && return 0 + [[ -z "${HOME:-}" ]] && return 0 + + local rc_file="" + case "${SHELL:-}" in + */zsh) rc_file="${HOME}/.zshrc" ;; + */bash) + if [[ -f "${HOME}/.bashrc" ]]; then + rc_file="${HOME}/.bashrc" + elif [[ -f "${HOME}/.bash_profile" ]]; then + rc_file="${HOME}/.bash_profile" + else + rc_file="${HOME}/.bashrc" + fi + ;; + */fish) rc_file="${HOME}/.config/fish/config.fish" ;; + *) rc_file="${HOME}/.profile" ;; + esac + + [[ -z "${rc_file}" ]] && return 0 + + local marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local export_line + if [[ "${rc_file}" == *config.fish ]]; then + export_line="set -gx PATH ${install_bin_dir} \$PATH" + else + export_line="export PATH=\"${install_bin_dir}:\$PATH\"" + fi + + if [[ -f "${rc_file}" ]] && grep -qF "${marker}" "${rc_file}" 2>/dev/null; then + log_info "PATH update already present in ${rc_file} (skipping)." + return 0 + fi + + mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true + { + echo "" + echo "${marker}" + echo "${export_line}" + } >> "${rc_file}" || { + log_warning "Could not write PATH update to ${rc_file}." + return 0 + } + + log_success "Appended PATH prepend to ${rc_file}" + log_info "Open a new terminal, or run: source ${rc_file}" +} + +github_base_url_for_version() { + local version_path="$1" + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/QwenLM/qwen-code/releases/latest/download" + else + echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" + fi +} + +aliyun_base_url_for_version() { + local version_path="$1" + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" +} + +# Race two HEAD probes; print "aliyun" or "github" based on which mirror's +# SHA256SUMS responds first. Default to github if both time out. +race_mirror_head() { + local timeout="${1:-2}" + local gh_url="$2" + local oss_url="$3" + local tmpdir + tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" + mkdir -p "${tmpdir}" 2>/dev/null || true + + (curl -fsI -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & + local oss_pid=$! + (curl -fsI -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & + local gh_pid=$! + + local winner="" + local elapsed=0 + local max=$((timeout * 10 + 5)) + while [[ -z "${winner}" && "${elapsed}" -lt "${max}" ]]; do + # Probe OSS first to break ties in favor of the closer mirror for CN users. + [[ -e "${tmpdir}/aliyun" ]] && winner="aliyun" && break + [[ -e "${tmpdir}/github" ]] && winner="github" && break + sleep 0.1 + elapsed=$((elapsed + 1)) + done + + kill "${oss_pid}" "${gh_pid}" 2>/dev/null || true + wait "${oss_pid}" "${gh_pid}" 2>/dev/null || true + rm -rf "${tmpdir}" 2>/dev/null || true + + echo "${winner:-github}" +} + standalone_base_url() { if [[ -n "${BASE_URL}" ]]; then echo "${BASE_URL%/}" @@ -487,30 +598,50 @@ standalone_base_url() { local version_path version_path=$(release_version_path) - if [[ "${MIRROR}" == "aliyun" ]]; then - echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" - return 0 + if [[ "${MIRROR}" == "auto" ]]; then + local gh_head oss_head selected + gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" + oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" + selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") + log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 + MIRROR="${selected}" fi - if [[ "${version_path}" == "latest" ]]; then - echo "https://github.com/QwenLM/qwen-code/releases/latest/download" + if [[ "${MIRROR}" == "aliyun" ]]; then + aliyun_base_url_for_version "${version_path}" return 0 fi - echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" + github_base_url_for_version "${version_path}" } download_file() { local url="$1" local destination="$2" + # Show progress only when stderr is a terminal (so CI / non-tty stays clean). + # `curl ... | bash` keeps stderr connected to the user's terminal even though + # stdin is a pipe, so progress shows correctly during normal usage. + local show_progress=0 + if [ -t 2 ] && [ "${QWEN_INSTALL_QUIET:-0}" != "1" ]; then + show_progress=1 + fi + if command_exists curl; then - curl -fsSL --retry 2 "${url}" -o "${destination}" + if [ "${show_progress}" = "1" ]; then + curl -fL --progress-bar --retry 2 "${url}" -o "${destination}" + else + curl -fsSL --retry 2 "${url}" -o "${destination}" + fi return $? fi if command_exists wget; then - wget -q --tries=3 "${url}" -O "${destination}" || return 1 + if [ "${show_progress}" = "1" ]; then + wget --show-progress --tries=3 "${url}" -O "${destination}" || return 1 + else + wget -q --tries=3 "${url}" -O "${destination}" || return 1 + fi return $? fi @@ -918,6 +1049,31 @@ install_npm() { print_final_instructions() { local install_bin_dir="${1:-}" + local installed_bin="" + if [[ -n "${install_bin_dir}" ]]; then + installed_bin="${install_bin_dir}/qwen" + 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. + local other_qwens="" + if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then + local saved_ifs="${IFS}" + IFS=$'\n' + local path + for path in ${PRE_INSTALL_QWENS}; do + [[ -z "${path}" ]] && continue + [[ -n "${installed_bin}" && "${path}" == "${installed_bin}" ]] && continue + if [[ -z "${other_qwens}" ]]; then + other_qwens="${path}" + else + other_qwens="${other_qwens}"$'\n'"${path}" + fi + done + IFS="${saved_ifs}" + fi + if [[ -n "${install_bin_dir}" ]]; then export PATH="${install_bin_dir}:${PATH}" fi @@ -928,26 +1084,64 @@ print_final_instructions() { echo "==========================================" echo "" - if command_exists qwen; then - local qwen_version - qwen_version=$(qwen --version 2>/dev/null || echo "unknown") - log_success "Qwen Code is ready to use: ${qwen_version}" + local installed_version="unknown" + if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then + installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") + elif command_exists qwen; then + installed_version=$(qwen --version 2>/dev/null || echo "unknown") + fi + + if [[ -n "${installed_bin}" ]]; then + log_success "Installed at ${installed_bin}: ${installed_version}" + else + log_success "Qwen Code installed: ${installed_version}" + fi + + if [[ -n "${other_qwens}" ]]; then echo "" - echo "You can now run: qwen" + 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 "" - log_info "Run qwen in your project directory to start an interactive session." + if [[ "${NO_MODIFY_PATH:-0}" == "1" ]]; then + echo "Skipped shell rc update because --no-modify-path is set." + echo "To make this install win, manually add to your shell rc:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + else + maybe_update_shell_path "${install_bin_dir}" + echo "" + echo "If you prefer not to modify the shell rc, rerun with --no-modify-path" + echo "and pick one of:" + echo " - npm uninstall -g @qwen-code/qwen-code # if the shadow is an npm install" + echo " - invoke directly: ${installed_bin}" + fi return 0 fi - log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." - echo "" - echo "Restart your terminal, then run: qwen" - if [[ -n "${install_bin_dir}" ]]; then + if ! command_exists qwen; then + log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." echo "" - echo "Or run this in the current shell:" - echo " export PATH=\"${install_bin_dir}:\$PATH\"" - echo " qwen" + echo "Restart your terminal, then run: qwen" + if [[ -n "${install_bin_dir}" ]]; then + echo "" + echo "Or run this in the current shell:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " qwen" + fi + return 0 fi + + echo "" + echo "You can now run: qwen" + echo "" + log_info "Run qwen in your project directory to start an interactive session." } main() { @@ -956,6 +1150,42 @@ main() { exit 1 fi + # Discover all qwen executables on disk BEFORE we install, so the + # just-installed binary doesn't pollute the search. We can't reliably + # simulate the user's interactive shell PATH (some tools inject their + # bin only under a tty), so we enumerate well-known per-tool bin + # directories plus whatever bash inherited on PATH. + PRE_INSTALL_QWENS=$( + { + IFS=: + for dir in $PATH; do + [[ -z "${dir}" ]] && continue + [[ -x "${dir}/qwen" ]] && echo "${dir}/qwen" + done + for candidate in \ + "${HOME}/.opencode/bin/qwen" \ + "${HOME}/.bun/bin/qwen" \ + "${HOME}/.cargo/bin/qwen" \ + "${HOME}/.deno/bin/qwen" \ + "${HOME}/.volta/bin/qwen" \ + "${HOME}/.fnm/bin/qwen" \ + "${HOME}/.local/bin/qwen" \ + "${HOME}/Library/pnpm/qwen" \ + "/usr/local/bin/qwen" \ + "/opt/homebrew/bin/qwen"; do + [[ -x "${candidate}" ]] && echo "${candidate}" + done + if command_exists npm; then + local npm_prefix + npm_prefix=$(npm prefix -g 2>/dev/null || true) + if [[ -n "${npm_prefix}" && -x "${npm_prefix}/bin/qwen" ]]; then + echo "${npm_prefix}/bin/qwen" + fi + fi + } 2>/dev/null | sort -u + ) + export PRE_INSTALL_QWENS + print_header case "${METHOD}" in diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9fef543fccc..844d2f8cfc7 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -151,7 +151,14 @@ describe('installation scripts', () => { expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); - expect(script).not.toContain('Invoke-WebRequest'); + // Invoke-WebRequest is now used in :DownloadFile so the user sees a + // progress bar while the standalone tarball downloads. Net.WebClient is + // silent and was previously preferred, but the UX hit (the user thinks + // the install is hung on slow GitHub release CDN) outweighed the small + // PS5 startup overhead. The :UrlExists / :RaceMirrorHead helpers still + // use Net.WebRequest for HEAD probes since those are sub-second and + // benefit from the leaner cold-start path. + expect(script).toContain('Invoke-WebRequest'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); diff --git a/tools/.env.example b/tools/.env.example new file mode 100644 index 00000000000..6749c446f98 --- /dev/null +++ b/tools/.env.example @@ -0,0 +1,15 @@ +# Copy to .env and fill in your STAGING (NOT production) OSS credentials. +# .env is gitignored. + +# Aliyun RAM AccessKey scoped to the staging bucket only +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= + +# Bucket name. MUST NOT be the production bucket "qwen-code-assets". +OSS_BUCKET= + +# Region endpoint, e.g. oss-cn-hangzhou.aliyuncs.com +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com + +# Object key prefix. Default: installation +# OSS_PREFIX=installation diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 00000000000..63f6705b8de --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1,2 @@ +.env +*.bak diff --git a/tools/copy-release-to-latest.mjs b/tools/copy-release-to-latest.mjs new file mode 100644 index 00000000000..b8f58905b6c --- /dev/null +++ b/tools/copy-release-to-latest.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Server-side OSS Copy Object: /releases/qwen-code/v0.0.0-pr3828-test/* +// -> /releases/qwen-code/latest/* +// No re-upload, just metadata copy. Lets the default `--version latest` flow +// find SHA256SUMS + tarballs on OSS so the mirror race actually picks aliyun. + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ENV_FILE = path.join(__dirname, '.env'); + +const SOURCE_PREFIX = 'releases/qwen-code/v0.0.0-pr3828-test'; +const DEST_PREFIX = 'releases/qwen-code/latest'; + +const FILES = [ + 'qwen-code-darwin-arm64.tar.gz', + 'qwen-code-darwin-x64.tar.gz', + 'qwen-code-linux-arm64.tar.gz', + 'qwen-code-linux-x64.tar.gz', + 'qwen-code-win-x64.zip', + 'SHA256SUMS', +]; + +if (!fs.existsSync(ENV_FILE)) { + console.error('Missing tools/.env'); + process.exit(1); +} + +const env = {}; +for (const raw of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); +} +for (const k of ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET', 'OSS_ENDPOINT']) { + if (!env[k]) { + console.error(`Missing ${k} in tools/.env`); + process.exit(1); + } +} +env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + +function ossCopy({ ak, sk, endpoint, bucket, sourceKey, destKey }) { + return new Promise((resolve, reject) => { + const date = new Date().toUTCString(); + const copySource = `/${bucket}/${sourceKey}`; + const ossHeaders = `x-oss-copy-source:${copySource}\nx-oss-object-acl:public-read`; + const stringToSign = ['PUT', '', '', date, ossHeaders, `/${bucket}/${destKey}`].join('\n'); + const sig = crypto.createHmac('sha1', sk).update(stringToSign).digest('base64'); + + const host = `${bucket}.${endpoint}`; + const req = https.request( + { + method: 'PUT', + host, + path: `/${encodeURI(destKey)}`, + headers: { + Host: host, + Date: date, + 'Content-Length': '0', + 'x-oss-copy-source': copySource, + 'x-oss-object-acl': 'public-read', + Authorization: `OSS ${ak}:${sig}`, + }, + }, + (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(new Error(`OSS COPY ${destKey} -> ${res.statusCode}\n${data}`)); + } + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function httpHead(url) { + return new Promise((resolve, reject) => { + const u = new URL(url); + https + .request({ method: 'HEAD', host: u.host, path: u.pathname }, (res) => resolve(res.statusCode)) + .on('error', reject) + .end(); + }); +} + +const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${DEST_PREFIX}`; + +console.log(`Copying ${FILES.length} files`); +console.log(` src: oss://${env.OSS_BUCKET}/${SOURCE_PREFIX}/`); +console.log(` dst: oss://${env.OSS_BUCKET}/${DEST_PREFIX}/`); +console.log(''); +for (const f of FILES) { + process.stdout.write(` COPY ${f.padEnd(34)} ... `); + const start = Date.now(); + await ossCopy({ + ak: env.OSS_ACCESS_KEY_ID, + sk: env.OSS_ACCESS_KEY_SECRET, + endpoint: env.OSS_ENDPOINT, + bucket: env.OSS_BUCKET, + sourceKey: `${SOURCE_PREFIX}/${f}`, + destKey: `${DEST_PREFIX}/${f}`, + }); + console.log(`OK (${((Date.now() - start) / 1000).toFixed(1)}s)`); +} + +console.log(''); +console.log('Verifying public URLs (HEAD)...'); +let allOk = true; +for (const f of FILES) { + const url = `${baseUrl}/${f}`; + const status = await httpHead(url); + console.log(` ${status === 200 ? 'OK ' : 'FAIL'} ${status} ${url}`); + if (status !== 200) allOk = false; +} +if (!allOk) process.exit(1); + +const installBaseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation`; +console.log(''); +console.log('Now `--version latest` (the default) will find SHA256SUMS on OSS,'); +console.log('so race_mirror_head will pick aliyun for CN users:'); +console.log(''); +console.log('Linux/macOS:'); +console.log(` curl -fsSL ${installBaseUrl}/install-qwen.sh | bash`); +console.log(''); +console.log('Windows PowerShell:'); +console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest '${installBaseUrl}/install-qwen.bat' -OutFile $i; & $i`); diff --git a/tools/test-upload-one.mjs b/tools/test-upload-one.mjs new file mode 100644 index 00000000000..4aef2ef5b5c --- /dev/null +++ b/tools/test-upload-one.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// One-shot OSS upload smoke test: PUT only SHA256SUMS, then HEAD verify. +// Used to validate credentials + bucket ACL before running the full upload. + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ENV_FILE = path.join(__dirname, '.env'); +const TARGET = path.resolve(__dirname, '..', 'hosted-staging', 'SHA256SUMS'); + +if (!fs.existsSync(ENV_FILE)) { + console.error('Missing tools/.env'); + process.exit(1); +} +if (!fs.existsSync(TARGET)) { + console.error(`Missing ${TARGET}`); + process.exit(1); +} + +const env = {}; +for (const raw of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); +} +for (const k of [ + 'OSS_ACCESS_KEY_ID', + 'OSS_ACCESS_KEY_SECRET', + 'OSS_BUCKET', + 'OSS_ENDPOINT', +]) { + if (!env[k]) { + console.error(`Missing ${k} in tools/.env`); + process.exit(1); + } +} +if (env.OSS_BUCKET === 'qwen-code-assets') { + console.error('Refusing to upload to production bucket qwen-code-assets.'); + process.exit(1); +} +env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const prefix = (env.OSS_PREFIX || 'installation').replace(/^\/+|\/+$/g, ''); +const objectKey = `${prefix}/SHA256SUMS`; +const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; +const objectUrl = `${baseUrl}/SHA256SUMS`; + +console.log(`Bucket: ${env.OSS_BUCKET}`); +console.log(`Endpoint: ${env.OSS_ENDPOINT}`); +console.log(`Object key: ${objectKey}`); +console.log(`Public URL: ${objectUrl}`); +console.log(''); + +const body = fs.readFileSync(TARGET); +const date = new Date().toUTCString(); +const md5 = crypto.createHash('md5').update(body).digest('base64'); +const ossHeaders = 'x-oss-object-acl:public-read'; +const stringToSign = [ + 'PUT', + md5, + 'text/plain', + date, + ossHeaders, + `/${env.OSS_BUCKET}/${objectKey}`, +].join('\n'); +const sig = crypto + .createHmac('sha1', env.OSS_ACCESS_KEY_SECRET) + .update(stringToSign) + .digest('base64'); + +const host = `${env.OSS_BUCKET}.${env.OSS_ENDPOINT}`; +console.log(`PUT https://${host}/${objectKey} (${body.length} bytes) ...`); + +await new Promise((resolve, reject) => { + const req = https.request( + { + method: 'PUT', + host, + path: `/${encodeURI(objectKey)}`, + headers: { + Host: host, + Date: date, + 'Content-Type': 'text/plain', + 'Content-Length': body.length, + 'Content-MD5': md5, + 'x-oss-object-acl': 'public-read', + Authorization: `OSS ${env.OSS_ACCESS_KEY_ID}:${sig}`, + }, + }, + (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + console.log(` -> HTTP ${res.statusCode}`); + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + console.error('--- response body ---'); + console.error(data); + reject(new Error(`PUT failed: ${res.statusCode}`)); + } + }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); +}); + +console.log(''); +console.log(`HEAD ${objectUrl} ...`); +const headStatus = await new Promise((resolve, reject) => { + const u = new URL(objectUrl); + const req = https.request( + { method: 'HEAD', host: u.host, path: u.pathname }, + (res) => resolve(res.statusCode), + ); + req.on('error', reject); + req.end(); +}); +console.log(` -> HTTP ${headStatus}`); + +console.log(''); +console.log(`GET ${objectUrl} ...`); +const remote = await new Promise((resolve, reject) => { + https + .get(objectUrl, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }) + .on('error', reject); +}); +console.log(` -> HTTP ${remote.status}, ${remote.body.length} bytes`); + +const local = fs.readFileSync(TARGET, 'utf8'); +if (remote.body.trim() === local.trim()) { + console.log(''); + console.log('✓ Smoke test passed. Credentials, ACL, and HEAD/GET all OK.'); + console.log(' You can now run: node tools/upload-staging.mjs'); +} else { + console.log(''); + console.log('✗ Remote body differs from local SHA256SUMS.'); + process.exit(1); +} diff --git a/tools/upload-release-mirror.mjs b/tools/upload-release-mirror.mjs new file mode 100644 index 00000000000..2fe7d6d105e --- /dev/null +++ b/tools/upload-release-mirror.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// Upload one or more standalone release archives + SHA256SUMS to a mirror path +// on the staging OSS bucket, so install-qwen.sh can be tested with +// QWEN_INSTALL_BASE_URL pointing at OSS instead of slow GitHub release CDN. +// +// Usage: +// node tools/upload-release-mirror.mjs # uploads linux-x64 + SHA256SUMS +// node tools/upload-release-mirror.mjs all # uploads all 5 tarballs + SHA256SUMS +// node tools/upload-release-mirror.mjs darwin-arm64 win-x64 + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const RELEASE_DIR = path.join(ROOT, 'release-staging'); +const ENV_FILE = path.join(__dirname, '.env'); +const VERSION_TAG = 'v0.0.0-pr3828-test'; + +const ALL_TARGETS = { + 'darwin-arm64': 'qwen-code-darwin-arm64.tar.gz', + 'darwin-x64': 'qwen-code-darwin-x64.tar.gz', + 'linux-arm64': 'qwen-code-linux-arm64.tar.gz', + 'linux-x64': 'qwen-code-linux-x64.tar.gz', + 'win-x64': 'qwen-code-win-x64.zip', +}; + +function bail(msg) { + console.error(msg); + process.exit(1); +} + +function loadEnv(file) { + if (!fs.existsSync(file)) { + bail(`Missing ${file}. Copy tools/.env.example to tools/.env and fill it in.`); + } + const env = {}; + for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); + } + return env; +} + +function ossPut({ ak, sk, endpoint, bucket, objectKey, body, contentType }) { + return new Promise((resolve, reject) => { + const date = new Date().toUTCString(); + const md5 = crypto.createHash('md5').update(body).digest('base64'); + const ossHeaders = 'x-oss-object-acl:public-read'; + const stringToSign = [ + 'PUT', + md5, + contentType, + date, + ossHeaders, + `/${bucket}/${objectKey}`, + ].join('\n'); + const sig = crypto.createHmac('sha1', sk).update(stringToSign).digest('base64'); + const host = `${bucket}.${endpoint}`; + const req = https.request( + { + method: 'PUT', + host, + path: `/${encodeURI(objectKey)}`, + headers: { + Host: host, + Date: date, + 'Content-Type': contentType, + 'Content-Length': body.length, + 'Content-MD5': md5, + 'x-oss-object-acl': 'public-read', + Authorization: `OSS ${ak}:${sig}`, + }, + }, + (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(new Error(`OSS PUT ${objectKey} -> ${res.statusCode}\n${data}`)); + } + }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function httpHead(url) { + return new Promise((resolve, reject) => { + const u = new URL(url); + https + .request({ method: 'HEAD', host: u.host, path: u.pathname }, (res) => + resolve({ status: res.statusCode, contentLength: res.headers['content-length'] }), + ) + .on('error', reject) + .end(); + }); +} + +const env = loadEnv(ENV_FILE); +for (const k of ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET', 'OSS_ENDPOINT']) { + if (!env[k]) bail(`Missing ${k} in tools/.env`); +} +if (env.OSS_BUCKET === 'qwen-code-assets') { + bail('Refusing to upload to production bucket qwen-code-assets.'); +} +env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + +const args = process.argv.slice(2); +let selected; +if (args.length === 0) { + selected = ['linux-x64']; +} else if (args.length === 1 && args[0] === 'all') { + selected = Object.keys(ALL_TARGETS); +} else { + selected = args.map((a) => { + if (!ALL_TARGETS[a]) bail(`Unknown target: ${a}. Valid: ${Object.keys(ALL_TARGETS).join(', ')} | all`); + return a; + }); +} + +if (!fs.existsSync(RELEASE_DIR)) { + bail(`Missing ${RELEASE_DIR}. Run: npm run package:standalone:release -- --version ${VERSION_TAG} --out-dir ./release-staging`); +} + +const prefix = `releases/qwen-code/${VERSION_TAG}`; +const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; + +const filesToUpload = [ + ...selected.map((t) => ({ + name: ALL_TARGETS[t], + contentType: ALL_TARGETS[t].endsWith('.zip') + ? 'application/zip' + : 'application/gzip', + })), + { name: 'SHA256SUMS', contentType: 'text/plain' }, +]; + +console.log(`Uploading ${filesToUpload.length} files to oss://${env.OSS_BUCKET}/${prefix}/ ...`); +for (const f of filesToUpload) { + const local = path.join(RELEASE_DIR, f.name); + if (!fs.existsSync(local)) bail(`Missing ${local}`); + const body = fs.readFileSync(local); + const sizeMb = (body.length / 1024 / 1024).toFixed(1); + process.stdout.write(` PUT ${f.name.padEnd(34)} ${sizeMb.padStart(6)} MB ... `); + const start = Date.now(); + await ossPut({ + ak: env.OSS_ACCESS_KEY_ID, + sk: env.OSS_ACCESS_KEY_SECRET, + endpoint: env.OSS_ENDPOINT, + bucket: env.OSS_BUCKET, + objectKey: `${prefix}/${f.name}`, + body, + contentType: f.contentType, + }); + console.log(`OK (${((Date.now() - start) / 1000).toFixed(1)}s)`); +} + +console.log('\nVerifying public URLs (HEAD)...'); +let allOk = true; +for (const f of filesToUpload) { + const url = `${baseUrl}/${f.name}`; + const { status, contentLength } = await httpHead(url); + const tag = status === 200 ? 'OK ' : 'FAIL'; + console.log(` ${tag} ${status} ${(contentLength || '?').padStart(10)} B ${url}`); + if (status !== 200) allOk = false; +} +if (!allOk) bail('\nOne or more URLs not reachable.'); + +console.log('\n=== Done ==='); +console.log(`\nLinux/macOS test command (uses your OSS as mirror):`); +console.log(` QWEN_INSTALL_BASE_URL=${baseUrl} \\`); +console.log(` bash <(curl -fsSL https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation/install-qwen.sh)`); +console.log(`\nWindows PowerShell test command:`); +console.log(` $env:QWEN_INSTALL_BASE_URL='${baseUrl}'`); +console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'`); +console.log(` Invoke-WebRequest 'https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation/install-qwen.bat' -OutFile $i`); +console.log(` & $i`); +console.log(''); diff --git a/tools/upload-staging.mjs b/tools/upload-staging.mjs new file mode 100644 index 00000000000..e5d7e38f968 --- /dev/null +++ b/tools/upload-staging.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// Upload hosted-staging/{install-qwen.sh,install-qwen.bat,SHA256SUMS} to your +// own staging OSS bucket using credentials from tools/.env. +// +// Usage: +// 1. cp tools/.env.example tools/.env && fill in +// 2. node tools/upload-staging.mjs + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const STAGING_DIR = path.join(ROOT, 'hosted-staging'); +const ENV_FILE = path.join(__dirname, '.env'); + +const FILES = [ + { name: 'install-qwen.sh', contentType: 'application/x-sh' }, + { name: 'install-qwen.bat', contentType: 'application/octet-stream' }, + { name: 'SHA256SUMS', contentType: 'text/plain' }, +]; + +function loadEnv(file) { + if (!fs.existsSync(file)) { + bail(`Missing ${file}. Copy tools/.env.example to tools/.env and fill it in.`); + } + const env = {}; + for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); + if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); + } + return env; +} + +function bail(msg) { + console.error(msg); + process.exit(1); +} + +function ossPut({ ak, sk, endpoint, bucket, objectKey, body, contentType }) { + return new Promise((resolve, reject) => { + const date = new Date().toUTCString(); + const md5 = crypto.createHash('md5').update(body).digest('base64'); + const ossHeaders = 'x-oss-object-acl:public-read'; + const stringToSign = [ + 'PUT', + md5, + contentType, + date, + ossHeaders, + `/${bucket}/${objectKey}`, + ].join('\n'); + const sig = crypto + .createHmac('sha1', sk) + .update(stringToSign) + .digest('base64'); + + const host = `${bucket}.${endpoint}`; + const req = https.request( + { + method: 'PUT', + host, + path: `/${encodeURI(objectKey)}`, + headers: { + Host: host, + Date: date, + 'Content-Type': contentType, + 'Content-Length': body.length, + 'Content-MD5': md5, + 'x-oss-object-acl': 'public-read', + Authorization: `OSS ${ak}:${sig}`, + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject( + new Error( + `OSS PUT ${objectKey} -> ${res.statusCode}\n${data}`, + ), + ); + } + }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function httpHead(url) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request( + { method: 'HEAD', host: u.host, path: u.pathname }, + (res) => resolve(res.statusCode), + ); + req.on('error', reject); + req.end(); + }); +} + +function httpGet(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }) + .on('error', reject); + }); +} + +const env = loadEnv(ENV_FILE); +for (const k of [ + 'OSS_ACCESS_KEY_ID', + 'OSS_ACCESS_KEY_SECRET', + 'OSS_BUCKET', + 'OSS_ENDPOINT', +]) { + if (!env[k]) bail(`Missing ${k} in tools/.env`); +} +if (env.OSS_BUCKET === 'qwen-code-assets') { + bail('Refusing to upload to production bucket qwen-code-assets. Use a staging bucket.'); +} +env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const prefix = (env.OSS_PREFIX || 'installation').replace(/^\/+|\/+$/g, ''); + +if (!fs.existsSync(STAGING_DIR)) { + bail(`Missing ${STAGING_DIR}. Run: npm run package:hosted-installation -- --out-dir ./hosted-staging`); +} + +const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; + +console.log(`Uploading to oss://${env.OSS_BUCKET}/${prefix}/ ...`); +for (const f of FILES) { + const local = path.join(STAGING_DIR, f.name); + const body = fs.readFileSync(local); + process.stdout.write(` PUT ${f.name.padEnd(20)} ${body.length.toString().padStart(6)} bytes ... `); + await ossPut({ + ak: env.OSS_ACCESS_KEY_ID, + sk: env.OSS_ACCESS_KEY_SECRET, + endpoint: env.OSS_ENDPOINT, + bucket: env.OSS_BUCKET, + objectKey: `${prefix}/${f.name}`, + body, + contentType: f.contentType, + }); + console.log('OK'); +} + +console.log('\nVerifying public URLs (HEAD)...'); +let allOk = true; +for (const f of FILES) { + const url = `${baseUrl}/${f.name}`; + const status = await httpHead(url); + const tag = status === 200 ? 'OK ' : 'FAIL'; + console.log(` ${tag} ${status} ${url}`); + if (status !== 200) allOk = false; +} +if (!allOk) bail('\nOne or more URLs not reachable. Check bucket ACL.'); + +console.log('\nVerifying remote SHA256SUMS matches local...'); +const remote = await httpGet(`${baseUrl}/SHA256SUMS`); +const local = fs.readFileSync(path.join(STAGING_DIR, 'SHA256SUMS'), 'utf8'); +if (remote.body.trim() === local.trim()) { + console.log(' OK remote SHA256SUMS matches local'); +} else { + console.log(' FAIL remote != local'); + console.log('--- local ---\n' + local); + console.log('--- remote ---\n' + remote.body); + process.exit(1); +} + +console.log('\n=== Done ==='); +console.log(`\nECS test entry point (Linux/macOS):`); +console.log(` curl -fsSL ${baseUrl}/install-qwen.sh | bash`); +console.log(`\nECS test entry point (Windows PowerShell):`); +console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest '${baseUrl}/install-qwen.bat' -OutFile $i; & $i`); +console.log(`\nGitHub Actions input (installer_base_url):`); +console.log(` ${baseUrl}\n`); From 2ae9d0c05824b835f58e9a518ed4921b9057b669 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 00:39:59 +0800 Subject: [PATCH 050/129] feat(installer): support QWEN_INSTALL_GITHUB_REPO env var for custom repo --- scripts/installation/install-qwen-with-source.bat | 7 +++++-- scripts/installation/install-qwen-with-source.sh | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 6584591da71..8df39fc9064 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -384,11 +384,14 @@ exit /b 0 :GithubBaseUrlForVersion rem args: %~1=version_path → sets QWEN_GH_BASE_URL +set "QWEN_GH_REPO=QwenLM/qwen-code" +if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" if /i "%~1"=="latest" ( - set "QWEN_GH_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" ) else ( - set "QWEN_GH_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/%~1" + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" ) +set "QWEN_GH_REPO=" exit /b 0 :AliyunBaseUrlForVersion diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 0682d7e5a6c..1c86ee9362e 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -544,10 +544,11 @@ maybe_update_shell_path() { github_base_url_for_version() { local version_path="$1" + local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}" if [[ "${version_path}" == "latest" ]]; then - echo "https://github.com/QwenLM/qwen-code/releases/latest/download" + echo "https://github.com/${github_repo}/releases/latest/download" else - echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" + echo "https://github.com/${github_repo}/releases/download/${version_path}" fi } From 02e570693f26eabf83cb90c4b3292761354997dc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 00:41:24 +0800 Subject: [PATCH 051/129] chore(installer): exclude local-only staging tools from PR The tools/ directory contained personal staging-OSS upload helpers (upload-staging, upload-release-mirror, copy-release-to-latest, test-upload-one) that should not ship in the public PR. They reference a personal staging bucket and only exist to validate the installer end-to-end before production release. Removes them from git tracking via `git rm --cached` (files stay on disk for the author's local use) and adds /tools/ to root .gitignore so they cannot be re-added accidentally. No runtime / installer code change. Production CI on ubuntu-latest is unaffected. --- .gitignore | 3 + tools/.env.example | 15 --- tools/.gitignore | 2 - tools/copy-release-to-latest.mjs | 138 ---------------------- tools/test-upload-one.mjs | 149 ------------------------ tools/upload-release-mirror.mjs | 189 ------------------------------ tools/upload-staging.mjs | 192 ------------------------------- 7 files changed, 3 insertions(+), 685 deletions(-) delete mode 100644 tools/.env.example delete mode 100644 tools/.gitignore delete mode 100644 tools/copy-release-to-latest.mjs delete mode 100644 tools/test-upload-one.mjs delete mode 100644 tools/upload-release-mirror.mjs delete mode 100644 tools/upload-staging.mjs diff --git a/.gitignore b/.gitignore index c6edf4256d2..59895561c42 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ release-staging/ # Hosted installation assets staging (generated by scripts/build-hosted-installation-assets.js) hosted-staging/ + +# Local-only staging tools (PR #3828) — staging OSS upload, not for production +/tools/ diff --git a/tools/.env.example b/tools/.env.example deleted file mode 100644 index 6749c446f98..00000000000 --- a/tools/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Copy to .env and fill in your STAGING (NOT production) OSS credentials. -# .env is gitignored. - -# Aliyun RAM AccessKey scoped to the staging bucket only -OSS_ACCESS_KEY_ID= -OSS_ACCESS_KEY_SECRET= - -# Bucket name. MUST NOT be the production bucket "qwen-code-assets". -OSS_BUCKET= - -# Region endpoint, e.g. oss-cn-hangzhou.aliyuncs.com -OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com - -# Object key prefix. Default: installation -# OSS_PREFIX=installation diff --git a/tools/.gitignore b/tools/.gitignore deleted file mode 100644 index 63f6705b8de..00000000000 --- a/tools/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env -*.bak diff --git a/tools/copy-release-to-latest.mjs b/tools/copy-release-to-latest.mjs deleted file mode 100644 index b8f58905b6c..00000000000 --- a/tools/copy-release-to-latest.mjs +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env node -// Server-side OSS Copy Object: /releases/qwen-code/v0.0.0-pr3828-test/* -// -> /releases/qwen-code/latest/* -// No re-upload, just metadata copy. Lets the default `--version latest` flow -// find SHA256SUMS + tarballs on OSS so the mirror race actually picks aliyun. - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import https from 'node:https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ENV_FILE = path.join(__dirname, '.env'); - -const SOURCE_PREFIX = 'releases/qwen-code/v0.0.0-pr3828-test'; -const DEST_PREFIX = 'releases/qwen-code/latest'; - -const FILES = [ - 'qwen-code-darwin-arm64.tar.gz', - 'qwen-code-darwin-x64.tar.gz', - 'qwen-code-linux-arm64.tar.gz', - 'qwen-code-linux-x64.tar.gz', - 'qwen-code-win-x64.zip', - 'SHA256SUMS', -]; - -if (!fs.existsSync(ENV_FILE)) { - console.error('Missing tools/.env'); - process.exit(1); -} - -const env = {}; -for (const raw of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); - if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); -} -for (const k of ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET', 'OSS_ENDPOINT']) { - if (!env[k]) { - console.error(`Missing ${k} in tools/.env`); - process.exit(1); - } -} -env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); - -function ossCopy({ ak, sk, endpoint, bucket, sourceKey, destKey }) { - return new Promise((resolve, reject) => { - const date = new Date().toUTCString(); - const copySource = `/${bucket}/${sourceKey}`; - const ossHeaders = `x-oss-copy-source:${copySource}\nx-oss-object-acl:public-read`; - const stringToSign = ['PUT', '', '', date, ossHeaders, `/${bucket}/${destKey}`].join('\n'); - const sig = crypto.createHmac('sha1', sk).update(stringToSign).digest('base64'); - - const host = `${bucket}.${endpoint}`; - const req = https.request( - { - method: 'PUT', - host, - path: `/${encodeURI(destKey)}`, - headers: { - Host: host, - Date: date, - 'Content-Length': '0', - 'x-oss-copy-source': copySource, - 'x-oss-object-acl': 'public-read', - Authorization: `OSS ${ak}:${sig}`, - }, - }, - (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(); - } else { - reject(new Error(`OSS COPY ${destKey} -> ${res.statusCode}\n${data}`)); - } - }); - }, - ); - req.on('error', reject); - req.end(); - }); -} - -function httpHead(url) { - return new Promise((resolve, reject) => { - const u = new URL(url); - https - .request({ method: 'HEAD', host: u.host, path: u.pathname }, (res) => resolve(res.statusCode)) - .on('error', reject) - .end(); - }); -} - -const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${DEST_PREFIX}`; - -console.log(`Copying ${FILES.length} files`); -console.log(` src: oss://${env.OSS_BUCKET}/${SOURCE_PREFIX}/`); -console.log(` dst: oss://${env.OSS_BUCKET}/${DEST_PREFIX}/`); -console.log(''); -for (const f of FILES) { - process.stdout.write(` COPY ${f.padEnd(34)} ... `); - const start = Date.now(); - await ossCopy({ - ak: env.OSS_ACCESS_KEY_ID, - sk: env.OSS_ACCESS_KEY_SECRET, - endpoint: env.OSS_ENDPOINT, - bucket: env.OSS_BUCKET, - sourceKey: `${SOURCE_PREFIX}/${f}`, - destKey: `${DEST_PREFIX}/${f}`, - }); - console.log(`OK (${((Date.now() - start) / 1000).toFixed(1)}s)`); -} - -console.log(''); -console.log('Verifying public URLs (HEAD)...'); -let allOk = true; -for (const f of FILES) { - const url = `${baseUrl}/${f}`; - const status = await httpHead(url); - console.log(` ${status === 200 ? 'OK ' : 'FAIL'} ${status} ${url}`); - if (status !== 200) allOk = false; -} -if (!allOk) process.exit(1); - -const installBaseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation`; -console.log(''); -console.log('Now `--version latest` (the default) will find SHA256SUMS on OSS,'); -console.log('so race_mirror_head will pick aliyun for CN users:'); -console.log(''); -console.log('Linux/macOS:'); -console.log(` curl -fsSL ${installBaseUrl}/install-qwen.sh | bash`); -console.log(''); -console.log('Windows PowerShell:'); -console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest '${installBaseUrl}/install-qwen.bat' -OutFile $i; & $i`); diff --git a/tools/test-upload-one.mjs b/tools/test-upload-one.mjs deleted file mode 100644 index 4aef2ef5b5c..00000000000 --- a/tools/test-upload-one.mjs +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env node -// One-shot OSS upload smoke test: PUT only SHA256SUMS, then HEAD verify. -// Used to validate credentials + bucket ACL before running the full upload. - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import https from 'node:https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ENV_FILE = path.join(__dirname, '.env'); -const TARGET = path.resolve(__dirname, '..', 'hosted-staging', 'SHA256SUMS'); - -if (!fs.existsSync(ENV_FILE)) { - console.error('Missing tools/.env'); - process.exit(1); -} -if (!fs.existsSync(TARGET)) { - console.error(`Missing ${TARGET}`); - process.exit(1); -} - -const env = {}; -for (const raw of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); - if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); -} -for (const k of [ - 'OSS_ACCESS_KEY_ID', - 'OSS_ACCESS_KEY_SECRET', - 'OSS_BUCKET', - 'OSS_ENDPOINT', -]) { - if (!env[k]) { - console.error(`Missing ${k} in tools/.env`); - process.exit(1); - } -} -if (env.OSS_BUCKET === 'qwen-code-assets') { - console.error('Refusing to upload to production bucket qwen-code-assets.'); - process.exit(1); -} -env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); -const prefix = (env.OSS_PREFIX || 'installation').replace(/^\/+|\/+$/g, ''); -const objectKey = `${prefix}/SHA256SUMS`; -const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; -const objectUrl = `${baseUrl}/SHA256SUMS`; - -console.log(`Bucket: ${env.OSS_BUCKET}`); -console.log(`Endpoint: ${env.OSS_ENDPOINT}`); -console.log(`Object key: ${objectKey}`); -console.log(`Public URL: ${objectUrl}`); -console.log(''); - -const body = fs.readFileSync(TARGET); -const date = new Date().toUTCString(); -const md5 = crypto.createHash('md5').update(body).digest('base64'); -const ossHeaders = 'x-oss-object-acl:public-read'; -const stringToSign = [ - 'PUT', - md5, - 'text/plain', - date, - ossHeaders, - `/${env.OSS_BUCKET}/${objectKey}`, -].join('\n'); -const sig = crypto - .createHmac('sha1', env.OSS_ACCESS_KEY_SECRET) - .update(stringToSign) - .digest('base64'); - -const host = `${env.OSS_BUCKET}.${env.OSS_ENDPOINT}`; -console.log(`PUT https://${host}/${objectKey} (${body.length} bytes) ...`); - -await new Promise((resolve, reject) => { - const req = https.request( - { - method: 'PUT', - host, - path: `/${encodeURI(objectKey)}`, - headers: { - Host: host, - Date: date, - 'Content-Type': 'text/plain', - 'Content-Length': body.length, - 'Content-MD5': md5, - 'x-oss-object-acl': 'public-read', - Authorization: `OSS ${env.OSS_ACCESS_KEY_ID}:${sig}`, - }, - }, - (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - console.log(` -> HTTP ${res.statusCode}`); - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(); - } else { - console.error('--- response body ---'); - console.error(data); - reject(new Error(`PUT failed: ${res.statusCode}`)); - } - }); - }, - ); - req.on('error', reject); - req.write(body); - req.end(); -}); - -console.log(''); -console.log(`HEAD ${objectUrl} ...`); -const headStatus = await new Promise((resolve, reject) => { - const u = new URL(objectUrl); - const req = https.request( - { method: 'HEAD', host: u.host, path: u.pathname }, - (res) => resolve(res.statusCode), - ); - req.on('error', reject); - req.end(); -}); -console.log(` -> HTTP ${headStatus}`); - -console.log(''); -console.log(`GET ${objectUrl} ...`); -const remote = await new Promise((resolve, reject) => { - https - .get(objectUrl, (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => resolve({ status: res.statusCode, body: data })); - }) - .on('error', reject); -}); -console.log(` -> HTTP ${remote.status}, ${remote.body.length} bytes`); - -const local = fs.readFileSync(TARGET, 'utf8'); -if (remote.body.trim() === local.trim()) { - console.log(''); - console.log('✓ Smoke test passed. Credentials, ACL, and HEAD/GET all OK.'); - console.log(' You can now run: node tools/upload-staging.mjs'); -} else { - console.log(''); - console.log('✗ Remote body differs from local SHA256SUMS.'); - process.exit(1); -} diff --git a/tools/upload-release-mirror.mjs b/tools/upload-release-mirror.mjs deleted file mode 100644 index 2fe7d6d105e..00000000000 --- a/tools/upload-release-mirror.mjs +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node -// Upload one or more standalone release archives + SHA256SUMS to a mirror path -// on the staging OSS bucket, so install-qwen.sh can be tested with -// QWEN_INSTALL_BASE_URL pointing at OSS instead of slow GitHub release CDN. -// -// Usage: -// node tools/upload-release-mirror.mjs # uploads linux-x64 + SHA256SUMS -// node tools/upload-release-mirror.mjs all # uploads all 5 tarballs + SHA256SUMS -// node tools/upload-release-mirror.mjs darwin-arm64 win-x64 - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import https from 'node:https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, '..'); -const RELEASE_DIR = path.join(ROOT, 'release-staging'); -const ENV_FILE = path.join(__dirname, '.env'); -const VERSION_TAG = 'v0.0.0-pr3828-test'; - -const ALL_TARGETS = { - 'darwin-arm64': 'qwen-code-darwin-arm64.tar.gz', - 'darwin-x64': 'qwen-code-darwin-x64.tar.gz', - 'linux-arm64': 'qwen-code-linux-arm64.tar.gz', - 'linux-x64': 'qwen-code-linux-x64.tar.gz', - 'win-x64': 'qwen-code-win-x64.zip', -}; - -function bail(msg) { - console.error(msg); - process.exit(1); -} - -function loadEnv(file) { - if (!fs.existsSync(file)) { - bail(`Missing ${file}. Copy tools/.env.example to tools/.env and fill it in.`); - } - const env = {}; - for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); - if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); - } - return env; -} - -function ossPut({ ak, sk, endpoint, bucket, objectKey, body, contentType }) { - return new Promise((resolve, reject) => { - const date = new Date().toUTCString(); - const md5 = crypto.createHash('md5').update(body).digest('base64'); - const ossHeaders = 'x-oss-object-acl:public-read'; - const stringToSign = [ - 'PUT', - md5, - contentType, - date, - ossHeaders, - `/${bucket}/${objectKey}`, - ].join('\n'); - const sig = crypto.createHmac('sha1', sk).update(stringToSign).digest('base64'); - const host = `${bucket}.${endpoint}`; - const req = https.request( - { - method: 'PUT', - host, - path: `/${encodeURI(objectKey)}`, - headers: { - Host: host, - Date: date, - 'Content-Type': contentType, - 'Content-Length': body.length, - 'Content-MD5': md5, - 'x-oss-object-acl': 'public-read', - Authorization: `OSS ${ak}:${sig}`, - }, - }, - (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(); - } else { - reject(new Error(`OSS PUT ${objectKey} -> ${res.statusCode}\n${data}`)); - } - }); - }, - ); - req.on('error', reject); - req.write(body); - req.end(); - }); -} - -function httpHead(url) { - return new Promise((resolve, reject) => { - const u = new URL(url); - https - .request({ method: 'HEAD', host: u.host, path: u.pathname }, (res) => - resolve({ status: res.statusCode, contentLength: res.headers['content-length'] }), - ) - .on('error', reject) - .end(); - }); -} - -const env = loadEnv(ENV_FILE); -for (const k of ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET', 'OSS_ENDPOINT']) { - if (!env[k]) bail(`Missing ${k} in tools/.env`); -} -if (env.OSS_BUCKET === 'qwen-code-assets') { - bail('Refusing to upload to production bucket qwen-code-assets.'); -} -env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); - -const args = process.argv.slice(2); -let selected; -if (args.length === 0) { - selected = ['linux-x64']; -} else if (args.length === 1 && args[0] === 'all') { - selected = Object.keys(ALL_TARGETS); -} else { - selected = args.map((a) => { - if (!ALL_TARGETS[a]) bail(`Unknown target: ${a}. Valid: ${Object.keys(ALL_TARGETS).join(', ')} | all`); - return a; - }); -} - -if (!fs.existsSync(RELEASE_DIR)) { - bail(`Missing ${RELEASE_DIR}. Run: npm run package:standalone:release -- --version ${VERSION_TAG} --out-dir ./release-staging`); -} - -const prefix = `releases/qwen-code/${VERSION_TAG}`; -const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; - -const filesToUpload = [ - ...selected.map((t) => ({ - name: ALL_TARGETS[t], - contentType: ALL_TARGETS[t].endsWith('.zip') - ? 'application/zip' - : 'application/gzip', - })), - { name: 'SHA256SUMS', contentType: 'text/plain' }, -]; - -console.log(`Uploading ${filesToUpload.length} files to oss://${env.OSS_BUCKET}/${prefix}/ ...`); -for (const f of filesToUpload) { - const local = path.join(RELEASE_DIR, f.name); - if (!fs.existsSync(local)) bail(`Missing ${local}`); - const body = fs.readFileSync(local); - const sizeMb = (body.length / 1024 / 1024).toFixed(1); - process.stdout.write(` PUT ${f.name.padEnd(34)} ${sizeMb.padStart(6)} MB ... `); - const start = Date.now(); - await ossPut({ - ak: env.OSS_ACCESS_KEY_ID, - sk: env.OSS_ACCESS_KEY_SECRET, - endpoint: env.OSS_ENDPOINT, - bucket: env.OSS_BUCKET, - objectKey: `${prefix}/${f.name}`, - body, - contentType: f.contentType, - }); - console.log(`OK (${((Date.now() - start) / 1000).toFixed(1)}s)`); -} - -console.log('\nVerifying public URLs (HEAD)...'); -let allOk = true; -for (const f of filesToUpload) { - const url = `${baseUrl}/${f.name}`; - const { status, contentLength } = await httpHead(url); - const tag = status === 200 ? 'OK ' : 'FAIL'; - console.log(` ${tag} ${status} ${(contentLength || '?').padStart(10)} B ${url}`); - if (status !== 200) allOk = false; -} -if (!allOk) bail('\nOne or more URLs not reachable.'); - -console.log('\n=== Done ==='); -console.log(`\nLinux/macOS test command (uses your OSS as mirror):`); -console.log(` QWEN_INSTALL_BASE_URL=${baseUrl} \\`); -console.log(` bash <(curl -fsSL https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation/install-qwen.sh)`); -console.log(`\nWindows PowerShell test command:`); -console.log(` $env:QWEN_INSTALL_BASE_URL='${baseUrl}'`); -console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'`); -console.log(` Invoke-WebRequest 'https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/installation/install-qwen.bat' -OutFile $i`); -console.log(` & $i`); -console.log(''); diff --git a/tools/upload-staging.mjs b/tools/upload-staging.mjs deleted file mode 100644 index e5d7e38f968..00000000000 --- a/tools/upload-staging.mjs +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -// Upload hosted-staging/{install-qwen.sh,install-qwen.bat,SHA256SUMS} to your -// own staging OSS bucket using credentials from tools/.env. -// -// Usage: -// 1. cp tools/.env.example tools/.env && fill in -// 2. node tools/upload-staging.mjs - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import https from 'node:https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, '..'); -const STAGING_DIR = path.join(ROOT, 'hosted-staging'); -const ENV_FILE = path.join(__dirname, '.env'); - -const FILES = [ - { name: 'install-qwen.sh', contentType: 'application/x-sh' }, - { name: 'install-qwen.bat', contentType: 'application/octet-stream' }, - { name: 'SHA256SUMS', contentType: 'text/plain' }, -]; - -function loadEnv(file) { - if (!fs.existsSync(file)) { - bail(`Missing ${file}. Copy tools/.env.example to tools/.env and fill it in.`); - } - const env = {}; - for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/); - if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); - } - return env; -} - -function bail(msg) { - console.error(msg); - process.exit(1); -} - -function ossPut({ ak, sk, endpoint, bucket, objectKey, body, contentType }) { - return new Promise((resolve, reject) => { - const date = new Date().toUTCString(); - const md5 = crypto.createHash('md5').update(body).digest('base64'); - const ossHeaders = 'x-oss-object-acl:public-read'; - const stringToSign = [ - 'PUT', - md5, - contentType, - date, - ossHeaders, - `/${bucket}/${objectKey}`, - ].join('\n'); - const sig = crypto - .createHmac('sha1', sk) - .update(stringToSign) - .digest('base64'); - - const host = `${bucket}.${endpoint}`; - const req = https.request( - { - method: 'PUT', - host, - path: `/${encodeURI(objectKey)}`, - headers: { - Host: host, - Date: date, - 'Content-Type': contentType, - 'Content-Length': body.length, - 'Content-MD5': md5, - 'x-oss-object-acl': 'public-read', - Authorization: `OSS ${ak}:${sig}`, - }, - }, - (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(); - } else { - reject( - new Error( - `OSS PUT ${objectKey} -> ${res.statusCode}\n${data}`, - ), - ); - } - }); - }, - ); - req.on('error', reject); - req.write(body); - req.end(); - }); -} - -function httpHead(url) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const req = https.request( - { method: 'HEAD', host: u.host, path: u.pathname }, - (res) => resolve(res.statusCode), - ); - req.on('error', reject); - req.end(); - }); -} - -function httpGet(url) { - return new Promise((resolve, reject) => { - https - .get(url, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve({ status: res.statusCode, body: data })); - }) - .on('error', reject); - }); -} - -const env = loadEnv(ENV_FILE); -for (const k of [ - 'OSS_ACCESS_KEY_ID', - 'OSS_ACCESS_KEY_SECRET', - 'OSS_BUCKET', - 'OSS_ENDPOINT', -]) { - if (!env[k]) bail(`Missing ${k} in tools/.env`); -} -if (env.OSS_BUCKET === 'qwen-code-assets') { - bail('Refusing to upload to production bucket qwen-code-assets. Use a staging bucket.'); -} -env.OSS_ENDPOINT = env.OSS_ENDPOINT.replace(/^https?:\/\//, '').replace(/\/+$/, ''); -const prefix = (env.OSS_PREFIX || 'installation').replace(/^\/+|\/+$/g, ''); - -if (!fs.existsSync(STAGING_DIR)) { - bail(`Missing ${STAGING_DIR}. Run: npm run package:hosted-installation -- --out-dir ./hosted-staging`); -} - -const baseUrl = `https://${env.OSS_BUCKET}.${env.OSS_ENDPOINT}/${prefix}`; - -console.log(`Uploading to oss://${env.OSS_BUCKET}/${prefix}/ ...`); -for (const f of FILES) { - const local = path.join(STAGING_DIR, f.name); - const body = fs.readFileSync(local); - process.stdout.write(` PUT ${f.name.padEnd(20)} ${body.length.toString().padStart(6)} bytes ... `); - await ossPut({ - ak: env.OSS_ACCESS_KEY_ID, - sk: env.OSS_ACCESS_KEY_SECRET, - endpoint: env.OSS_ENDPOINT, - bucket: env.OSS_BUCKET, - objectKey: `${prefix}/${f.name}`, - body, - contentType: f.contentType, - }); - console.log('OK'); -} - -console.log('\nVerifying public URLs (HEAD)...'); -let allOk = true; -for (const f of FILES) { - const url = `${baseUrl}/${f.name}`; - const status = await httpHead(url); - const tag = status === 200 ? 'OK ' : 'FAIL'; - console.log(` ${tag} ${status} ${url}`); - if (status !== 200) allOk = false; -} -if (!allOk) bail('\nOne or more URLs not reachable. Check bucket ACL.'); - -console.log('\nVerifying remote SHA256SUMS matches local...'); -const remote = await httpGet(`${baseUrl}/SHA256SUMS`); -const local = fs.readFileSync(path.join(STAGING_DIR, 'SHA256SUMS'), 'utf8'); -if (remote.body.trim() === local.trim()) { - console.log(' OK remote SHA256SUMS matches local'); -} else { - console.log(' FAIL remote != local'); - console.log('--- local ---\n' + local); - console.log('--- remote ---\n' + remote.body); - process.exit(1); -} - -console.log('\n=== Done ==='); -console.log(`\nECS test entry point (Linux/macOS):`); -console.log(` curl -fsSL ${baseUrl}/install-qwen.sh | bash`); -console.log(`\nECS test entry point (Windows PowerShell):`); -console.log(` $i = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest '${baseUrl}/install-qwen.bat' -OutFile $i; & $i`); -console.log(`\nGitHub Actions input (installer_base_url):`); -console.log(` ${baseUrl}\n`); From 34a86bd8a7ce96964d6b320db6dbe05d61fada75 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 00:50:26 +0800 Subject: [PATCH 052/129] fix(installer): enforce CRLF line endings for .bat files via gitattributes cmd.exe requires CRLF in batch scripts; the global eol=lf was causing every line to be misparsed on Windows, producing errors like 'QWEN_VALIDATE_METHOD=detect is not recognized as a command'. --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index deab5ae88bd..ac0711169d4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,10 @@ *.bash eol=lf Makefile eol=lf +# Windows batch scripts must use CRLF or cmd.exe will misparse every line. +*.bat eol=crlf +*.cmd eol=crlf + # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. *.png binary From 2ca9dcd26dc0970e68b643697366e4e0f15add22 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 00:54:52 +0800 Subject: [PATCH 053/129] fix(installer): store .bat files with CRLF in git blob for raw GitHub downloads GitHub raw file serving bypasses gitattributes eol conversion and serves blob bytes directly, so eol=crlf alone was not enough. Use -text to disable normalization and commit with actual CRLF so raw downloads work on Windows. --- .gitattributes | 8 +- .../installation/install-qwen-with-source.bat | 2074 ++++++++--------- 2 files changed, 1042 insertions(+), 1040 deletions(-) diff --git a/.gitattributes b/.gitattributes index ac0711169d4..67a751c1d52 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,9 +9,11 @@ *.bash eol=lf Makefile eol=lf -# Windows batch scripts must use CRLF or cmd.exe will misparse every line. -*.bat eol=crlf -*.cmd eol=crlf +# Windows batch scripts must be stored with CRLF in the git blob so that +# raw GitHub downloads are also CRLF (GitHub raw does not apply eol conversion). +# -text disables normalization; files must be committed with CRLF endings. +*.bat -text +*.cmd -text # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 8df39fc9064..6cc9d2ff829 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -1,1037 +1,1037 @@ -@echo off -REM Qwen Code Installation Script -REM Installs Qwen Code from a standalone archive when available, with npm fallback. -REM This script intentionally does not install Node.js or change npm config. - -setlocal enabledelayedexpansion - -set "SOURCE=unknown" -set "METHOD=" -if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" -set "MIRROR=auto" -if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" -set "NO_MODIFY_PATH=0" -if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" -set "BASE_URL=" -if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" -set "ARCHIVE_PATH=" -if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" -set "VERSION=latest" -if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" -set "NPM_REGISTRY=https://registry.npmmirror.com" -if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" -if defined LOCALAPPDATA ( - set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" -) else ( - set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" -) -if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" -set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" -if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" -set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" -if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" - -REM Parse flags before any network or filesystem work. -:parse_args -if "%~1"=="" goto end_parse -if /i "%~1"=="--source" ( - if "%~2"=="" ( - echo ERROR: --source requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="-s" ( - if "%~2"=="" ( - echo ERROR: -s requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--method" ( - if "%~2"=="" ( - echo ERROR: --method requires a value - exit /b 1 - ) - set "METHOD=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--mirror" ( - if "%~2"=="" ( - echo ERROR: --mirror requires a value - exit /b 1 - ) - set "MIRROR=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--base-url" ( - if "%~2"=="" ( - echo ERROR: --base-url requires a value - exit /b 1 - ) - set "BASE_URL=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--archive" ( - if "%~2"=="" ( - echo ERROR: --archive requires a value - exit /b 1 - ) - set "ARCHIVE_PATH=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--version" ( - if "%~2"=="" ( - echo ERROR: --version requires a value - exit /b 1 - ) - set "VERSION=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--registry" ( - if "%~2"=="" ( - echo ERROR: --registry requires a value - exit /b 1 - ) - set "NPM_REGISTRY=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--no-modify-path" ( - set "NO_MODIFY_PATH=1" - shift - goto parse_args -) -if /i "%~1"=="-h" goto usage -if /i "%~1"=="--help" goto usage - -echo ERROR: Unknown option. -echo. -goto usage_error - -:end_parse - -call :ValidateOptions -if %ERRORLEVEL% NEQ 0 exit /b 1 - -echo =========================================== -echo Qwen Code Installation Script -echo =========================================== -echo. -echo INFO: Install method: !METHOD! -if /i not "!METHOD!"=="npm" ( - echo INFO: Standalone mirror: !MIRROR! - if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! - if not "!ARCHIVE_PATH!"=="" ( - echo INFO: Standalone archive: !ARCHIVE_PATH! - ) else ( - echo INFO: Standalone version: !VERSION! - ) -) -if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! -if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! -echo. - -REM Discover all qwen executables on disk BEFORE we install. We can't -REM reliably simulate the user's PATH ordering, so enumerate well-known -REM per-tool bin directories plus everything `where qwen` returns. -set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" -del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" -for %%c in ( - "!USERPROFILE!\.opencode\bin\qwen.cmd" - "!APPDATA!\npm\qwen.cmd" - "!USERPROFILE!\.bun\bin\qwen.cmd" - "!LOCALAPPDATA!\bun\bin\qwen.cmd" - "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" -) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" -for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( - if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" -) -set "PRE_INSTALL_QWENS_LIST=" -if exist "!PRE_INSTALL_QWENS_FILE!" ( - for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( - if "!PRE_INSTALL_QWENS_LIST!"=="" ( - set "PRE_INSTALL_QWENS_LIST=%%i" - ) else ( - echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 - if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" - ) - ) - del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -) - -REM Dispatch after validation; detect falls back to npm only when unavailable. -if /i "!METHOD!"=="standalone" ( - call :InstallStandalone - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal - exit /b 0 -) - -if /i "!METHOD!"=="npm" ( - call :InstallNpm - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" - endlocal - exit /b 0 -) - -call :InstallStandalone -set "STANDALONE_STATUS=!ERRORLEVEL!" -if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal - exit /b 0 -) - -if !STANDALONE_STATUS! EQU 2 ( - echo WARNING: Falling back to npm installation. - call :InstallNpm - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. - echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. - exit /b !ERRORLEVEL! - ) - call :PrintFinalInstructions "" - endlocal - exit /b 0 -) - -echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. -exit /b !STANDALONE_STATUS! - -:usage -call :PrintUsage -exit /b 0 - -:usage_error -call :PrintUsage -exit /b 1 - -:PrintUsage -echo Qwen Code Installer -echo. -echo Usage: install-qwen-with-source.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. -exit /b 0 - -:ValidateOptions -if "!METHOD!"=="" set "METHOD=detect" - -set "QWEN_VALIDATE_METHOD=!METHOD!" -set "QWEN_VALIDATE_MIRROR=!MIRROR!" -set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" -set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" -set "QWEN_VALIDATE_VERSION=!VERSION!" -set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" -set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" -set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -set "QWEN_VALIDATE_SOURCE=!SOURCE!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_VALIDATE_METHOD=" -set "QWEN_VALIDATE_MIRROR=" -set "QWEN_VALIDATE_BASE_URL=" -set "QWEN_VALIDATE_ARCHIVE_PATH=" -set "QWEN_VALIDATE_VERSION=" -set "QWEN_VALIDATE_NPM_REGISTRY=" -set "QWEN_VALIDATE_INSTALL_BASE=" -set "QWEN_VALIDATE_INSTALL_DIR=" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=" -set "QWEN_VALIDATE_SOURCE=" -if %PS_STATUS% NEQ 0 ( - echo ERROR: installer options contain unsafe command characters. - exit /b 1 -) - -if "!INSTALL_BASE!"=="" ( - echo ERROR: QWEN_INSTALL_ROOT must not be empty. - exit /b 1 -) -if "!INSTALL_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BIN_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok -if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok -if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok -echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. -exit /b 1 -:validate_install_base_ok -if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok -if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok -if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok -echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. -exit /b 1 -:validate_install_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok -echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. -exit /b 1 -:validate_install_bin_dir_ok - -if /i "!METHOD!"=="detect" goto validate_method_ok -if /i "!METHOD!"=="standalone" goto validate_method_ok -if /i "!METHOD!"=="npm" goto validate_method_ok -echo ERROR: --method must be detect, standalone, or npm. -exit /b 1 - -:validate_method_ok -if /i "!MIRROR!"=="github" goto validate_mirror_ok -if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok -if /i "!MIRROR!"=="auto" goto validate_mirror_ok -echo ERROR: --mirror must be auto, github, or aliyun. -exit /b 1 - -:validate_mirror_ok -call :ValidateHttpsUrlVar "BASE_URL" "--base-url" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateVersion -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateSource -exit /b %ERRORLEVEL% - -:ValidateHttpsUrlVar -set "URL_VALUE=!%~1!" -set "URL_OPTION=%~2" -if "!URL_VALUE!"=="" exit /b 0 -if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 - -echo ERROR: !URL_OPTION! must start with https:// -exit /b 1 - -:ValidateVersion -if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul -if !ERRORLEVEL! EQU 0 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 -echo ERROR: --version must be 'latest' or a semver string. -exit /b 1 - -:ValidateSource -if "!SOURCE!"=="unknown" exit /b 0 -echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 - -echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. -exit /b 1 - -:DetectTarget -set "TARGET=" -if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" -if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" -if "!TARGET!"=="" ( - echo WARNING: Standalone archive is not available for this Windows architecture. - exit /b 1 -) -exit /b 0 - -:ReleaseVersionPath -if /i "!VERSION!"=="latest" ( - set "VERSION_PATH=latest" - exit /b 0 -) -set "VERSION_PATH=!VERSION!" -if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 -set "VERSION_PATH=v!VERSION_PATH!" -exit /b 0 - -:GithubBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_GH_BASE_URL -set "QWEN_GH_REPO=QwenLM/qwen-code" -if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" -if /i "%~1"=="latest" ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" -) else ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" -) -set "QWEN_GH_REPO=" -exit /b 0 - -:AliyunBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_OSS_BASE_URL -set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" -exit /b 0 - -:RaceMirrorHead -rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url -rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH -rem fallback) keeps the PowerShell snippet small; a true parallel race adds a -rem lot of escaping for marginal speedup since OSS HEAD is sub-second when -rem reachable. Default fallback: github. -set "QWEN_RACE_TIMEOUT=%~1" -set "QWEN_RACE_GH_URL=%~2" -set "QWEN_RACE_OSS_URL=%~3" -set "QWEN_RACE_RESULT=github" -for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" -set "QWEN_RACE_TIMEOUT=" -set "QWEN_RACE_GH_URL=" -set "QWEN_RACE_OSS_URL=" -exit /b 0 - -:StandaloneBaseUrl -if not "!BASE_URL!"=="" ( - set "STANDALONE_BASE_URL=!BASE_URL!" - exit /b 0 -) - -call :ReleaseVersionPath - -if /i "!MIRROR!"=="auto" ( - call :GithubBaseUrlForVersion "!VERSION_PATH!" - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" - set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! - set "QWEN_GH_BASE_URL=" - set "QWEN_OSS_BASE_URL=" - set "QWEN_RACE_RESULT=" -) - -if /i "!MIRROR!"=="aliyun" ( - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" - set "QWEN_OSS_BASE_URL=" - exit /b 0 -) - -call :GithubBaseUrlForVersion "!VERSION_PATH!" -set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" -set "QWEN_GH_BASE_URL=" -exit /b 0 - -:MaybeUpdateUserPath -rem args: %~1=install_bin_dir -rem Prepend the install dir to the user-level PATH (HKCU\Environment) via -rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is -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.'" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_NEW_BIN=" -exit /b %PS_STATUS% - -:UrlExists -set "QWEN_CHECK_URL=%~1" -rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_CHECK_URL=" -exit /b %PS_STATUS% - -:DownloadFile -set "QWEN_DOWNLOAD_URL=%~1" -set "QWEN_DOWNLOAD_DEST=%~2" -rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). -rem ProgressPreference defaults to 'Continue' which renders a bar in interactive -rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_DOWNLOAD_URL=" -set "QWEN_DOWNLOAD_DEST=" -exit /b %PS_STATUS% - -:VerifyChecksum -set "ARCHIVE_FILE=%~1" -set "CHECKSUM_SOURCE=%~2" -set "ARCHIVE_NAME=%~3" -set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" -set "TEMP_CHECKSUM=" -set "REQUIRE_CHECKSUM=1" - -if "!CHECKSUM_FILE!"=="" ( - for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" -) else ( - if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( - set "REQUIRE_CHECKSUM=1" - set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" - call :DownloadFile "!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. - exit /b 1 - ) - set "CHECKSUM_FILE=!TEMP_CHECKSUM!" - ) -) - -if not exist "!CHECKSUM_FILE!" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: SHA256SUMS not found; cannot verify archive. - exit /b 1 - ) - echo WARNING: SHA256SUMS not found; skipping checksum verification. - exit /b 0 -) - -set "EXPECTED_HASH=" -for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( - set "CHECKSUM_HASH=%%H" - set "CHECKSUM_NAME=%%I" - if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" - if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( - if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" - ) -) - -if "!EXPECTED_HASH!"=="" ( - if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. - exit /b 1 - ) - echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. - exit /b 0 -) - -set "ACTUAL_HASH=" -set "QWEN_HASH_FILE=!ARCHIVE_FILE!" -for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( - if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" -) -set "QWEN_HASH_FILE=" - -if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - -if "!ACTUAL_HASH!"=="" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Could not calculate SHA-256 checksum for archive. - exit /b 1 - ) - echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. - exit /b 0 -) - -if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( - echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. - exit /b 1 -) - -echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. -exit /b 0 - -:InstallStandalone -set "TEMP_DIR=" -set "CHECKSUM_SOURCE=" - -REM Resolve the archive from a local file or from the configured release mirror. -if not "!ARCHIVE_PATH!"=="" ( - set "ARCHIVE_FILE=!ARCHIVE_PATH!" - for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" - if not exist "!ARCHIVE_FILE!" ( - echo ERROR: Standalone archive not found: !ARCHIVE_FILE! - exit /b 1 - ) -) else ( - call :DetectTarget - if !ERRORLEVEL! NEQ 0 exit /b 2 - - set "ARCHIVE_NAME=qwen-code-win-x64.zip" - call :StandaloneBaseUrl - set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" - set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" - - if /i "!METHOD!"=="detect" ( - call :UrlExists "!ARCHIVE_URL!" - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive not found: !ARCHIVE_NAME! - exit /b 2 - ) - ) - - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 - set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - - echo INFO: Downloading !ARCHIVE_URL! - call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo WARNING: Failed to download standalone archive. - exit /b 2 - ) -) - -if "!TEMP_DIR!"=="" ( - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 -) - -REM Verify integrity before extraction or changing the install directory. -call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Extract into a temporary directory, then validate required entry points. -set "EXTRACT_DIR=!TEMP_DIR!\extract" -call :EnsureDir "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :ValidateArchiveContents "!ARCHIVE_FILE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" -set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_ARCHIVE_FILE=" -set "QWEN_EXTRACT_DIR=" -if !PS_STATUS! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to extract standalone archive. - exit /b 1 -) - -call :RejectArchiveLinks "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\node\node.exe. - exit /b 1 -) - -call :EnsureDir "!INSTALL_BASE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureDir "!INSTALL_BIN_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" -call :EnsureDir "!INSTALL_PARENT!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Stage into .new and keep .old so failed upgrades can roll back. -set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" -set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" - -call :EnsureManagedInstallDir "!INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if exist "!NEW_INSTALL_DIR!" ( - rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. - exit /b 1 - ) -) -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to stage standalone archive. - exit /b 1 -) - -if exist "!INSTALL_DIR!" ( - move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to back up existing install at !INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. - exit /b 1 -) - -rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated -rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels -rem (`!`) and other shell-metacharacters in those values; if that validator is ever -rem loosened, the wrapper write below becomes a command injection sink. -( -echo @echo off -echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* -) > "!INSTALL_BIN_DIR!\qwen.cmd.new" -if !ERRORLEVEL! NEQ 0 ( - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) -move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) - -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! -) - -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! -exit /b 0 - -:CreateTempDir -set "TEMP_DIR=" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" -if "!TEMP_DIR!"=="" ( - echo ERROR: Failed to create a temporary directory. - exit /b 1 -) -exit /b 0 - -:EnsureDir -set "REQUIRED_DIR=%~1" -set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_REQUIRED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 -if !PS_STATUS! EQU 2 ( - echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! - exit /b 1 -) -echo ERROR: Failed to create directory: !REQUIRED_DIR! -exit /b 1 - -:ValidateArchiveContents -set "QWEN_ARCHIVE_FILE=%~1" -REM Normalize backslashes to forward slashes before checking. Some Windows -REM zip producers (including PowerShell's Compress-Archive) emit entries -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) { $name = $entry.FullName -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 -if %PS_STATUS% EQU 1 ( - echo ERROR: Archive contains unsafe path entries. - exit /b 1 -) -if %PS_STATUS% EQU 2 ( - echo ERROR: Archive could not be inspected before extraction. - exit /b 1 -) -echo ERROR: Archive validation failed before extraction. -exit /b %PS_STATUS% - -:RemoveInstalledDirWithWarning -if not exist "!INSTALL_DIR!" exit /b 0 -rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 -if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! -exit /b 0 - -:RestoreOldInstall -if not exist "!OLD_INSTALL_DIR!" exit /b 0 -move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. - exit /b 1 -) -exit /b 0 - -:RejectArchiveLinks -set "QWEN_EXTRACT_DIR=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_EXTRACT_DIR=" -if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. -exit /b %PS_STATUS% - -:EnsureManagedInstallDir -set "MANAGED_DIR=%~1" -set "QWEN_MANAGED_DIR=!MANAGED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_MANAGED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 - -echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. -echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. -exit /b 1 - -:RequireNode -where node >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Node.js was not found. - echo. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" -if "%NODE_VERSION%"=="" ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" -set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -if %NODE_MAJOR_NUM% LSS 22 ( - echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -echo SUCCESS: 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. - exit /b 1 -) - -for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" -echo SUCCESS: npm %NPM_VERSION% detected. -exit /b 0 - -:InstallNpm -call :RequireNode -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :RequireNpm -if %ERRORLEVEL% NEQ 0 exit /b 1 - -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! - echo INFO: Upgrading to the latest version. -) - -echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! -call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! - exit /b 1 -) - -echo SUCCESS: Qwen Code installed successfully. -call :CreateSourceJson -exit /b 0 - -:CreateSourceJson -if "!SOURCE!"=="unknown" exit /b 0 - -set "QWEN_DIR=!USERPROFILE!\.qwen" -call :EnsureDir "!QWEN_DIR!" -if !ERRORLEVEL! NEQ 0 exit /b 1 - -( -echo { -echo "source": "!SOURCE!" -echo } -) > "!QWEN_DIR!\source.json" - -echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json -exit /b 0 - -:PrintFinalInstructions -set "EXTRA_BIN=%~1" - -set "INSTALLED_BIN=" -if not "!EXTRA_BIN!"=="" ( - set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" - set "PATH=!EXTRA_BIN!;!PATH!" -) - -echo. -echo =========================================== -echo Installation completed! -echo =========================================== -echo. - -set "INSTALLED_VERSION=unknown" -if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( - for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" -) - -if not "!INSTALLED_BIN!"=="" ( - echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! -) else ( - echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! -) - -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!" - ) - ) - ) -) - -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. - if /i "!NO_MODIFY_PATH!"=="1" ( - echo Skipped user PATH update because --no-modify-path is set. - echo To make this install win, add this to your user PATH manually: - echo !EXTRA_BIN! - ) else ( - call :MaybeUpdateUserPath "!EXTRA_BIN!" - echo. - echo If you prefer not to modify user PATH, rerun with --no-modify-path - echo and pick one of: - echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) - echo - invoke directly: "!INSTALLED_BIN!" - ) - exit /b 0 -) - -where qwen >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. - echo. - echo Restart your command prompt, then run: qwen - if not "!EXTRA_BIN!"=="" ( - echo. - echo Or add this directory to PATH: - echo !EXTRA_BIN! - echo Then run: - echo qwen - exit /b 0 - ) - - for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" - if not "!NPM_PREFIX!"=="" ( - echo. - echo Or add this npm global directory to PATH: - echo !NPM_PREFIX! - echo Then run: - echo qwen - ) - exit /b 0 -) - -echo. -echo You can now run: qwen -echo. -echo INFO: Run qwen in your project directory to start an interactive session. -exit /b 0 +@echo off +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. + +setlocal enabledelayedexpansion + +set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=auto" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "NO_MODIFY_PATH=0" +if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" + +REM Parse flags before any network or filesystem work. +:parse_args +if "%~1"=="" goto end_parse +if /i "%~1"=="--source" ( + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-s" ( + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--method" ( + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--mirror" ( + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--base-url" ( + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--archive" ( + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--registry" ( + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--no-modify-path" ( + set "NO_MODIFY_PATH=1" + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option. +echo. +goto usage_error + +:end_parse + +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +echo =========================================== +echo Qwen Code Installation Script +echo =========================================== +echo. +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! + ) else ( + echo INFO: Standalone version: !VERSION! + ) +) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo. + +REM Discover all qwen executables on disk BEFORE we install. We can't +REM reliably simulate the user's PATH ordering, so enumerate well-known +REM per-tool bin directories plus everything `where qwen` returns. +set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" +del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for %%c in ( + "!USERPROFILE!\.opencode\bin\qwen.cmd" + "!APPDATA!\npm\qwen.cmd" + "!USERPROFILE!\.bun\bin\qwen.cmd" + "!LOCALAPPDATA!\bun\bin\qwen.cmd" + "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" +) +set "PRE_INSTALL_QWENS_LIST=" +if exist "!PRE_INSTALL_QWENS_FILE!" ( + for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( + if "!PRE_INSTALL_QWENS_LIST!"=="" ( + set "PRE_INSTALL_QWENS_LIST=%%i" + ) else ( + echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 + if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" + ) + ) + del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +) + +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +echo. +echo Usage: install-qwen-with-source.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. +exit /b 0 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +if /i "!MIRROR!"=="auto" goto validate_mirror_ok +echo ERROR: --mirror must be auto, github, or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 + +:ValidateVersion +if /i "!VERSION!"=="latest" exit /b 0 +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if !ERRORLEVEL! EQU 0 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 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:GithubBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_GH_BASE_URL +set "QWEN_GH_REPO=QwenLM/qwen-code" +if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" +if /i "%~1"=="latest" ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" +) else ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" +) +set "QWEN_GH_REPO=" +exit /b 0 + +:AliyunBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_OSS_BASE_URL +set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" +exit /b 0 + +:RaceMirrorHead +rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url +rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH +rem fallback) keeps the PowerShell snippet small; a true parallel race adds a +rem lot of escaping for marginal speedup since OSS HEAD is sub-second when +rem reachable. Default fallback: github. +set "QWEN_RACE_TIMEOUT=%~1" +set "QWEN_RACE_GH_URL=%~2" +set "QWEN_RACE_OSS_URL=%~3" +set "QWEN_RACE_RESULT=github" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_TIMEOUT=" +set "QWEN_RACE_GH_URL=" +set "QWEN_RACE_OSS_URL=" +exit /b 0 + +:StandaloneBaseUrl +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath + +if /i "!MIRROR!"=="auto" ( + call :GithubBaseUrlForVersion "!VERSION_PATH!" + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" + set "MIRROR=!QWEN_RACE_RESULT!" + echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! + set "QWEN_GH_BASE_URL=" + set "QWEN_OSS_BASE_URL=" + set "QWEN_RACE_RESULT=" +) + +if /i "!MIRROR!"=="aliyun" ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" + set "QWEN_OSS_BASE_URL=" + exit /b 0 +) + +call :GithubBaseUrlForVersion "!VERSION_PATH!" +set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" +set "QWEN_GH_BASE_URL=" +exit /b 0 + +:MaybeUpdateUserPath +rem args: %~1=install_bin_dir +rem Prepend the install dir to the user-level PATH (HKCU\Environment) via +rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is +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.'" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_NEW_BIN=" +exit /b %PS_STATUS% + +:UrlExists +set "QWEN_CHECK_URL=%~1" +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). +rem ProgressPreference defaults to 'Continue' which renders a bar in interactive +rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +set "REQUIRE_CHECKSUM=1" + +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + set "REQUIRE_CHECKSUM=1" + set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :DownloadFile "!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. + exit /b 1 + ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" + ) +) + +if not exist "!CHECKSUM_FILE!" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 + ) + echo WARNING: SHA256SUMS not found; skipping checksum verification. + exit /b 0 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 + ) + echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. + exit /b 0 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 + ) + echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. + exit /b 0 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. + exit /b 1 +) + +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +exit /b 0 + +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-win-x64.zip" + call :StandaloneBaseUrl + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + exit /b 2 + ) +) + +if "!TEMP_DIR!"=="" ( + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 +) + +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +call :EnsureDir "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +call :EnsureDir "!INSTALL_BASE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureDir "!INSTALL_BIN_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +call :EnsureDir "!INSTALL_PARENT!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated +rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels +rem (`!`) and other shell-metacharacters in those values; if that validator is ever +rem loosened, the wrapper write below becomes a command injection sink. +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) + +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! +exit /b 0 + +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:EnsureDir +set "REQUIRED_DIR=%~1" +set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_REQUIRED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 +if !PS_STATUS! EQU 2 ( + echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! + exit /b 1 +) +echo ERROR: Failed to create directory: !REQUIRED_DIR! +exit /b 1 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +REM Normalize backslashes to forward slashes before checking. Some Windows +REM zip producers (including PowerShell's Compress-Archive) emit entries +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) { $name = $entry.FullName -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 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +set "QWEN_MANAGED_DIR=!MANAGED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_MANAGED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 + +echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. +exit /b 1 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 22 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +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! + echo INFO: Upgrading to the latest version. +) + +echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! +call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +call :EnsureDir "!QWEN_DIR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" + +set "INSTALLED_BIN=" +if not "!EXTRA_BIN!"=="" ( + set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" + set "PATH=!EXTRA_BIN!;!PATH!" +) + +echo. +echo =========================================== +echo Installation completed! +echo =========================================== +echo. + +set "INSTALLED_VERSION=unknown" +if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( + for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" +) + +if not "!INSTALLED_BIN!"=="" ( + echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! +) else ( + echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! +) + +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!" + ) + ) + ) +) + +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. + if /i "!NO_MODIFY_PATH!"=="1" ( + echo Skipped user PATH update because --no-modify-path is set. + echo To make this install win, add this to your user PATH manually: + echo !EXTRA_BIN! + ) else ( + call :MaybeUpdateUserPath "!EXTRA_BIN!" + echo. + echo If you prefer not to modify user PATH, rerun with --no-modify-path + echo and pick one of: + echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) + echo - invoke directly: "!INSTALLED_BIN!" + ) + exit /b 0 +) + +where qwen >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. + echo. + echo Restart your command prompt, then run: qwen + if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 + ) + + for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" + if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen + ) + exit /b 0 +) + +echo. +echo You can now run: qwen +echo. +echo INFO: Run qwen in your project directory to start an interactive session. +exit /b 0 From 3549a34fd82143c0e98a50fe67689671a20cbd3b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 01:06:10 +0800 Subject: [PATCH 054/129] fix(installer): follow HTTP redirects in UrlExists and RaceMirrorHead probes GitHub release asset URLs return HTTP 302 to objects.githubusercontent.com. [Net.WebRequest] with HEAD does not auto-redirect by default, so the existence check and mirror-race probe both incorrectly reported the file as missing. Set AllowAutoRedirect=true on HttpWebRequest instances. --- scripts/installation/install-qwen-with-source.bat | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 6cc9d2ff829..6b028f397a0 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -409,7 +409,7 @@ set "QWEN_RACE_TIMEOUT=%~1" set "QWEN_RACE_GH_URL=%~2" set "QWEN_RACE_OSS_URL=%~3" set "QWEN_RACE_RESULT=github" -for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" set "QWEN_RACE_TIMEOUT=" set "QWEN_RACE_GH_URL=" set "QWEN_RACE_OSS_URL=" @@ -462,7 +462,8 @@ exit /b %PS_STATUS% :UrlExists set "QWEN_CHECK_URL=%~1" rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 set "PS_STATUS=%ERRORLEVEL%" set "QWEN_CHECK_URL=" exit /b %PS_STATUS% From bd7602b7758411379f6a6cd2cc7634070074d4e8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 01:15:01 +0800 Subject: [PATCH 055/129] fix(installer): surface download errors and add MaximumRedirection 10 --- scripts/installation/install-qwen-with-source.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 6b028f397a0..c70d4f519e5 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -474,7 +474,7 @@ set "QWEN_DOWNLOAD_DEST=%~2" rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). rem ProgressPreference defaults to 'Continue' which renders a bar in interactive rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { exit 1 }" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" From 68f05db51888d7121d7d2e9c276013f8d8f93e1a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 14:27:50 +0800 Subject: [PATCH 056/129] feat(installer): add hosted install-qwen.ps1 shim for irm|iex one-liner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Windows quick-install one-liner used `Invoke-WebRequest -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path …)`. When pasted into a narrow terminal, line wrap could land on `-OutFile`, orphaning the parameter from its value and producing the "missing argument for OutFile" failure followed by a "file not found" when the second `&` ran. PowerShell's line continuation rules cannot resolve this for parameter-name-at-EOL. Add `install-qwen.ps1` as a thin hosted entrypoint that downloads `install-qwen.bat` into TEMP, runs it, and cleans up. Documented one-liner becomes the standard pattern used by bun, uv, scoop, deno, pnpm: powershell -ExecutionPolicy Bypass -c "irm /install-qwen.ps1 | iex" The `.bat` remains the source of truth for installer behavior; `.ps1` is just the modern hosted entrypoint. Version pinning via `$env:QWEN_INSTALL_VERSION` flows through unchanged. Stored with `*.ps1 -text` so CRLF survives both GitHub raw and OSS uploads, matching the existing `.bat` handling. --- .gitattributes | 1 + README.md | 2 +- docs/users/overview.md | 2 +- docs/users/quickstart.md | 2 +- scripts/build-hosted-installation-assets.js | 4 ++ scripts/installation/INSTALLATION_GUIDE.md | 32 ++++++++++++---- .../installation/install-qwen-with-source.ps1 | 37 +++++++++++++++++++ scripts/tests/install-script.test.js | 6 +++ scripts/verify-installation-release.js | 7 ++-- 9 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 scripts/installation/install-qwen-with-source.ps1 diff --git a/.gitattributes b/.gitattributes index 67a751c1d52..d9472b48572 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,6 +14,7 @@ Makefile eol=lf # -text disables normalization; files must be committed with CRLF endings. *.bat -text *.cmd -text +*.ps1 -text # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. diff --git a/README.md b/README.md index fa88ea34a84..7f5711f9132 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/inst 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 -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" ``` > **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. diff --git a/docs/users/overview.md b/docs/users/overview.md index b2cbf4dc234..d529ca09b15 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -23,7 +23,7 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in **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 -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" ``` > [!note] diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 1d9fc203e7e..7d0bcfef170 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -27,7 +27,7 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in **Windows (Run as Administrator)** ```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 -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" ``` > [!note] diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 9d1c21335dd..fd006fb4146 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -31,6 +31,10 @@ const HOSTED_INSTALLATION_ASSETS = [ sourcePath: ['scripts', 'installation', 'install-qwen-with-source.bat'], output: 'install-qwen.bat', }, + { + sourcePath: ['scripts', 'installation', 'install-qwen-with-source.ps1'], + output: 'install-qwen.ps1', + }, ]; const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 131f7af3a43..e1822a2546c 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -57,6 +57,19 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --version vX.Y.Z ``` +```cmd +powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +``` + +To pin a release with the hosted PowerShell entrypoint, set the env var first: + +```powershell +$env:QWEN_INSTALL_VERSION = 'vX.Y.Z' +powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +``` + +Or download `install-qwen.bat` directly and pass `--version`: + ```powershell $installer = Join-Path $env:TEMP 'install-qwen.bat' Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer @@ -69,7 +82,9 @@ cannot be passed through. Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen.sh` is the Linux/macOS hosted entrypoint. -- `install-qwen.bat` is the Windows hosted entrypoint. +- `install-qwen.bat` is the Windows hosted entrypoint (also runnable directly). +- `install-qwen.ps1` is the Windows hosted PowerShell shim that wraps + `install-qwen.bat` so the documented one-liner can use `irm | iex`. Build them with: @@ -77,13 +92,14 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged `install-qwen.sh` and `install-qwen.bat` files map to the fixed -hosted URLs shown above. Upload their contents byte-for-byte to -`installation/install-qwen.sh` and `installation/install-qwen.bat`; the staging -command also writes `SHA256SUMS` for upload verification. The hosted installers -intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to -pin a standalone release. OSS/CDN upload automation is still a follow-up release -operation; until then, release operators must sync these staged files manually. +The staged `install-qwen.sh`, `install-qwen.bat`, and `install-qwen.ps1` files +map to the fixed hosted URLs shown above. Upload their contents byte-for-byte +to `installation/install-qwen.sh`, `installation/install-qwen.bat`, and +`installation/install-qwen.ps1`; the staging command also writes `SHA256SUMS` +for upload verification. The hosted installers intentionally default to +`latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone +release. OSS/CDN upload automation is still a follow-up release operation; +until then, release operators must sync these staged files manually. Archive layout: diff --git a/scripts/installation/install-qwen-with-source.ps1 b/scripts/installation/install-qwen-with-source.ps1 new file mode 100644 index 00000000000..8c54c27535d --- /dev/null +++ b/scripts/installation/install-qwen-with-source.ps1 @@ -0,0 +1,37 @@ +# Qwen Code Windows hosted PowerShell entrypoint. +# Pairs with install-qwen.bat: this shim downloads the .bat into TEMP and runs +# it, so the documented one-liner can use the standard `irm | iex` pattern. +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +# +# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, +# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing +# --version vX.Y.Z to install-qwen.bat directly. + +$ErrorActionPreference = 'Stop' + +$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' +$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen.bat' + +try { + Invoke-WebRequest -Uri $qwenInstallerUrl ` + -OutFile $qwenInstallerPath ` + -UseBasicParsing ` + -MaximumRedirection 10 +} catch { + Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" + exit 1 +} + +$qwenInstallerExitCode = 0 +try { + & $qwenInstallerPath + $qwenInstallerExitCode = $LASTEXITCODE +} finally { + Remove-Item -Path $qwenInstallerPath -Force -ErrorAction SilentlyContinue +} + +if ($qwenInstallerExitCode -ne 0) { + exit $qwenInstallerExitCode +} diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 844d2f8cfc7..c37efc0622c 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -635,12 +635,14 @@ describe('standalone release packaging', () => { const installSh = path.join(tmpDir, 'install-qwen.sh'); const installBat = path.join(tmpDir, 'install-qwen.bat'); + const installPs1 = path.join(tmpDir, 'install-qwen.ps1'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); const checksumLines = checksums.trim().split('\n'); expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', 'install-qwen.bat', + 'install-qwen.ps1', ]); expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, @@ -677,11 +679,15 @@ describe('standalone release packaging', () => { expect(readScript(installBat)).toBe( readScript('scripts/installation/install-qwen-with-source.bat'), ); + expect(readScript(installPs1)).toBe( + readScript('scripts/installation/install-qwen-with-source.ps1'), + ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); const checksumNames = checksumLines.map((line) => line.split(' ')[1]); expect(checksumNames).toEqual([...checksumNames].sort()); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); + expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.ps1$/m); expect(checksums).not.toMatch(/ {2}install$/m); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 3c5f3d51024..a5def7198b7 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -31,9 +31,10 @@ const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( ({ qwenTarget }) => standaloneArchiveName(qwenTarget), ); // Release artifacts that the installer chain expects in a GitHub Release. -// Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served -// from a separate hosted endpoint and are intentionally not part of this set; -// they have their own staging path in `package:hosted-installation`. +// Hosted installer scripts (install-qwen.sh / install-qwen.bat / +// install-qwen.ps1) are served from a separate hosted endpoint and are +// intentionally not part of this set; they have their own staging path in +// `package:hosted-installation`. const EXPECTED_RELEASE_ASSET_NAMES = [ ...EXPECTED_STANDALONE_ARCHIVE_NAMES, 'SHA256SUMS', From eeabd1b8dd3b08420af9b250ab398cb085bf2126 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 16:47:33 +0800 Subject: [PATCH 057/129] fix(installer): stage direct hosted install scripts --- .gitattributes | 7 - .github/workflows/release-test.yml | 63 - .gitignore | 8 - README.md | 2 +- docs/users/overview.md | 7 +- docs/users/quickstart.md | 2 +- scripts/build-hosted-installation-assets.js | 21 +- scripts/installation/INSTALLATION_GUIDE.md | 67 +- .../installation/install-qwen-with-source.ps1 | 37 - scripts/installation/install-qwen.bat | 1038 ++++++++++++++ scripts/installation/install-qwen.sh | 1225 +++++++++++++++++ scripts/tests/install-script.test.js | 51 +- scripts/verify-installation-release.js | 4 +- 13 files changed, 2334 insertions(+), 198 deletions(-) delete mode 100644 .github/workflows/release-test.yml delete mode 100644 scripts/installation/install-qwen-with-source.ps1 create mode 100644 scripts/installation/install-qwen.bat create mode 100755 scripts/installation/install-qwen.sh diff --git a/.gitattributes b/.gitattributes index d9472b48572..deab5ae88bd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,13 +9,6 @@ *.bash eol=lf Makefile eol=lf -# Windows batch scripts must be stored with CRLF in the git blob so that -# raw GitHub downloads are also CRLF (GitHub raw does not apply eol conversion). -# -text disables normalization; files must be committed with CRLF endings. -*.bat -text -*.cmd -text -*.ps1 -text - # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. *.png binary diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml deleted file mode 100644 index e8674a2da93..00000000000 --- a/.github/workflows/release-test.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: 'Release Test (Fork)' - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version, e.g. v0.0.0-pr3828-test.1' - required: true - type: 'string' - ref: - description: 'Branch or commit SHA to build from' - required: true - type: 'string' - default: 'main' - -jobs: - build_and_release: - name: 'Build Standalone Archives and Release' - runs-on: 'ubuntu-latest' - permissions: - contents: 'write' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@v4' - with: - ref: '${{ github.event.inputs.ref }}' - - - name: 'Setup Node.js' - uses: 'actions/setup-node@v4' - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: 'Install dependencies' - run: 'npm ci' - - - name: 'Build bundle' - run: 'npm run build && npm run bundle' - - - name: 'Build Standalone Archives (all platforms)' - env: - RELEASE_VERSION: '${{ github.event.inputs.version }}' - run: | - VERSION="${RELEASE_VERSION#v}" - npm run package:standalone:release -- --version "${VERSION}" --out-dir dist/standalone - - - name: 'Verify release assets' - run: 'npm run verify:installation-release -- --dir dist/standalone' - - - name: 'Create GitHub Release' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_TAG: '${{ github.event.inputs.version }}' - run: | - gh release create "${RELEASE_TAG}" \ - dist/standalone/qwen-code-*.tar.gz \ - dist/standalone/qwen-code-*.zip \ - dist/standalone/SHA256SUMS \ - --target '${{ github.event.inputs.ref }}' \ - --title "Test Release ${RELEASE_TAG}" \ - --notes "Installer test build for PR #3828. Not for production use." \ - --prerelease diff --git a/.gitignore b/.gitignore index 59895561c42..90d51cfdf4e 100644 --- a/.gitignore +++ b/.gitignore @@ -97,11 +97,3 @@ tmp/ # code graph skills .venv .codegraph -# Standalone release build artifacts (generated by scripts/build-standalone-release.js) -release-staging/ - -# Hosted installation assets staging (generated by scripts/build-hosted-installation-assets.js) -hosted-staging/ - -# Local-only staging tools (PR #3828) — staging OSS upload, not for production -/tools/ diff --git a/README.md b/README.md index a13233a8406..a0a95c88b11 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/inst Works in both Command Prompt and PowerShell: ```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +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')" ``` > **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. diff --git a/docs/users/overview.md b/docs/users/overview.md index d529ca09b15..a40753d7605 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -9,9 +9,8 @@ ### Install Qwen Code: -After the hosted installer endpoint is synced with the latest installer assets, -the recommended installer uses a standalone archive when one is available for -your platform. If it falls back to npm, Node.js 22 or later with npm must be +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 available on PATH. **Linux / macOS** @@ -23,7 +22,7 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in **Windows** ```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +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')" ``` > [!note] diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 7d0bcfef170..1d9fc203e7e 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -27,7 +27,7 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in **Windows (Run as Administrator)** ```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +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')" ``` > [!note] diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index fd006fb4146..403d6c48c0e 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -23,17 +23,14 @@ const rootDir = path.resolve(__dirname, '..'); const HOSTED_INSTALLATION_ASSETS = [ { - sourcePath: ['scripts', 'installation', 'install-qwen-with-source.sh'], + sourcePath: ['scripts', 'installation', 'install-qwen.sh'], output: 'install-qwen.sh', mode: 0o755, }, { - sourcePath: ['scripts', 'installation', 'install-qwen-with-source.bat'], + sourcePath: ['scripts', 'installation', 'install-qwen.bat'], output: 'install-qwen.bat', - }, - { - sourcePath: ['scripts', 'installation', 'install-qwen-with-source.ps1'], - output: 'install-qwen.ps1', + lineEndings: 'crlf', }, ]; const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( @@ -113,7 +110,7 @@ async function buildHostedInstallationAssets(outDir, options = {}) { assertHostedInstallerSource(source, asset.output); const destination = path.join(outDir, asset.output); - fs.copyFileSync(source, destination); + copyHostedInstallationAsset(source, destination, asset); if (asset.mode !== undefined) { fs.chmodSync(destination, asset.mode); } @@ -134,6 +131,16 @@ function assertNoUnexpectedHostedFiles(outDir) { } } +function copyHostedInstallationAsset(source, destination, asset) { + if (asset.lineEndings === 'crlf') { + const contents = fs.readFileSync(source, 'utf8'); + fs.writeFileSync(destination, contents.replace(/\r?\n/g, '\r\n')); + return; + } + + fs.copyFileSync(source, destination); +} + function assertHostedInstallerSource(source, output) { const contents = fs.readFileSync(source, 'utf8'); const missing = HOSTED_INSTALLER_REQUIRED_FRAGMENTS.filter( diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index e1822a2546c..33607431c9f 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -22,8 +22,8 @@ are only required when the installer falls back to npm or when ## Installation Scripts -- Linux/macOS: `install-qwen-with-source.sh` -- Windows: `install-qwen-with-source.bat` +- Linux/macOS: `install-qwen.sh` +- Windows: `install-qwen.bat` ## Release Artifacts @@ -36,8 +36,8 @@ GitHub releases publish these standalone archives: - `qwen-code-win-x64.zip` - `SHA256SUMS` -The installer scripts (`install-qwen-with-source.sh`, -`install-qwen-with-source.bat`) are not republished per release. They are +The installer scripts (`install-qwen.sh`, +`install-qwen.bat`) are not republished per release. They are served from a hosted installation endpoint and accept `--version` to pin a specific standalone release. This keeps the public install command on a stable hosted entrypoint while still allowing version pinning, rather than using @@ -47,7 +47,7 @@ per-release installer URLs. > next release, the URL below still serves the legacy NVM-based installer, > which does not honor `--version` or `QWEN_INSTALL_VERSION` in the way > documented here. To get the standalone-archive-first behavior immediately, -> run `install-qwen-with-source.sh` from a local checkout of this repository. +> run `install-qwen.sh` from a local checkout of this repository. > The `--version` examples below describe the post-sync behavior. Latest hosted entrypoints used today: @@ -58,17 +58,11 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in ``` ```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +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')" ``` -To pin a release with the hosted PowerShell entrypoint, set the env var first: - -```powershell -$env:QWEN_INSTALL_VERSION = 'vX.Y.Z' -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" -``` - -Or download `install-qwen.bat` directly and pass `--version`: +To pin a release with the hosted Windows entrypoint, download `install-qwen.bat` +and pass `--version`: ```powershell $installer = Join-Path $env:TEMP 'install-qwen.bat' @@ -83,8 +77,6 @@ Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen.sh` is the Linux/macOS hosted entrypoint. - `install-qwen.bat` is the Windows hosted entrypoint (also runnable directly). -- `install-qwen.ps1` is the Windows hosted PowerShell shim that wraps - `install-qwen.bat` so the documented one-liner can use `irm | iex`. Build them with: @@ -92,14 +84,13 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged `install-qwen.sh`, `install-qwen.bat`, and `install-qwen.ps1` files -map to the fixed hosted URLs shown above. Upload their contents byte-for-byte -to `installation/install-qwen.sh`, `installation/install-qwen.bat`, and -`installation/install-qwen.ps1`; the staging command also writes `SHA256SUMS` -for upload verification. The hosted installers intentionally default to -`latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone -release. OSS/CDN upload automation is still a follow-up release operation; -until then, release operators must sync these staged files manually. +The staged `install-qwen.sh` and `install-qwen.bat` files map to the fixed +hosted URLs shown above. Upload their contents byte-for-byte to +`installation/install-qwen.sh` and `installation/install-qwen.bat`; the staging +command also writes `SHA256SUMS` for upload verification. The hosted installers +intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to +pin a standalone release. OSS/CDN upload automation is still a follow-up release +operation; until then, release operators must sync these staged files manually. Archive layout: @@ -127,13 +118,13 @@ The default method is `detect`: You can force a method: ```bash -bash install-qwen-with-source.sh --method standalone -bash install-qwen-with-source.sh --method npm +bash install-qwen.sh --method standalone +bash install-qwen.sh --method npm ``` ```bat -install-qwen-with-source.bat --method standalone -install-qwen-with-source.bat --method npm +install-qwen.bat --method standalone +install-qwen.bat --method npm ``` ## Optional Native Modules @@ -151,20 +142,20 @@ modules for the current machine. ```bash # Default: standalone archive with npm fallback -bash install-qwen-with-source.sh +bash install-qwen.sh # Record a source value -bash install-qwen-with-source.sh --source github +bash install-qwen.sh --source github # Use npm explicitly -bash install-qwen-with-source.sh --method npm --registry https://registry.npmjs.org +bash install-qwen.sh --method npm --registry https://registry.npmjs.org # Use the Aliyun standalone mirror -bash install-qwen-with-source.sh --mirror aliyun +bash install-qwen.sh --mirror aliyun # Install an offline archive # SHA256SUMS must be in the same directory. -bash install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz +bash install-qwen.sh --archive ./qwen-code-linux-x64.tar.gz ``` Standalone installs to: @@ -179,20 +170,20 @@ Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, ```bat REM Default: standalone archive with npm fallback -install-qwen-with-source.bat +install-qwen.bat REM Record a source value -install-qwen-with-source.bat --source github +install-qwen.bat --source github REM Use npm explicitly -install-qwen-with-source.bat --method npm --registry https://registry.npmjs.org +install-qwen.bat --method npm --registry https://registry.npmjs.org REM Use the Aliyun standalone mirror -install-qwen-with-source.bat --mirror aliyun +install-qwen.bat --mirror aliyun REM Install an offline archive REM SHA256SUMS must be in the same directory. -install-qwen-with-source.bat --archive qwen-code-win-x64.zip +install-qwen.bat --archive qwen-code-win-x64.zip ``` Standalone installs to: diff --git a/scripts/installation/install-qwen-with-source.ps1 b/scripts/installation/install-qwen-with-source.ps1 deleted file mode 100644 index 8c54c27535d..00000000000 --- a/scripts/installation/install-qwen-with-source.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -# Qwen Code Windows hosted PowerShell entrypoint. -# Pairs with install-qwen.bat: this shim downloads the .bat into TEMP and runs -# it, so the documented one-liner can use the standard `irm | iex` pattern. -# -# Usage: -# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" -# -# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, -# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing -# --version vX.Y.Z to install-qwen.bat directly. - -$ErrorActionPreference = 'Stop' - -$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen.bat' - -try { - Invoke-WebRequest -Uri $qwenInstallerUrl ` - -OutFile $qwenInstallerPath ` - -UseBasicParsing ` - -MaximumRedirection 10 -} catch { - Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" - exit 1 -} - -$qwenInstallerExitCode = 0 -try { - & $qwenInstallerPath - $qwenInstallerExitCode = $LASTEXITCODE -} finally { - Remove-Item -Path $qwenInstallerPath -Force -ErrorAction SilentlyContinue -} - -if ($qwenInstallerExitCode -ne 0) { - exit $qwenInstallerExitCode -} diff --git a/scripts/installation/install-qwen.bat b/scripts/installation/install-qwen.bat new file mode 100644 index 00000000000..d1c52ebd527 --- /dev/null +++ b/scripts/installation/install-qwen.bat @@ -0,0 +1,1038 @@ +@echo off +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. + +setlocal enabledelayedexpansion + +set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=auto" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "NO_MODIFY_PATH=0" +if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" + +REM Parse flags before any network or filesystem work. +:parse_args +if "%~1"=="" goto end_parse +if /i "%~1"=="--source" ( + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-s" ( + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--method" ( + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--mirror" ( + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--base-url" ( + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--archive" ( + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--registry" ( + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--no-modify-path" ( + set "NO_MODIFY_PATH=1" + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option. +echo. +goto usage_error + +:end_parse + +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +echo =========================================== +echo Qwen Code Installation Script +echo =========================================== +echo. +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! + ) else ( + echo INFO: Standalone version: !VERSION! + ) +) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo. + +REM Discover all qwen executables on disk BEFORE we install. We can't +REM reliably simulate the user's PATH ordering, so enumerate well-known +REM per-tool bin directories plus everything `where qwen` returns. +set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" +del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for %%c in ( + "!USERPROFILE!\.opencode\bin\qwen.cmd" + "!APPDATA!\npm\qwen.cmd" + "!USERPROFILE!\.bun\bin\qwen.cmd" + "!LOCALAPPDATA!\bun\bin\qwen.cmd" + "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" +) +set "PRE_INSTALL_QWENS_LIST=" +if exist "!PRE_INSTALL_QWENS_FILE!" ( + for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( + if "!PRE_INSTALL_QWENS_LIST!"=="" ( + set "PRE_INSTALL_QWENS_LIST=%%i" + ) else ( + echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 + if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" + ) + ) + del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +) + +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +echo. +echo Usage: install-qwen.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. +exit /b 0 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +if /i "!MIRROR!"=="auto" goto validate_mirror_ok +echo ERROR: --mirror must be auto, github, or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 + +:ValidateVersion +if /i "!VERSION!"=="latest" exit /b 0 +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if !ERRORLEVEL! EQU 0 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 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:GithubBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_GH_BASE_URL +set "QWEN_GH_REPO=QwenLM/qwen-code" +if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" +if /i "%~1"=="latest" ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" +) else ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" +) +set "QWEN_GH_REPO=" +exit /b 0 + +:AliyunBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_OSS_BASE_URL +set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" +exit /b 0 + +:RaceMirrorHead +rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url +rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH +rem fallback) keeps the PowerShell snippet small; a true parallel race adds a +rem lot of escaping for marginal speedup since OSS HEAD is sub-second when +rem reachable. Default fallback: github. +set "QWEN_RACE_TIMEOUT=%~1" +set "QWEN_RACE_GH_URL=%~2" +set "QWEN_RACE_OSS_URL=%~3" +set "QWEN_RACE_RESULT=github" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_TIMEOUT=" +set "QWEN_RACE_GH_URL=" +set "QWEN_RACE_OSS_URL=" +exit /b 0 + +:StandaloneBaseUrl +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath + +if /i "!MIRROR!"=="auto" ( + call :GithubBaseUrlForVersion "!VERSION_PATH!" + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" + set "MIRROR=!QWEN_RACE_RESULT!" + echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! + set "QWEN_GH_BASE_URL=" + set "QWEN_OSS_BASE_URL=" + set "QWEN_RACE_RESULT=" +) + +if /i "!MIRROR!"=="aliyun" ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" + set "QWEN_OSS_BASE_URL=" + exit /b 0 +) + +call :GithubBaseUrlForVersion "!VERSION_PATH!" +set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" +set "QWEN_GH_BASE_URL=" +exit /b 0 + +:MaybeUpdateUserPath +rem args: %~1=install_bin_dir +rem Prepend the install dir to the user-level PATH (HKCU\Environment) via +rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is +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.'" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_NEW_BIN=" +exit /b %PS_STATUS% + +:UrlExists +set "QWEN_CHECK_URL=%~1" +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). +rem ProgressPreference defaults to 'Continue' which renders a bar in interactive +rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +set "REQUIRE_CHECKSUM=1" + +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + set "REQUIRE_CHECKSUM=1" + set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :DownloadFile "!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. + exit /b 1 + ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" + ) +) + +if not exist "!CHECKSUM_FILE!" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 + ) + echo WARNING: SHA256SUMS not found; skipping checksum verification. + exit /b 0 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 + ) + echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. + exit /b 0 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 + ) + echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. + exit /b 0 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. + exit /b 1 +) + +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +exit /b 0 + +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-win-x64.zip" + call :StandaloneBaseUrl + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + exit /b 2 + ) +) + +if "!TEMP_DIR!"=="" ( + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 +) + +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +call :EnsureDir "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +call :EnsureDir "!INSTALL_BASE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureDir "!INSTALL_BIN_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +call :EnsureDir "!INSTALL_PARENT!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated +rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels +rem (`!`) and other shell-metacharacters in those values; if that validator is ever +rem loosened, the wrapper write below becomes a command injection sink. +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) + +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! +exit /b 0 + +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:EnsureDir +set "REQUIRED_DIR=%~1" +set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_REQUIRED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 +if !PS_STATUS! EQU 2 ( + echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! + exit /b 1 +) +echo ERROR: Failed to create directory: !REQUIRED_DIR! +exit /b 1 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +REM Normalize backslashes to forward slashes before checking. Some Windows +REM zip producers (including PowerShell's Compress-Archive) emit entries +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) { $name = $entry.FullName -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 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +set "QWEN_MANAGED_DIR=!MANAGED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_MANAGED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 + +echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. +exit /b 1 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 22 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +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! + echo INFO: Upgrading to the latest version. +) + +echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! +call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +call :EnsureDir "!QWEN_DIR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" + +set "INSTALLED_BIN=" +if not "!EXTRA_BIN!"=="" ( + set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" + set "PATH=!EXTRA_BIN!;!PATH!" +) + +echo. +echo =========================================== +echo Installation completed! +echo =========================================== +echo. + +set "INSTALLED_VERSION=unknown" +if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( + for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" +) + +if not "!INSTALLED_BIN!"=="" ( + echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! +) else ( + echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! +) + +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!" + ) + ) + ) +) + +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. + if /i "!NO_MODIFY_PATH!"=="1" ( + echo Skipped user PATH update because --no-modify-path is set. + echo To make this install win, add this to your user PATH manually: + echo !EXTRA_BIN! + ) else ( + call :MaybeUpdateUserPath "!EXTRA_BIN!" + echo. + echo If you prefer not to modify user PATH, rerun with --no-modify-path + echo and pick one of: + echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) + echo - invoke directly: "!INSTALLED_BIN!" + ) + exit /b 0 +) + +where qwen >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. + echo. + echo Restart your command prompt, then run: qwen + if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 + ) + + for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" + if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen + ) + exit /b 0 +) + +echo. +echo You can now run: qwen +echo. +echo INFO: Run qwen in your project directory to start an interactive session. +exit /b 0 diff --git a/scripts/installation/install-qwen.sh b/scripts/installation/install-qwen.sh new file mode 100755 index 00000000000..7890666eace --- /dev/null +++ b/scripts/installation/install-qwen.sh @@ -0,0 +1,1225 @@ +#!/usr/bin/env bash + +# Qwen Code Installation Script +# Installs Qwen Code from a standalone archive when available, with npm fallback. +# This script intentionally does not install Node.js or change npm config. +# +# Usage: +# install-qwen.sh --source [github|npm|internal|local-build] +# install-qwen.sh --method [detect|standalone|npm] + +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then + if command -v bash >/dev/null 2>&1; then + if [ -f "${0}" ]; then + export __QWEN_INSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the installer with: curl ... | bash" + exit 1 + fi + + echo "Error: This script requires bash. Please install bash first." + exit 1 +fi + +set -eo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + echo -e "${BLUE}INFO:${NC} $1" +} + +log_success() { + echo -e "${GREEN}SUCCESS:${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}WARNING:${NC} $1" +} + +log_error() { + echo -e "${RED}ERROR:${NC} $1" +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +TEMP_DIRS=() + +cleanup_temp_dirs() { + local temp_dir + for temp_dir in "${TEMP_DIRS[@]}"; do + if [[ -n "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi + done +} + +register_temp_dir() { + local temp_dir="$1" + TEMP_DIRS+=("${temp_dir}") +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +trap cleanup_temp_dirs EXIT +trap 'cleanup_temp_dirs; exit 130' INT +trap 'cleanup_temp_dirs; exit 143' TERM + +print_usage() { + cat </dev/null || echo unknown) $(uname -r 2>/dev/null || true)" + log_info "Install method: ${METHOD}" + if [[ "${METHOD}" != "npm" ]]; then + log_info "Standalone mirror: ${MIRROR}" + if [[ -n "${BASE_URL}" ]]; then + log_info "Standalone base URL: ${BASE_URL}" + fi + if [[ -n "${ARCHIVE_PATH}" ]]; then + log_info "Standalone archive: ${ARCHIVE_PATH}" + else + log_info "Standalone version: ${VERSION}" + fi + fi + if [[ "${METHOD}" != "standalone" ]]; then + log_info "npm registry: ${NPM_REGISTRY}" + fi + if [[ "${SOURCE}" != "unknown" ]]; then + log_info "Installation source: ${SOURCE}" + fi + echo "" +} + +print_node_help() { + echo "" + echo "Node.js 22 or newer is required before installing Qwen Code with npm." + echo "" + echo "Install Node.js, then rerun this installer:" + case "$(uname -s 2>/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." +} + +require_node() { + if ! command_exists node; then + log_error "Node.js was not found." + print_node_help + return 1 + fi + + local node_version + node_version=$(node -p "process.versions.node" 2>/dev/null || true) + local node_major + node_major=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || true) + + if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then + log_error "Unable to determine Node.js version." + print_node_help + return 1 + fi + + if [[ "${node_major}" -lt 22 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 22 or newer is required." + 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." + return 1 +} + +get_npm_global_bin() { + local prefix + prefix=$(npm prefix -g 2>/dev/null || true) + + if [[ -z "${prefix}" ]]; then + return 0 + fi + + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*) + echo "${prefix}" + ;; + *) + echo "${prefix}/bin" + ;; + esac +} + +create_source_json() { + if [[ "${SOURCE}" == "unknown" ]]; then + return 0 + fi + + local qwen_dir="${HOME}/.qwen" + mkdir -p "${qwen_dir}" + + local escaped_source + escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') + + cat > "${qwen_dir}/source.json" </dev/null || echo unknown) + local arch + arch=$(uname -m 2>/dev/null || echo unknown) + + case "${os}" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + return 1 + ;; + esac + + case "${arch}" in + x86_64|amd64) + arch="x64" + ;; + arm64|aarch64) + arch="arm64" + ;; + *) + return 1 + ;; + esac + + echo "${os}-${arch}" +} + +archive_extension_for_target() { + case "$1" in + darwin-*|linux-*) + echo "tar.gz" + ;; + *) + return 1 + ;; + esac +} + +release_version_path() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + case "${VERSION}" in + v*) + echo "${VERSION}" + ;; + *) + echo "v${VERSION}" + ;; + esac +} + +# When a shadowing 'qwen' is detected, append a PATH prepend to the user's +# shell rc file at the very end. Putting it at the END means our prepend runs +# AFTER any earlier PATH munging in the rc file (e.g., other tools' shell +# init), so our installed_bin wins. Idempotent via a marker comment. +maybe_update_shell_path() { + local install_bin_dir="$1" + + [[ "${NO_MODIFY_PATH:-0}" == "1" ]] && return 0 + [[ -z "${install_bin_dir}" ]] && return 0 + [[ -z "${HOME:-}" ]] && return 0 + + local rc_file="" + case "${SHELL:-}" in + */zsh) rc_file="${HOME}/.zshrc" ;; + */bash) + if [[ -f "${HOME}/.bashrc" ]]; then + rc_file="${HOME}/.bashrc" + elif [[ -f "${HOME}/.bash_profile" ]]; then + rc_file="${HOME}/.bash_profile" + else + rc_file="${HOME}/.bashrc" + fi + ;; + */fish) rc_file="${HOME}/.config/fish/config.fish" ;; + *) rc_file="${HOME}/.profile" ;; + esac + + [[ -z "${rc_file}" ]] && return 0 + + local marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local export_line + if [[ "${rc_file}" == *config.fish ]]; then + export_line="set -gx PATH ${install_bin_dir} \$PATH" + else + export_line="export PATH=\"${install_bin_dir}:\$PATH\"" + fi + + if [[ -f "${rc_file}" ]] && grep -qF "${marker}" "${rc_file}" 2>/dev/null; then + log_info "PATH update already present in ${rc_file} (skipping)." + return 0 + fi + + mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true + { + echo "" + echo "${marker}" + echo "${export_line}" + } >> "${rc_file}" || { + log_warning "Could not write PATH update to ${rc_file}." + return 0 + } + + log_success "Appended PATH prepend to ${rc_file}" + log_info "Open a new terminal, or run: source ${rc_file}" +} + +github_base_url_for_version() { + local version_path="$1" + local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}" + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/${github_repo}/releases/latest/download" + else + echo "https://github.com/${github_repo}/releases/download/${version_path}" + fi +} + +aliyun_base_url_for_version() { + local version_path="$1" + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" +} + +# Race two HEAD probes; print "aliyun" or "github" based on which mirror's +# SHA256SUMS responds first. Default to github if both time out. +race_mirror_head() { + local timeout="${1:-2}" + local gh_url="$2" + local oss_url="$3" + local tmpdir + tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" + mkdir -p "${tmpdir}" 2>/dev/null || true + + (curl -fsI -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & + local oss_pid=$! + (curl -fsI -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & + local gh_pid=$! + + local winner="" + local elapsed=0 + local max=$((timeout * 10 + 5)) + while [[ -z "${winner}" && "${elapsed}" -lt "${max}" ]]; do + # Probe OSS first to break ties in favor of the closer mirror for CN users. + [[ -e "${tmpdir}/aliyun" ]] && winner="aliyun" && break + [[ -e "${tmpdir}/github" ]] && winner="github" && break + sleep 0.1 + elapsed=$((elapsed + 1)) + done + + kill "${oss_pid}" "${gh_pid}" 2>/dev/null || true + wait "${oss_pid}" "${gh_pid}" 2>/dev/null || true + rm -rf "${tmpdir}" 2>/dev/null || true + + echo "${winner:-github}" +} + +standalone_base_url() { + if [[ -n "${BASE_URL}" ]]; then + echo "${BASE_URL%/}" + return 0 + fi + + local version_path + version_path=$(release_version_path) + + if [[ "${MIRROR}" == "auto" ]]; then + local gh_head oss_head selected + gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" + oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" + selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") + log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 + MIRROR="${selected}" + fi + + if [[ "${MIRROR}" == "aliyun" ]]; then + aliyun_base_url_for_version "${version_path}" + return 0 + fi + + github_base_url_for_version "${version_path}" +} + +download_file() { + local url="$1" + local destination="$2" + + # Show progress only when stderr is a terminal (so CI / non-tty stays clean). + # `curl ... | bash` keeps stderr connected to the user's terminal even though + # stdin is a pipe, so progress shows correctly during normal usage. + local show_progress=0 + if [ -t 2 ] && [ "${QWEN_INSTALL_QUIET:-0}" != "1" ]; then + show_progress=1 + fi + + if command_exists curl; then + if [ "${show_progress}" = "1" ]; then + curl -fL --progress-bar --retry 2 "${url}" -o "${destination}" + else + curl -fsSL --retry 2 "${url}" -o "${destination}" + fi + return $? + fi + + if command_exists wget; then + if [ "${show_progress}" = "1" ]; then + wget --show-progress --tries=3 "${url}" -O "${destination}" || return 1 + else + wget -q --tries=3 "${url}" -O "${destination}" || return 1 + fi + return $? + fi + + log_error "curl or wget is required to download the standalone archive." + return 1 +} + +url_exists() { + local url="$1" + + if command_exists curl; then + curl -fsIL --retry 1 "${url}" >/dev/null 2>&1 + return $? + fi + + if command_exists wget; then + wget -q --spider "${url}" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +sha256_file() { + local file_path="$1" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print $1}' + return 0 + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print $1}' + return 0 + fi + + return 1 +} + +verify_checksum() { + local archive_path="$1" + local checksum_source="$2" + local archive_name="$3" + local checksum_file="${checksum_source}" + local temp_checksum="" + + if [[ -z "${checksum_file}" ]]; then + checksum_file="$(dirname "${archive_path}")/SHA256SUMS" + elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then + temp_checksum="$(mktemp)" + if ! download_file "${checksum_file}" "${temp_checksum}"; then + rm -f "${temp_checksum}" + log_error "Could not download SHA256SUMS for checksum verification." + return 1 + fi + checksum_file="${temp_checksum}" + fi + + if [[ ! -f "${checksum_file}" ]]; then + rm -f "${temp_checksum}" + log_error "SHA256SUMS not found; cannot verify archive." + return 1 + fi + + local expected + expected=$(awk -v archive_name="${archive_name}" ' + { + name = $2 + sub(/^\*/, "", name) + if (name == archive_name) { + print $1 + exit + } + } + ' "${checksum_file}") + if [[ -z "${expected}" ]]; then + rm -f "${temp_checksum}" + log_error "Checksum entry for ${archive_name} not found." + return 1 + fi + + local actual + if ! actual=$(sha256_file "${archive_path}"); then + rm -f "${temp_checksum}" + log_error "No SHA-256 utility found; cannot verify archive." + return 1 + fi + + rm -f "${temp_checksum}" + + if [[ "${expected}" != "${actual}" ]]; then + log_error "Checksum verification failed for ${archive_name}." + return 1 + fi + + log_success "Checksum verified for ${archive_name}." +} + +validate_archive_entry_path() { + local entry="$1" + + while [[ "${entry}" == ./* ]]; do + entry="${entry#./}" + done + + # Reject entries containing CR/LF so a `..\r` or `..\n` entry cannot + # bypass the literal `..` glob below. + case "${entry}" in + *$'\r'*|*$'\n'*) + log_error "Archive contains unsafe path with control character: ${entry}" + return 1 + ;; + esac + + case "${entry}" in + ""|/*|..|../*|*/..|*/../*|*\\*) + log_error "Archive contains unsafe path: ${entry:-}" + return 1 + ;; + esac +} + +validate_archive_contents() { + local archive_path="$1" + local entries + local entry + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to inspect ${archive_path}." + return 1 + fi + if ! entries=$(unzip -Z1 "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *.tar.gz|*.tgz|*.tar.xz) + if ! entries=$(tar -tf "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + while IFS= read -r entry; do + validate_archive_entry_path "${entry}" || return 1 + done <<< "${entries}" +} + +extract_archive() { + local archive_path="$1" + local destination="$2" + + mkdir -p "${destination}" || return 1 + validate_archive_contents "${archive_path}" || return 1 + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to extract ${archive_path}." + return 1 + fi + unzip -q "${archive_path}" -d "${destination}" || return 1 + ;; + *.tar.gz|*.tgz) + tar -xzf "${archive_path}" -C "${destination}" || return 1 + ;; + *.tar.xz) + tar -xf "${archive_path}" -C "${destination}" || return 1 + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + local symlink_entry + symlink_entry=$(find "${destination}" -type l -print | sed -n '1p') + if [[ -n "${symlink_entry}" ]]; then + log_error "Archive contains symlinks; refusing to install." + return 1 + fi +} + +ensure_managed_install_dir() { + local install_dir="$1" + + if [[ ! -e "${install_dir}" ]]; then + return 0 + fi + + if is_qwen_standalone_install_dir "${install_dir}"; then + return 0 + fi + + log_error "${install_dir} exists but is not a Qwen Code standalone install." + log_error "Refusing to overwrite it. Move or remove it manually, then rerun the installer." + return 1 +} + +is_qwen_standalone_install_dir() { + local install_dir="$1" + local manifest_path="${install_dir}/manifest.json" + + [[ -f "${manifest_path}" ]] || return 1 + # Manifest format is produced by writeManifest in create-standalone-package.js. + # Keep these grep checks in sync if that JSON layout changes. + grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 + grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 + [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 + [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 +} + +write_unix_wrapper() { + local wrapper_path="$1" + local qwen_bin="$2" + local quoted_qwen_bin + quoted_qwen_bin=$(shell_quote "${qwen_bin}") + + cat > "${wrapper_path}" </dev/null || echo "unknown") + log_info "Existing Qwen Code detected: ${qwen_version}" + log_info "Upgrading to the latest version." + fi + + local install_cmd=( + npm + install + -g + @qwen-code/qwen-code@latest + --registry + "${NPM_REGISTRY}" + ) + + log_info "Running: npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" + return 1 +} + +print_final_instructions() { + local install_bin_dir="${1:-}" + local installed_bin="" + if [[ -n "${install_bin_dir}" ]]; then + installed_bin="${install_bin_dir}/qwen" + 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. + local other_qwens="" + if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then + local saved_ifs="${IFS}" + IFS=$'\n' + local path + for path in ${PRE_INSTALL_QWENS}; do + [[ -z "${path}" ]] && continue + [[ -n "${installed_bin}" && "${path}" == "${installed_bin}" ]] && continue + if [[ -z "${other_qwens}" ]]; then + other_qwens="${path}" + else + other_qwens="${other_qwens}"$'\n'"${path}" + fi + done + IFS="${saved_ifs}" + fi + + if [[ -n "${install_bin_dir}" ]]; then + export PATH="${install_bin_dir}:${PATH}" + fi + + echo "" + echo "==========================================" + echo "Installation completed!" + echo "==========================================" + echo "" + + local installed_version="unknown" + if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then + installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") + elif command_exists qwen; then + installed_version=$(qwen --version 2>/dev/null || echo "unknown") + fi + + if [[ -n "${installed_bin}" ]]; then + log_success "Installed at ${installed_bin}: ${installed_version}" + else + log_success "Qwen Code installed: ${installed_version}" + 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 "" + if [[ "${NO_MODIFY_PATH:-0}" == "1" ]]; then + echo "Skipped shell rc update because --no-modify-path is set." + echo "To make this install win, manually add to your shell rc:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + else + maybe_update_shell_path "${install_bin_dir}" + echo "" + echo "If you prefer not to modify the shell rc, rerun with --no-modify-path" + echo "and pick one of:" + echo " - npm uninstall -g @qwen-code/qwen-code # if the shadow is an npm install" + echo " - invoke directly: ${installed_bin}" + fi + return 0 + fi + + if ! command_exists qwen; then + log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." + echo "" + echo "Restart your terminal, then run: qwen" + if [[ -n "${install_bin_dir}" ]]; then + echo "" + echo "Or run this in the current shell:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " qwen" + fi + return 0 + fi + + echo "" + echo "You can now run: qwen" + echo "" + log_info "Run qwen in your project directory to start an interactive session." +} + +main() { + if [[ -z "${HOME:-}" ]]; then + log_error "HOME is not set; cannot determine where to install Qwen Code." + exit 1 + fi + + # Discover all qwen executables on disk BEFORE we install, so the + # just-installed binary doesn't pollute the search. We can't reliably + # simulate the user's interactive shell PATH (some tools inject their + # bin only under a tty), so we enumerate well-known per-tool bin + # directories plus whatever bash inherited on PATH. + PRE_INSTALL_QWENS=$( + { + IFS=: + for dir in $PATH; do + [[ -z "${dir}" ]] && continue + [[ -x "${dir}/qwen" ]] && echo "${dir}/qwen" + done + for candidate in \ + "${HOME}/.opencode/bin/qwen" \ + "${HOME}/.bun/bin/qwen" \ + "${HOME}/.cargo/bin/qwen" \ + "${HOME}/.deno/bin/qwen" \ + "${HOME}/.volta/bin/qwen" \ + "${HOME}/.fnm/bin/qwen" \ + "${HOME}/.local/bin/qwen" \ + "${HOME}/Library/pnpm/qwen" \ + "/usr/local/bin/qwen" \ + "/opt/homebrew/bin/qwen"; do + [[ -x "${candidate}" ]] && echo "${candidate}" + done + if command_exists npm; then + local npm_prefix + npm_prefix=$(npm prefix -g 2>/dev/null || true) + if [[ -n "${npm_prefix}" && -x "${npm_prefix}/bin/qwen" ]]; then + echo "${npm_prefix}/bin/qwen" + fi + fi + } 2>/dev/null | sort -u + ) + export PRE_INSTALL_QWENS + + print_header + + case "${METHOD}" in + standalone) + install_standalone + print_final_instructions "${INSTALL_BIN_DIR}" + ;; + npm) + install_npm + print_final_instructions "$(get_npm_global_bin)" + ;; + detect) + # Try the standalone archive first; fall back only when unavailable. + if install_standalone; then + print_final_instructions "${INSTALL_BIN_DIR}" + else + standalone_status=$? + if [[ "${standalone_status}" -eq 2 ]]; then + log_warning "Falling back to npm installation." + if install_npm; then + print_final_instructions "$(get_npm_global_bin)" + 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." + exit 1 + fi + else + log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." + exit "${standalone_status}" + fi + fi + ;; + esac +} + +main "$@" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c37efc0622c..2867b4b030b 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -52,9 +52,7 @@ const WINDOWS_INSTALLER_TEST_TIMEOUT = 15_000; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { - const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', - ); + const script = readScript('scripts/installation/install-qwen.sh'); expect(script).not.toContain('install_nvm'); expect(script).not.toContain('install_nvm.sh'); @@ -79,9 +77,7 @@ describe('installation scripts', () => { }); it('supports code-server-style standalone install on Linux/macOS', () => { - const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', - ); + const script = readScript('scripts/installation/install-qwen.sh'); expect(script).toContain('--method METHOD'); expect(script).toContain('--mirror MIRROR'); @@ -144,9 +140,7 @@ describe('installation scripts', () => { }); it('keeps the Windows installer lightweight', () => { - const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', - ); + const script = readScript('scripts/installation/install-qwen.bat'); expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); @@ -177,9 +171,7 @@ describe('installation scripts', () => { }); it('supports code-server-style standalone install on Windows', () => { - const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', - ); + const script = readScript('scripts/installation/install-qwen.bat'); expect(script).toContain('--method METHOD'); expect(script).toContain('--mirror MIRROR'); @@ -600,7 +592,7 @@ describe('standalone release packaging', () => { // and the equivalent Windows incantation can pin a specific standalone // release without per-release installer assets. const installShellSource = readScript( - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen.sh', ); expect(installShellSource).toContain( 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', @@ -609,7 +601,7 @@ describe('standalone release packaging', () => { expect(installShellSource).toContain('--version requires a value'); const installBatchSource = readScript( - 'scripts/installation/install-qwen-with-source.bat', + 'scripts/installation/install-qwen.bat', ); expect(installBatchSource).toContain('set "VERSION=latest"'); expect(installBatchSource).toContain( @@ -635,14 +627,12 @@ describe('standalone release packaging', () => { const installSh = path.join(tmpDir, 'install-qwen.sh'); const installBat = path.join(tmpDir, 'install-qwen.bat'); - const installPs1 = path.join(tmpDir, 'install-qwen.ps1'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); const checksumLines = checksums.trim().split('\n'); expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', 'install-qwen.bat', - 'install-qwen.ps1', ]); expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, @@ -674,20 +664,21 @@ describe('standalone release packaging', () => { ), ).toBe(false); expect(readScript(installSh)).toBe( - readScript('scripts/installation/install-qwen-with-source.sh'), + readScript('scripts/installation/install-qwen.sh'), ); expect(readScript(installBat)).toBe( - readScript('scripts/installation/install-qwen-with-source.bat'), - ); - expect(readScript(installPs1)).toBe( - readScript('scripts/installation/install-qwen-with-source.ps1'), + readScript('scripts/installation/install-qwen.bat').replace( + /\r?\n/g, + '\r\n', + ), ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); + expect(existsSync(path.join(tmpDir, 'install-qwen.ps1'))).toBe(false); const checksumNames = checksumLines.map((line) => line.split(' ')[1]); expect(checksumNames).toEqual([...checksumNames].sort()); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); - expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.ps1$/m); + expect(checksums).not.toMatch(/ {2}install-qwen\.ps1$/m); expect(checksums).not.toMatch(/ {2}install$/m); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); @@ -713,11 +704,11 @@ describe('standalone release packaging', () => { try { mkdirSync(sourceDir, { recursive: true }); writeFileSync( - path.join(sourceDir, 'install-qwen-with-source.sh'), + path.join(sourceDir, 'install-qwen.sh'), '#!/usr/bin/env bash\nVERSION="${QWEN_INSTALL_VERSION:-latest}"\n', ); writeFileSync( - path.join(sourceDir, 'install-qwen-with-source.bat'), + path.join(sourceDir, 'install-qwen.bat'), '@echo off\r\nset "VERSION=latest"\r\n', ); @@ -746,14 +737,14 @@ describe('standalone release packaging', () => { // something other than `latest`. The default-version pattern guard // catches this, even though loose substring matching would not. writeFileSync( - path.join(sourceDir, 'install-qwen-with-source.sh'), + path.join(sourceDir, 'install-qwen.sh'), '#!/usr/bin/env bash\n' + '# Defaults to latest unless --version is passed.\n' + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', ); writeFileSync( - path.join(sourceDir, 'install-qwen-with-source.bat'), + path.join(sourceDir, 'install-qwen.bat'), '@echo off\r\nset "VERSION=stable"\r\n', ); @@ -1577,7 +1568,7 @@ describe('Linux/macOS installer end-to-end', () => { const output = execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen.sh', '--method', 'detect', '--base-url', @@ -1621,7 +1612,7 @@ describe('Linux/macOS installer end-to-end', () => { execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen.sh', '--method', 'detect', '--base-url', @@ -2165,7 +2156,7 @@ function runUnixInstaller( return execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen.sh', '--method', method, '--archive', @@ -2206,7 +2197,7 @@ function runWindowsInstaller( try { return runWindowsCommand( [ - `call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + `call "${path.resolve('scripts/installation/install-qwen.bat')}"`, '--method', method, '--archive', diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index a5def7198b7..f3536a8f794 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -31,8 +31,8 @@ const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( ({ qwenTarget }) => standaloneArchiveName(qwenTarget), ); // Release artifacts that the installer chain expects in a GitHub Release. -// Hosted installer scripts (install-qwen.sh / install-qwen.bat / -// install-qwen.ps1) are served from a separate hosted endpoint and are +// Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served +// from a separate hosted endpoint and are // intentionally not part of this set; they have their own staging path in // `package:hosted-installation`. const EXPECTED_RELEASE_ASSET_NAMES = [ From b8e54900f2b13755d4296d3329026c9503e0e993 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 16:58:20 +0800 Subject: [PATCH 058/129] chore(installer): trim hosted release diff scope --- packages/cli/src/utils/relaunch.test.ts | 2 +- .../installation/install-qwen-with-source.bat | 1809 +++++++---------- .../installation/install-qwen-with-source.sh | 317 +-- .../get-release-version-python-sdk.test.js | 2 +- 4 files changed, 805 insertions(+), 1325 deletions(-) diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 83349dbb9e9..1d137bced24 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -111,7 +111,7 @@ describe('relaunchAppInChildProcess', () => { process.execArgv = [...originalExecArgv]; process.argv = [...originalArgv]; - process.execPath = originalExecPath; + process.execPath = '/usr/bin/node'; processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('PROCESS_EXIT_CALLED'); diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c70d4f519e5..c8ce8cf067a 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -1,1038 +1,771 @@ -@echo off -REM Qwen Code Installation Script -REM Installs Qwen Code from a standalone archive when available, with npm fallback. -REM This script intentionally does not install Node.js or change npm config. - -setlocal enabledelayedexpansion - -set "SOURCE=unknown" -set "METHOD=" -if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" -set "MIRROR=auto" -if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" -set "NO_MODIFY_PATH=0" -if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" -set "BASE_URL=" -if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" -set "ARCHIVE_PATH=" -if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" -set "VERSION=latest" -if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" -set "NPM_REGISTRY=https://registry.npmmirror.com" -if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" -if defined LOCALAPPDATA ( - set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" -) else ( - set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" -) -if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" -set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" -if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" -set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" -if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" - -REM Parse flags before any network or filesystem work. -:parse_args -if "%~1"=="" goto end_parse -if /i "%~1"=="--source" ( - if "%~2"=="" ( - echo ERROR: --source requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="-s" ( - if "%~2"=="" ( - echo ERROR: -s requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--method" ( - if "%~2"=="" ( - echo ERROR: --method requires a value - exit /b 1 - ) - set "METHOD=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--mirror" ( - if "%~2"=="" ( - echo ERROR: --mirror requires a value - exit /b 1 - ) - set "MIRROR=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--base-url" ( - if "%~2"=="" ( - echo ERROR: --base-url requires a value - exit /b 1 - ) - set "BASE_URL=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--archive" ( - if "%~2"=="" ( - echo ERROR: --archive requires a value - exit /b 1 - ) - set "ARCHIVE_PATH=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--version" ( - if "%~2"=="" ( - echo ERROR: --version requires a value - exit /b 1 - ) - set "VERSION=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--registry" ( - if "%~2"=="" ( - echo ERROR: --registry requires a value - exit /b 1 - ) - set "NPM_REGISTRY=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--no-modify-path" ( - set "NO_MODIFY_PATH=1" - shift - goto parse_args -) -if /i "%~1"=="-h" goto usage -if /i "%~1"=="--help" goto usage - -echo ERROR: Unknown option. -echo. -goto usage_error - -:end_parse - -call :ValidateOptions -if %ERRORLEVEL% NEQ 0 exit /b 1 - -echo =========================================== -echo Qwen Code Installation Script -echo =========================================== -echo. -echo INFO: Install method: !METHOD! -if /i not "!METHOD!"=="npm" ( - echo INFO: Standalone mirror: !MIRROR! - if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! - if not "!ARCHIVE_PATH!"=="" ( - echo INFO: Standalone archive: !ARCHIVE_PATH! - ) else ( - echo INFO: Standalone version: !VERSION! - ) -) -if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! -if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! -echo. - -REM Discover all qwen executables on disk BEFORE we install. We can't -REM reliably simulate the user's PATH ordering, so enumerate well-known -REM per-tool bin directories plus everything `where qwen` returns. -set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" -del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" -for %%c in ( - "!USERPROFILE!\.opencode\bin\qwen.cmd" - "!APPDATA!\npm\qwen.cmd" - "!USERPROFILE!\.bun\bin\qwen.cmd" - "!LOCALAPPDATA!\bun\bin\qwen.cmd" - "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" -) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" -for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( - if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" -) -set "PRE_INSTALL_QWENS_LIST=" -if exist "!PRE_INSTALL_QWENS_FILE!" ( - for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( - if "!PRE_INSTALL_QWENS_LIST!"=="" ( - set "PRE_INSTALL_QWENS_LIST=%%i" - ) else ( - echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 - if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" - ) - ) - del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -) - -REM Dispatch after validation; detect falls back to npm only when unavailable. -if /i "!METHOD!"=="standalone" ( - call :InstallStandalone - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal - exit /b 0 -) - -if /i "!METHOD!"=="npm" ( - call :InstallNpm - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" - endlocal - exit /b 0 -) - -call :InstallStandalone -set "STANDALONE_STATUS=!ERRORLEVEL!" -if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal - exit /b 0 -) - -if !STANDALONE_STATUS! EQU 2 ( - echo WARNING: Falling back to npm installation. - call :InstallNpm - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. - echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. - exit /b !ERRORLEVEL! - ) - call :PrintFinalInstructions "" - endlocal - exit /b 0 -) - -echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. -exit /b !STANDALONE_STATUS! - -:usage -call :PrintUsage -exit /b 0 - -:usage_error -call :PrintUsage -exit /b 1 - -:PrintUsage -echo Qwen Code Installer -echo. -echo Usage: install-qwen-with-source.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. -exit /b 0 - -:ValidateOptions -if "!METHOD!"=="" set "METHOD=detect" - -set "QWEN_VALIDATE_METHOD=!METHOD!" -set "QWEN_VALIDATE_MIRROR=!MIRROR!" -set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" -set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" -set "QWEN_VALIDATE_VERSION=!VERSION!" -set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" -set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" -set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -set "QWEN_VALIDATE_SOURCE=!SOURCE!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_VALIDATE_METHOD=" -set "QWEN_VALIDATE_MIRROR=" -set "QWEN_VALIDATE_BASE_URL=" -set "QWEN_VALIDATE_ARCHIVE_PATH=" -set "QWEN_VALIDATE_VERSION=" -set "QWEN_VALIDATE_NPM_REGISTRY=" -set "QWEN_VALIDATE_INSTALL_BASE=" -set "QWEN_VALIDATE_INSTALL_DIR=" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=" -set "QWEN_VALIDATE_SOURCE=" -if %PS_STATUS% NEQ 0 ( - echo ERROR: installer options contain unsafe command characters. - exit /b 1 -) - -if "!INSTALL_BASE!"=="" ( - echo ERROR: QWEN_INSTALL_ROOT must not be empty. - exit /b 1 -) -if "!INSTALL_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BIN_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok -if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok -if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok -echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. -exit /b 1 -:validate_install_base_ok -if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok -if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok -if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok -echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. -exit /b 1 -:validate_install_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok -echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. -exit /b 1 -:validate_install_bin_dir_ok - -if /i "!METHOD!"=="detect" goto validate_method_ok -if /i "!METHOD!"=="standalone" goto validate_method_ok -if /i "!METHOD!"=="npm" goto validate_method_ok -echo ERROR: --method must be detect, standalone, or npm. -exit /b 1 - -:validate_method_ok -if /i "!MIRROR!"=="github" goto validate_mirror_ok -if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok -if /i "!MIRROR!"=="auto" goto validate_mirror_ok -echo ERROR: --mirror must be auto, github, or aliyun. -exit /b 1 - -:validate_mirror_ok -call :ValidateHttpsUrlVar "BASE_URL" "--base-url" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateVersion -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateSource -exit /b %ERRORLEVEL% - -:ValidateHttpsUrlVar -set "URL_VALUE=!%~1!" -set "URL_OPTION=%~2" -if "!URL_VALUE!"=="" exit /b 0 -if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 - -echo ERROR: !URL_OPTION! must start with https:// -exit /b 1 - -:ValidateVersion -if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul -if !ERRORLEVEL! EQU 0 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 -echo ERROR: --version must be 'latest' or a semver string. -exit /b 1 - -:ValidateSource -if "!SOURCE!"=="unknown" exit /b 0 -echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 - -echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. -exit /b 1 - -:DetectTarget -set "TARGET=" -if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" -if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" -if "!TARGET!"=="" ( - echo WARNING: Standalone archive is not available for this Windows architecture. - exit /b 1 -) -exit /b 0 - -:ReleaseVersionPath -if /i "!VERSION!"=="latest" ( - set "VERSION_PATH=latest" - exit /b 0 -) -set "VERSION_PATH=!VERSION!" -if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 -set "VERSION_PATH=v!VERSION_PATH!" -exit /b 0 - -:GithubBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_GH_BASE_URL -set "QWEN_GH_REPO=QwenLM/qwen-code" -if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" -if /i "%~1"=="latest" ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" -) else ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" -) -set "QWEN_GH_REPO=" -exit /b 0 - -:AliyunBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_OSS_BASE_URL -set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" -exit /b 0 - -:RaceMirrorHead -rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url -rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH -rem fallback) keeps the PowerShell snippet small; a true parallel race adds a -rem lot of escaping for marginal speedup since OSS HEAD is sub-second when -rem reachable. Default fallback: github. -set "QWEN_RACE_TIMEOUT=%~1" -set "QWEN_RACE_GH_URL=%~2" -set "QWEN_RACE_OSS_URL=%~3" -set "QWEN_RACE_RESULT=github" -for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" -set "QWEN_RACE_TIMEOUT=" -set "QWEN_RACE_GH_URL=" -set "QWEN_RACE_OSS_URL=" -exit /b 0 - -:StandaloneBaseUrl -if not "!BASE_URL!"=="" ( - set "STANDALONE_BASE_URL=!BASE_URL!" - exit /b 0 -) - -call :ReleaseVersionPath - -if /i "!MIRROR!"=="auto" ( - call :GithubBaseUrlForVersion "!VERSION_PATH!" - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" - set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! - set "QWEN_GH_BASE_URL=" - set "QWEN_OSS_BASE_URL=" - set "QWEN_RACE_RESULT=" -) - -if /i "!MIRROR!"=="aliyun" ( - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" - set "QWEN_OSS_BASE_URL=" - exit /b 0 -) - -call :GithubBaseUrlForVersion "!VERSION_PATH!" -set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" -set "QWEN_GH_BASE_URL=" -exit /b 0 - -:MaybeUpdateUserPath -rem args: %~1=install_bin_dir -rem Prepend the install dir to the user-level PATH (HKCU\Environment) via -rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is -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.'" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_NEW_BIN=" -exit /b %PS_STATUS% - -:UrlExists -set "QWEN_CHECK_URL=%~1" -rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. -rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_CHECK_URL=" -exit /b %PS_STATUS% - -:DownloadFile -set "QWEN_DOWNLOAD_URL=%~1" -set "QWEN_DOWNLOAD_DEST=%~2" -rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). -rem ProgressPreference defaults to 'Continue' which renders a bar in interactive -rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_DOWNLOAD_URL=" -set "QWEN_DOWNLOAD_DEST=" -exit /b %PS_STATUS% - -:VerifyChecksum -set "ARCHIVE_FILE=%~1" -set "CHECKSUM_SOURCE=%~2" -set "ARCHIVE_NAME=%~3" -set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" -set "TEMP_CHECKSUM=" -set "REQUIRE_CHECKSUM=1" - -if "!CHECKSUM_FILE!"=="" ( - for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" -) else ( - if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( - set "REQUIRE_CHECKSUM=1" - set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" - call :DownloadFile "!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. - exit /b 1 - ) - set "CHECKSUM_FILE=!TEMP_CHECKSUM!" - ) -) - -if not exist "!CHECKSUM_FILE!" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: SHA256SUMS not found; cannot verify archive. - exit /b 1 - ) - echo WARNING: SHA256SUMS not found; skipping checksum verification. - exit /b 0 -) - -set "EXPECTED_HASH=" -for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( - set "CHECKSUM_HASH=%%H" - set "CHECKSUM_NAME=%%I" - if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" - if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( - if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" - ) -) - -if "!EXPECTED_HASH!"=="" ( - if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. - exit /b 1 - ) - echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. - exit /b 0 -) - -set "ACTUAL_HASH=" -set "QWEN_HASH_FILE=!ARCHIVE_FILE!" -for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( - if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" -) -set "QWEN_HASH_FILE=" - -if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - -if "!ACTUAL_HASH!"=="" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Could not calculate SHA-256 checksum for archive. - exit /b 1 - ) - echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. - exit /b 0 -) - -if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( - echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. - exit /b 1 -) - -echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. -exit /b 0 - -:InstallStandalone -set "TEMP_DIR=" -set "CHECKSUM_SOURCE=" - -REM Resolve the archive from a local file or from the configured release mirror. -if not "!ARCHIVE_PATH!"=="" ( - set "ARCHIVE_FILE=!ARCHIVE_PATH!" - for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" - if not exist "!ARCHIVE_FILE!" ( - echo ERROR: Standalone archive not found: !ARCHIVE_FILE! - exit /b 1 - ) -) else ( - call :DetectTarget - if !ERRORLEVEL! NEQ 0 exit /b 2 - - set "ARCHIVE_NAME=qwen-code-win-x64.zip" - call :StandaloneBaseUrl - set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" - set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" - - if /i "!METHOD!"=="detect" ( - call :UrlExists "!ARCHIVE_URL!" - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive not found: !ARCHIVE_NAME! - exit /b 2 - ) - ) - - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 - set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - - echo INFO: Downloading !ARCHIVE_URL! - call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo WARNING: Failed to download standalone archive. - exit /b 2 - ) -) - -if "!TEMP_DIR!"=="" ( - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 -) - -REM Verify integrity before extraction or changing the install directory. -call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Extract into a temporary directory, then validate required entry points. -set "EXTRACT_DIR=!TEMP_DIR!\extract" -call :EnsureDir "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :ValidateArchiveContents "!ARCHIVE_FILE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" -set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_ARCHIVE_FILE=" -set "QWEN_EXTRACT_DIR=" -if !PS_STATUS! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to extract standalone archive. - exit /b 1 -) - -call :RejectArchiveLinks "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\node\node.exe. - exit /b 1 -) - -call :EnsureDir "!INSTALL_BASE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureDir "!INSTALL_BIN_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" -call :EnsureDir "!INSTALL_PARENT!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Stage into .new and keep .old so failed upgrades can roll back. -set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" -set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" - -call :EnsureManagedInstallDir "!INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if exist "!NEW_INSTALL_DIR!" ( - rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. - exit /b 1 - ) -) -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to stage standalone archive. - exit /b 1 -) - -if exist "!INSTALL_DIR!" ( - move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to back up existing install at !INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. - exit /b 1 -) - -rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated -rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels -rem (`!`) and other shell-metacharacters in those values; if that validator is ever -rem loosened, the wrapper write below becomes a command injection sink. -( -echo @echo off -echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* -) > "!INSTALL_BIN_DIR!\qwen.cmd.new" -if !ERRORLEVEL! NEQ 0 ( - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) -move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) - -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! -) - -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! -exit /b 0 - -:CreateTempDir -set "TEMP_DIR=" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" -if "!TEMP_DIR!"=="" ( - echo ERROR: Failed to create a temporary directory. - exit /b 1 -) -exit /b 0 - -:EnsureDir -set "REQUIRED_DIR=%~1" -set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_REQUIRED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 -if !PS_STATUS! EQU 2 ( - echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! - exit /b 1 -) -echo ERROR: Failed to create directory: !REQUIRED_DIR! -exit /b 1 - -:ValidateArchiveContents -set "QWEN_ARCHIVE_FILE=%~1" -REM Normalize backslashes to forward slashes before checking. Some Windows -REM zip producers (including PowerShell's Compress-Archive) emit entries -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) { $name = $entry.FullName -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 -if %PS_STATUS% EQU 1 ( - echo ERROR: Archive contains unsafe path entries. - exit /b 1 -) -if %PS_STATUS% EQU 2 ( - echo ERROR: Archive could not be inspected before extraction. - exit /b 1 -) -echo ERROR: Archive validation failed before extraction. -exit /b %PS_STATUS% - -:RemoveInstalledDirWithWarning -if not exist "!INSTALL_DIR!" exit /b 0 -rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 -if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! -exit /b 0 - -:RestoreOldInstall -if not exist "!OLD_INSTALL_DIR!" exit /b 0 -move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. - exit /b 1 -) -exit /b 0 - -:RejectArchiveLinks -set "QWEN_EXTRACT_DIR=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_EXTRACT_DIR=" -if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. -exit /b %PS_STATUS% - -:EnsureManagedInstallDir -set "MANAGED_DIR=%~1" -set "QWEN_MANAGED_DIR=!MANAGED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_MANAGED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 - -echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. -echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. -exit /b 1 - -:RequireNode -where node >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Node.js was not found. - echo. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" -if "%NODE_VERSION%"=="" ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" -set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -if %NODE_MAJOR_NUM% LSS 22 ( - echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -echo SUCCESS: 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. - exit /b 1 -) - -for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" -echo SUCCESS: npm %NPM_VERSION% detected. -exit /b 0 - -:InstallNpm -call :RequireNode -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :RequireNpm -if %ERRORLEVEL% NEQ 0 exit /b 1 - -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! - echo INFO: Upgrading to the latest version. -) - -echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! -call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! - exit /b 1 -) - -echo SUCCESS: Qwen Code installed successfully. -call :CreateSourceJson -exit /b 0 - -:CreateSourceJson -if "!SOURCE!"=="unknown" exit /b 0 - -set "QWEN_DIR=!USERPROFILE!\.qwen" -call :EnsureDir "!QWEN_DIR!" -if !ERRORLEVEL! NEQ 0 exit /b 1 - -( -echo { -echo "source": "!SOURCE!" -echo } -) > "!QWEN_DIR!\source.json" - -echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json -exit /b 0 - -:PrintFinalInstructions -set "EXTRA_BIN=%~1" - -set "INSTALLED_BIN=" -if not "!EXTRA_BIN!"=="" ( - set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" - set "PATH=!EXTRA_BIN!;!PATH!" -) - -echo. -echo =========================================== -echo Installation completed! -echo =========================================== -echo. - -set "INSTALLED_VERSION=unknown" -if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( - for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" -) - -if not "!INSTALLED_BIN!"=="" ( - echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! -) else ( - echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! -) - -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!" - ) - ) - ) -) - -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. - if /i "!NO_MODIFY_PATH!"=="1" ( - echo Skipped user PATH update because --no-modify-path is set. - echo To make this install win, add this to your user PATH manually: - echo !EXTRA_BIN! - ) else ( - call :MaybeUpdateUserPath "!EXTRA_BIN!" - echo. - echo If you prefer not to modify user PATH, rerun with --no-modify-path - echo and pick one of: - echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) - echo - invoke directly: "!INSTALLED_BIN!" - ) - exit /b 0 -) - -where qwen >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. - echo. - echo Restart your command prompt, then run: qwen - if not "!EXTRA_BIN!"=="" ( - echo. - echo Or add this directory to PATH: - echo !EXTRA_BIN! - echo Then run: - echo qwen - exit /b 0 - ) - - for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" - if not "!NPM_PREFIX!"=="" ( - echo. - echo Or add this npm global directory to PATH: - echo !NPM_PREFIX! - echo Then run: - echo qwen - ) - exit /b 0 -) - -echo. -echo You can now run: qwen -echo. -echo INFO: Run qwen in your project directory to start an interactive session. -exit /b 0 +@echo off +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. + +setlocal enabledelayedexpansion + +set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=github" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" + +REM Parse flags before any network or filesystem work. +:parse_args +if "%~1"=="" goto end_parse +if /i "%~1"=="--source" ( + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-s" ( + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--method" ( + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--mirror" ( + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--base-url" ( + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--archive" ( + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--version" ( + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--registry" ( + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option: %~1 +echo. +goto usage_error + +:end_parse + +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +echo =========================================== +echo Qwen Code Installation Script +echo =========================================== +echo. +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! + ) else ( + echo INFO: Standalone version: !VERSION! + ) +) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo. + +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + endlocal + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +echo. +echo Usage: install-qwen-with-source.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: github or aliyun. +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 -h, --help Show this help message. +exit /b 0 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +echo ERROR: --mirror must be github or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +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 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:StandaloneBaseUrl +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath +if /i "!MIRROR!"=="aliyun" ( + set "STANDALONE_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/!VERSION_PATH!" + exit /b 0 +) + +if /i "!VERSION_PATH!"=="latest" ( + set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/latest/download" + exit /b 0 +) + +set "STANDALONE_BASE_URL=https://github.com/QwenLM/qwen-code/releases/download/!VERSION_PATH!" +exit /b 0 + +:UrlExists +set "QWEN_CHECK_URL=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $client = New-Object Net.WebClient; $client.DownloadFile($env:QWEN_DOWNLOAD_URL, $env:QWEN_DOWNLOAD_DEST); exit 0 } catch { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +set "REQUIRE_CHECKSUM=1" + +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + set "REQUIRE_CHECKSUM=1" + set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :DownloadFile "!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. + exit /b 1 + ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" + ) +) + +if not exist "!CHECKSUM_FILE!" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 + ) + echo WARNING: SHA256SUMS not found; skipping checksum verification. + exit /b 0 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 + ) + echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. + exit /b 0 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + if "!REQUIRE_CHECKSUM!"=="1" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 + ) + echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. + exit /b 0 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. + exit /b 1 +) + +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +exit /b 0 + +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-win-x64.zip" + call :StandaloneBaseUrl + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + exit /b 2 + ) +) + +if "!TEMP_DIR!"=="" ( + set "TEMP_DIR=%TEMP%\qwen-code-install-%RANDOM%%RANDOM%" + mkdir "!TEMP_DIR!" >nul 2>&1 +) + +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +mkdir "!EXTRACT_DIR!" >nul 2>&1 +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +if not exist "!INSTALL_BASE!" mkdir "!INSTALL_BASE!" +if not exist "!INSTALL_BIN_DIR!" mkdir "!INSTALL_BIN_DIR!" +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +if not exist "!INSTALL_PARENT!" mkdir "!INSTALL_PARENT!" + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + if exist "!INSTALL_DIR!" rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 + if exist "!OLD_INSTALL_DIR!" move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + +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! +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +if not exist "!MANAGED_DIR!" exit /b 0 +if exist "!MANAGED_DIR!\manifest.json" exit /b 0 + +echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. +exit /b 1 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 20 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 20 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 20 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +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! + echo INFO: Upgrading to the latest version. +) + +echo INFO: Running: npm install -g @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! +call npm install -g @qwen-code/qwen-code@latest --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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +if not exist "!QWEN_DIR!" mkdir "!QWEN_DIR!" + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" +if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" + +echo. +echo =========================================== +echo Installation completed! +echo =========================================== +echo. + +where qwen >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" + echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! + echo. + echo You can now run: qwen + echo. + echo INFO: Run qwen in your project directory to start an interactive session. + exit /b 0 +) + +echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. +echo. +echo Restart your command prompt, then run: qwen +if not "!EXTRA_BIN!"=="" ( + echo. + echo Or add this directory to PATH: + echo !EXTRA_BIN! + echo Then run: + echo qwen + exit /b 0 +) + +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" +if not "!NPM_PREFIX!"=="" ( + echo. + echo Or add this npm global directory to PATH: + echo !NPM_PREFIX! + echo Then run: + echo qwen +) +exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1c86ee9362e..1d8c5d7d753 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -85,16 +85,13 @@ Options: -s, --source SOURCE Record the installation source. --method METHOD Install method: detect, standalone, or npm. Defaults to QWEN_INSTALL_METHOD or detect. - --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. - Defaults to QWEN_INSTALL_MIRROR or auto, which picks - whichever responds first via a HEAD probe. + --mirror MIRROR Standalone archive mirror: github or aliyun. + Defaults to QWEN_INSTALL_MIRROR or github. --base-url URL Override standalone archive base URL. --archive PATH Install from a local standalone archive. --version VERSION Standalone release version. Defaults to latest. --registry REGISTRY npm registry to use for npm fallback. Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com - --no-modify-path Do not append PATH to the user's shell rc file even - when a shadowing 'qwen' is detected. -h, --help Show this help message. Examples: @@ -107,11 +104,10 @@ EOF SOURCE="unknown" METHOD="${QWEN_INSTALL_METHOD:-}" -MIRROR="${QWEN_INSTALL_MIRROR:-auto}" +MIRROR="${QWEN_INSTALL_MIRROR:-github}" BASE_URL="${QWEN_INSTALL_BASE_URL:-}" ARCHIVE_PATH="${QWEN_INSTALL_ARCHIVE:-}" VERSION="${QWEN_INSTALL_VERSION:-latest}" -NO_MODIFY_PATH="${QWEN_NO_MODIFY_PATH:-0}" NPM_REGISTRY="${QWEN_NPM_REGISTRY:-https://registry.npmmirror.com}" INSTALL_ROOT="${QWEN_INSTALL_ROOT:-${HOME:-}/.local}" if [[ -n "${QWEN_INSTALL_LIB_DIR:-}" ]]; then @@ -200,10 +196,10 @@ validate_options() { esac case "${MIRROR}" in - auto|github|aliyun) + github|aliyun) ;; *) - log_error "--mirror must be auto, github, or aliyun." + log_error "--mirror must be github or aliyun." exit 1 ;; esac @@ -277,10 +273,6 @@ while [[ $# -gt 0 ]]; do NPM_REGISTRY="$2" shift 2 ;; - --no-modify-path) - NO_MODIFY_PATH=1 - shift - ;; -h|--help) print_usage exit 0 @@ -326,7 +318,7 @@ print_header() { print_node_help() { echo "" - echo "Node.js 22 or newer is required before installing Qwen Code with npm." + echo "Node.js 20 or newer is required before installing Qwen Code with npm." echo "" echo "Install Node.js, then rerun this installer:" case "$(uname -s 2>/dev/null || echo unknown)" in @@ -343,7 +335,7 @@ print_node_help() { ;; esac echo "" - echo "If you already use a Node version manager, activate Node.js 22+" + echo "If you already use a Node version manager, activate Node.js 20+" echo "in this shell before rerunning the installer." } @@ -365,8 +357,8 @@ require_node() { return 1 fi - if [[ "${node_major}" -lt 22 ]]; then - log_error "Node.js ${node_version:-unknown} is installed, but Node.js 22 or newer is required." + if [[ "${node_major}" -lt 20 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required." print_node_help return 1 fi @@ -486,110 +478,6 @@ release_version_path() { esac } -# When a shadowing 'qwen' is detected, append a PATH prepend to the user's -# shell rc file at the very end. Putting it at the END means our prepend runs -# AFTER any earlier PATH munging in the rc file (e.g., other tools' shell -# init), so our installed_bin wins. Idempotent via a marker comment. -maybe_update_shell_path() { - local install_bin_dir="$1" - - [[ "${NO_MODIFY_PATH:-0}" == "1" ]] && return 0 - [[ -z "${install_bin_dir}" ]] && return 0 - [[ -z "${HOME:-}" ]] && return 0 - - local rc_file="" - case "${SHELL:-}" in - */zsh) rc_file="${HOME}/.zshrc" ;; - */bash) - if [[ -f "${HOME}/.bashrc" ]]; then - rc_file="${HOME}/.bashrc" - elif [[ -f "${HOME}/.bash_profile" ]]; then - rc_file="${HOME}/.bash_profile" - else - rc_file="${HOME}/.bashrc" - fi - ;; - */fish) rc_file="${HOME}/.config/fish/config.fish" ;; - *) rc_file="${HOME}/.profile" ;; - esac - - [[ -z "${rc_file}" ]] && return 0 - - local marker="# Added by qwen-code installer (multi-qwen shadow fix)" - local export_line - if [[ "${rc_file}" == *config.fish ]]; then - export_line="set -gx PATH ${install_bin_dir} \$PATH" - else - export_line="export PATH=\"${install_bin_dir}:\$PATH\"" - fi - - if [[ -f "${rc_file}" ]] && grep -qF "${marker}" "${rc_file}" 2>/dev/null; then - log_info "PATH update already present in ${rc_file} (skipping)." - return 0 - fi - - mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true - { - echo "" - echo "${marker}" - echo "${export_line}" - } >> "${rc_file}" || { - log_warning "Could not write PATH update to ${rc_file}." - return 0 - } - - log_success "Appended PATH prepend to ${rc_file}" - log_info "Open a new terminal, or run: source ${rc_file}" -} - -github_base_url_for_version() { - local version_path="$1" - local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}" - if [[ "${version_path}" == "latest" ]]; then - echo "https://github.com/${github_repo}/releases/latest/download" - else - echo "https://github.com/${github_repo}/releases/download/${version_path}" - fi -} - -aliyun_base_url_for_version() { - local version_path="$1" - echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" -} - -# Race two HEAD probes; print "aliyun" or "github" based on which mirror's -# SHA256SUMS responds first. Default to github if both time out. -race_mirror_head() { - local timeout="${1:-2}" - local gh_url="$2" - local oss_url="$3" - local tmpdir - tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" - mkdir -p "${tmpdir}" 2>/dev/null || true - - (curl -fsI -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & - local oss_pid=$! - (curl -fsI -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & - local gh_pid=$! - - local winner="" - local elapsed=0 - local max=$((timeout * 10 + 5)) - while [[ -z "${winner}" && "${elapsed}" -lt "${max}" ]]; do - # Probe OSS first to break ties in favor of the closer mirror for CN users. - [[ -e "${tmpdir}/aliyun" ]] && winner="aliyun" && break - [[ -e "${tmpdir}/github" ]] && winner="github" && break - sleep 0.1 - elapsed=$((elapsed + 1)) - done - - kill "${oss_pid}" "${gh_pid}" 2>/dev/null || true - wait "${oss_pid}" "${gh_pid}" 2>/dev/null || true - rm -rf "${tmpdir}" 2>/dev/null || true - - echo "${winner:-github}" -} - standalone_base_url() { if [[ -n "${BASE_URL}" ]]; then echo "${BASE_URL%/}" @@ -599,50 +487,30 @@ standalone_base_url() { local version_path version_path=$(release_version_path) - if [[ "${MIRROR}" == "auto" ]]; then - local gh_head oss_head selected - gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" - oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" - selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") - log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 - MIRROR="${selected}" + if [[ "${MIRROR}" == "aliyun" ]]; then + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" + return 0 fi - if [[ "${MIRROR}" == "aliyun" ]]; then - aliyun_base_url_for_version "${version_path}" + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/QwenLM/qwen-code/releases/latest/download" return 0 fi - github_base_url_for_version "${version_path}" + echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}" } download_file() { local url="$1" local destination="$2" - # Show progress only when stderr is a terminal (so CI / non-tty stays clean). - # `curl ... | bash` keeps stderr connected to the user's terminal even though - # stdin is a pipe, so progress shows correctly during normal usage. - local show_progress=0 - if [ -t 2 ] && [ "${QWEN_INSTALL_QUIET:-0}" != "1" ]; then - show_progress=1 - fi - if command_exists curl; then - if [ "${show_progress}" = "1" ]; then - curl -fL --progress-bar --retry 2 "${url}" -o "${destination}" - else - curl -fsSL --retry 2 "${url}" -o "${destination}" - fi + curl -fsSL --retry 2 "${url}" -o "${destination}" return $? fi if command_exists wget; then - if [ "${show_progress}" = "1" ]; then - wget --show-progress --tries=3 "${url}" -O "${destination}" || return 1 - else - wget -q --tries=3 "${url}" -O "${destination}" || return 1 - fi + wget -q --tries=3 "${url}" -O "${destination}" || return 1 return $? fi @@ -748,15 +616,6 @@ validate_archive_entry_path() { entry="${entry#./}" done - # Reject entries containing CR/LF so a `..\r` or `..\n` entry cannot - # bypass the literal `..` glob below. - case "${entry}" in - *$'\r'*|*$'\n'*) - log_error "Archive contains unsafe path with control character: ${entry}" - return 1 - ;; - esac - case "${entry}" in ""|/*|..|../*|*/..|*/../*|*\\*) log_error "Archive contains unsafe path: ${entry:-}" @@ -840,7 +699,7 @@ ensure_managed_install_dir() { return 0 fi - if is_qwen_standalone_install_dir "${install_dir}"; then + if [[ -f "${install_dir}/manifest.json" ]]; then return 0 fi @@ -849,19 +708,6 @@ ensure_managed_install_dir() { return 1 } -is_qwen_standalone_install_dir() { - local install_dir="$1" - local manifest_path="${install_dir}/manifest.json" - - [[ -f "${manifest_path}" ]] || return 1 - # Manifest format is produced by writeManifest in create-standalone-package.js. - # Keep these grep checks in sync if that JSON layout changes. - grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 - grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 - [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 - [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 -} - write_unix_wrapper() { local wrapper_path="$1" local qwen_bin="$2" @@ -1050,31 +896,6 @@ install_npm() { print_final_instructions() { local install_bin_dir="${1:-}" - local installed_bin="" - if [[ -n "${install_bin_dir}" ]]; then - installed_bin="${install_bin_dir}/qwen" - 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. - local other_qwens="" - if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then - local saved_ifs="${IFS}" - IFS=$'\n' - local path - for path in ${PRE_INSTALL_QWENS}; do - [[ -z "${path}" ]] && continue - [[ -n "${installed_bin}" && "${path}" == "${installed_bin}" ]] && continue - if [[ -z "${other_qwens}" ]]; then - other_qwens="${path}" - else - other_qwens="${other_qwens}"$'\n'"${path}" - fi - done - IFS="${saved_ifs}" - fi - if [[ -n "${install_bin_dir}" ]]; then export PATH="${install_bin_dir}:${PATH}" fi @@ -1085,64 +906,26 @@ print_final_instructions() { echo "==========================================" echo "" - local installed_version="unknown" - if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then - installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") - elif command_exists qwen; then - installed_version=$(qwen --version 2>/dev/null || echo "unknown") - fi - - if [[ -n "${installed_bin}" ]]; then - log_success "Installed at ${installed_bin}: ${installed_version}" - else - log_success "Qwen Code installed: ${installed_version}" - fi - - if [[ -n "${other_qwens}" ]]; then + if command_exists qwen; then + local qwen_version + qwen_version=$(qwen --version 2>/dev/null || echo "unknown") + log_success "Qwen Code is ready to use: ${qwen_version}" 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 "You can now run: qwen" echo "" - if [[ "${NO_MODIFY_PATH:-0}" == "1" ]]; then - echo "Skipped shell rc update because --no-modify-path is set." - echo "To make this install win, manually add to your shell rc:" - echo " export PATH=\"${install_bin_dir}:\$PATH\"" - else - maybe_update_shell_path "${install_bin_dir}" - echo "" - echo "If you prefer not to modify the shell rc, rerun with --no-modify-path" - echo "and pick one of:" - echo " - npm uninstall -g @qwen-code/qwen-code # if the shadow is an npm install" - echo " - invoke directly: ${installed_bin}" - fi + log_info "Run qwen in your project directory to start an interactive session." return 0 fi - if ! command_exists qwen; then - log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." + log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." + echo "" + echo "Restart your terminal, then run: qwen" + if [[ -n "${install_bin_dir}" ]]; then echo "" - echo "Restart your terminal, then run: qwen" - if [[ -n "${install_bin_dir}" ]]; then - echo "" - echo "Or run this in the current shell:" - echo " export PATH=\"${install_bin_dir}:\$PATH\"" - echo " qwen" - fi - return 0 + echo "Or run this in the current shell:" + echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " qwen" fi - - echo "" - echo "You can now run: qwen" - echo "" - log_info "Run qwen in your project directory to start an interactive session." } main() { @@ -1151,42 +934,6 @@ main() { exit 1 fi - # Discover all qwen executables on disk BEFORE we install, so the - # just-installed binary doesn't pollute the search. We can't reliably - # simulate the user's interactive shell PATH (some tools inject their - # bin only under a tty), so we enumerate well-known per-tool bin - # directories plus whatever bash inherited on PATH. - PRE_INSTALL_QWENS=$( - { - IFS=: - for dir in $PATH; do - [[ -z "${dir}" ]] && continue - [[ -x "${dir}/qwen" ]] && echo "${dir}/qwen" - done - for candidate in \ - "${HOME}/.opencode/bin/qwen" \ - "${HOME}/.bun/bin/qwen" \ - "${HOME}/.cargo/bin/qwen" \ - "${HOME}/.deno/bin/qwen" \ - "${HOME}/.volta/bin/qwen" \ - "${HOME}/.fnm/bin/qwen" \ - "${HOME}/.local/bin/qwen" \ - "${HOME}/Library/pnpm/qwen" \ - "/usr/local/bin/qwen" \ - "/opt/homebrew/bin/qwen"; do - [[ -x "${candidate}" ]] && echo "${candidate}" - done - if command_exists npm; then - local npm_prefix - npm_prefix=$(npm prefix -g 2>/dev/null || true) - if [[ -n "${npm_prefix}" && -x "${npm_prefix}/bin/qwen" ]]; then - echo "${npm_prefix}/bin/qwen" - fi - fi - } 2>/dev/null | sort -u - ) - export PRE_INSTALL_QWENS - print_header case "${METHOD}" in @@ -1210,7 +957,7 @@ main() { print_final_instructions "$(get_npm_global_bin)" 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_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm." exit 1 fi else diff --git a/scripts/tests/get-release-version-python-sdk.test.js b/scripts/tests/get-release-version-python-sdk.test.js index b9d374664c9..9c7976bd585 100644 --- a/scripts/tests/get-release-version-python-sdk.test.js +++ b/scripts/tests/get-release-version-python-sdk.test.js @@ -52,7 +52,7 @@ function makeExecError(message, { stderr = '', stdout = '', status } = {}) { function makeTimeoutError(command) { const error = new Error(`Command failed: ${command}\nSIGTERM`); - // Real Node.js execSync timeout shape (verified on Node 22+): + // Real Node.js execSync timeout shape (verified on Node 20+): // killed=undefined, signal='SIGTERM', code='ETIMEDOUT' error.code = 'ETIMEDOUT'; error.signal = 'SIGTERM'; From fedcbae1c96f33d8ff0360b594a55a69349d0d26 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 17:26:05 +0800 Subject: [PATCH 059/129] chore(installer): narrow hosted release diff --- scripts/build-hosted-installation-assets.js | 87 +- scripts/build-standalone-release.js | 187 ++-- scripts/create-standalone-package.js | 191 ++-- scripts/release-asset-config.js | 21 - scripts/release-script-utils.js | 108 --- scripts/tests/install-script.test.js | 969 +++----------------- scripts/verify-installation-release.js | 133 ++- 7 files changed, 491 insertions(+), 1205 deletions(-) delete mode 100644 scripts/release-asset-config.js delete mode 100644 scripts/release-script-utils.js diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 403d6c48c0e..b308057292f 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -6,16 +6,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -55,12 +50,6 @@ const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ 'SHA256SUMS', ]); -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--out-dir': { name: 'outDir' }, -}; - if (isMainModule(import.meta.url)) { try { await main(); @@ -71,10 +60,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - outDir: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; @@ -97,6 +83,31 @@ Options: `); } +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + async function buildHostedInstallationAssets(outDir, options = {}) { const root = options.root || rootDir; fs.mkdirSync(outDir, { recursive: true }); @@ -189,11 +200,49 @@ async function assertHostedInstallationAssetChecksums(outDir) { } } +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const [index, line] of content.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + export { HOSTED_INSTALLATION_ASSETS, HOSTED_INSTALLATION_ASSET_NAMES, - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, - HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, }; diff --git a/scripts/build-standalone-release.js b/scripts/build-standalone-release.js index 60d84aed873..b0bf2bd1706 100644 --- a/scripts/build-standalone-release.js +++ b/scripts/build-standalone-release.js @@ -7,28 +7,19 @@ */ import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { TARGETS, writeSha256Sums } from './create-standalone-package.js'; -import { isStandaloneArchiveName } from './release-asset-config.js'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; +import { writeSha256Sums } from './create-standalone-package.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -// RELEASE_TARGETS must stay in sync with TARGETS in create-standalone-package.js; -// every release qwenTarget should map to a package target and output extension. const RELEASE_TARGETS = [ { qwenTarget: 'darwin-arm64', @@ -53,16 +44,8 @@ const RELEASE_TARGETS = [ { qwenTarget: 'win-x64', nodeTarget: 'win-x64', nodeArchiveExtension: 'zip' }, ]; const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--node-version': { name: 'nodeVersion' }, - '--out-dir': { name: 'outDir' }, - '--runtime-dir': { name: 'runtimeDir' }, - '--version': { name: 'version' }, -}; - -if (isMainModule(import.meta.url)) { + +if (isMainModule()) { try { await main(); } catch (error) { @@ -72,21 +55,13 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - nodeVersion: undefined, - outDir: undefined, - runtimeDir: undefined, - version: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; } - const nodeVersion = normalizeNodeVersion( - args.nodeVersion || process.versions.node, - ); + const nodeVersion = args.nodeVersion || process.versions.node; const outDir = path.resolve( args.outDir || path.join(rootDir, 'dist', 'standalone'), ); @@ -100,37 +75,21 @@ async function main() { const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`; try { - cleanOutputDirectory(outDir); + fs.mkdirSync(outDir, { recursive: true }); const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt'); await downloadFile(`${nodeDistUrl}/SHASUMS256.txt`, checksumsPath); const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8')); - const targetResults = await Promise.allSettled( - RELEASE_TARGETS.map(async (target) => { - await packageTarget({ - ...target, - nodeDistUrl, - nodeVersion, - outDir, - releaseVersion: args.version, - runtimeDir, - checksums, - }); - return target.qwenTarget; - }), - ); - const failures = targetResults.flatMap((result, index) => - result.status === 'rejected' - ? [ - `${RELEASE_TARGETS[index].qwenTarget}: ${formatErrorReason( - result.reason, - )}`, - ] - : [], - ); - - if (failures.length > 0) { - fail(`Failed to package standalone target(s): ${failures.join('; ')}`); + for (const target of RELEASE_TARGETS) { + await packageTarget({ + ...target, + nodeDistUrl, + nodeVersion, + outDir, + releaseVersion: args.version, + runtimeDir, + checksums, + }); } await writeSha256Sums(outDir); @@ -140,8 +99,8 @@ async function main() { } } -function normalizeNodeVersion(version) { - return version.replace(/^v/i, ''); +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; } async function packageTarget({ @@ -181,18 +140,6 @@ async function packageTarget({ }); } -function formatErrorReason(reason) { - if (reason instanceof Error) { - return reason.message; - } - return String(reason); -} - -function cleanOutputDirectory(outDir) { - fs.rmSync(outDir, { recursive: true, force: true }); - fs.mkdirSync(outDir, { recursive: true }); -} - async function downloadFile(url, destination) { console.log(`Downloading ${url}`); const response = await fetch(url); @@ -211,7 +158,14 @@ async function downloadFile(url, destination) { } function parseChecksums(content) { - return parseSha256Sums(content); + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const [hash, fileName] = line.trim().split(/\s+/, 2); + if (hash && fileName) { + checksums.set(fileName.replace(/^\*/, ''), hash); + } + } + return checksums; } async function verifyNodeArchive(archivePath, archiveName, checksums) { @@ -228,19 +182,28 @@ async function verifyNodeArchive(archivePath, archiveName, checksums) { console.log(`Verified Node.js runtime checksum for ${archiveName}`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function assertStandaloneOutput(outDir) { const checksumPath = path.join(outDir, 'SHA256SUMS'); if (!fs.existsSync(checksumPath)) { fail(`Standalone SHA256SUMS was not created at ${checksumPath}`); } - const archiveNames = Array.from( - parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')).keys(), - ) - .filter(isStandaloneArchiveName) + const archiveNames = fs + .readFileSync(checksumPath, 'utf8') + .split(/\r?\n/) + .filter((line) => /^[0-9a-f]{64}\s+/.test(line)) + .map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, '')) + .filter(Boolean) .sort(); - const expectedArchiveNames = RELEASE_TARGETS.map(({ qwenTarget }) => - standaloneArchiveName(qwenTarget), + const expectedArchiveNames = RELEASE_TARGETS.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, ).sort(); const missing = expectedArchiveNames.filter( (archiveName) => !archiveNames.includes(archiveName), @@ -269,12 +232,52 @@ function assertStandaloneOutput(outDir) { console.log(`Verified ${archiveNames.length} standalone release checksums.`); } -function standaloneArchiveName(qwenTarget) { - const targetConfig = TARGETS.get(qwenTarget); - if (!targetConfig) { - fail(`No standalone package target config found for ${qwenTarget}`); +function parseArgs(argv) { + const args = { + help: false, + nodeVersion: undefined, + outDir: undefined, + runtimeDir: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--node-version': + args.nodeVersion = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--runtime-dir': + args.runtimeDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); } - return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; + return value; } function printUsage() { @@ -287,17 +290,11 @@ Options: --out-dir PATH Output directory. Defaults to dist/standalone. --runtime-dir PATH Temporary Node.js runtime download directory. --node-version VERSION Node.js version to download. Defaults to current Node. - -Host requirements: - Linux Node.js runtimes are downloaded as tar.xz archives, so the host - needs xz support (Ubuntu/Debian: xz-utils; Alpine: xz; macOS/Windows: built-in). `); } -export { - assertStandaloneOutput, - normalizeNodeVersion, - parseChecksums, - RELEASE_TARGETS, - standaloneArchiveName, -}; +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export { assertStandaloneOutput, parseChecksums, RELEASE_TARGETS }; diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index e600cffaa6f..968d6da45ed 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -7,25 +7,18 @@ */ import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { isStandaloneArchiveName } from './release-asset-config.js'; -import { - fail, - isMainModule, - parseCliArgs, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); const distDir = path.join(rootDir, 'dist'); -// TARGETS must stay in sync with RELEASE_TARGETS in build-standalone-release.js; -// every release target should have a package target and output extension here. const TARGETS = new Map([ [ 'darwin-arm64', @@ -57,19 +50,9 @@ const DIST_ALLOWED_ENTRIES = new Set([ const DIST_ALLOWED_ENTRY_PATTERNS = [ /^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/, ]; -const DIST_IGNORED_ENTRIES = new Set(['.DS_Store', 'esbuild.json']); const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE']; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--target': { name: 'target' }, - '--node-archive': { name: 'nodeArchive' }, - '--out-dir': { name: 'outDir' }, - '--version': { name: 'version' }, - '--skip-checksums': { name: 'skipChecksums', type: 'boolean' }, -}; - -if (isMainModule(import.meta.url)) { + +if (isMainModule()) { try { await main(); } catch (error) { @@ -79,14 +62,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - nodeArchive: undefined, - outDir: undefined, - skipChecksums: false, - target: undefined, - version: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); @@ -155,6 +131,62 @@ async function main() { } } +function isMainModule() { + return process.argv[1] && path.resolve(process.argv[1]) === __filename; +} + +function parseArgs(argv) { + const args = { + help: false, + outDir: undefined, + nodeArchive: undefined, + skipChecksums: false, + target: undefined, + version: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--target': + args.target = readOptionValue(argv, index, arg); + index += 1; + break; + case '--node-archive': + args.nodeArchive = readOptionValue(argv, index, arg); + index += 1; + break; + case '--out-dir': + args.outDir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--version': + args.version = readOptionValue(argv, index, arg); + index += 1; + break; + case '--skip-checksums': + args.skipChecksums = true; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + function printUsage() { console.log(`Qwen Code standalone package builder @@ -202,7 +234,7 @@ function copyRuntimeAssets(packageRoot, outDir) { fs.mkdirSync(libDir, { recursive: true }); for (const entry of fs.readdirSync(distDir)) { - if (entry === skippedDistEntry || DIST_IGNORED_ENTRIES.has(entry)) { + if (entry === skippedDistEntry || entry === '.DS_Store') { continue; } if (!isAllowedDistEntry(entry)) { @@ -223,10 +255,10 @@ function copyRuntimeAssets(packageRoot, outDir) { ); } - const packageJsonPath = fs.existsSync(path.join(distDir, 'package.json')) - ? path.join(distDir, 'package.json') - : path.join(rootDir, 'package.json'); - fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json')); + fs.copyFileSync( + path.join(rootDir, 'package.json'), + path.join(packageRoot, 'package.json'), + ); } function topLevelDistEntryForPath(candidatePath) { @@ -491,57 +523,11 @@ function createArchive(outputExtension, outputPath, cwd) { return; } - // On macOS Sequoia+, every file inherits an immovable `com.apple.provenance` - // xattr that bsdtar embeds into pax extended headers. Linux GNU tar then - // emits one `Ignoring unknown extended header keyword` warning per file at - // extract time. bsdtar's `--no-mac-metadata` is silently ignored in older - // libarchive (3.5.x), and `xattr -d com.apple.provenance` is rejected by - // SIP. The reliable fix is to use GNU tar, which does not write xattrs - // unless `--xattrs` is passed. - const tarBin = pickTarBinary(); - run(tarBin, ['-czf', outputPath, '-C', cwd, 'qwen-code']); -} - -function pickTarBinary() { - if (process.platform !== 'darwin') return 'tar'; - // Try common gtar paths (homebrew arm/intel + gnubin shim). - const candidates = [ - '/opt/homebrew/bin/gtar', - '/usr/local/bin/gtar', - '/opt/homebrew/opt/gnu-tar/libexec/gnubin/tar', - ]; - for (const candidate of candidates) { - try { - if (fs.statSync(candidate).isFile()) return candidate; - } catch { - // continue - } - } - // PATH lookup via /bin/sh -c "command -v gtar". - try { - const out = execFileSync('/bin/sh', ['-c', 'command -v gtar'], { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - }).trim(); - if (out) return out; - } catch { - // not found - } - console.warn( - 'WARNING: GNU tar (gtar) not found on macOS. Falling back to bsdtar; ' + - 'archives will include com.apple.provenance pax headers that emit ' + - 'noisy warnings on Linux extract. Install with: brew install gnu-tar', - ); - return 'tar'; + run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']); } function createZipArchive(outputPath, cwd) { if (process.platform === 'win32') { - // Use [IO.Compression.ZipFile]::CreateFromDirectory rather than - // Compress-Archive: the latter writes Windows-style backslash - // separators into ZIP entry names, which then trip the .bat - // installer's path-traversal guard against backslashes. - // CreateFromDirectory writes spec-compliant forward slashes. run( 'powershell', [ @@ -549,7 +535,7 @@ function createZipArchive(outputPath, cwd) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_OUTPUT_PATH) { Remove-Item -LiteralPath $env:QWEN_OUTPUT_PATH -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_PACKAGE_ROOT, $env:QWEN_OUTPUT_PATH, [IO.Compression.CompressionLevel]::Optimal, $true)', + 'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force', ], { env: { @@ -565,31 +551,38 @@ function createZipArchive(outputPath, cwd) { run('zip', ['-qr', outputPath, 'qwen-code'], { cwd }); } -/** - * Rebuild SHA256SUMS from scratch by scanning outDir for standalone release - * archives. This overwrites any existing SHA256SUMS, so callers must ensure - * all desired archives are present in outDir before calling. - */ async function writeSha256Sums(outDir) { - const entries = fs.readdirSync(outDir).filter(isStandaloneArchiveName).sort(); + const entries = fs + .readdirSync(outDir) + .filter( + (entry) => + entry.startsWith('qwen-code-') && + (entry.endsWith('.tar.gz') || entry.endsWith('.zip')), + ) + .sort(); if (entries.length === 0) { fail( - `No standalone archive files found in ${outDir}; refusing to write empty SHA256SUMS.`, + `No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`, ); } - const lines = await Promise.all( - entries.map(async (entry) => { - const filePath = path.join(outDir, entry); - const hash = await sha256File(filePath); - return `${hash} ${entry}`; - }), - ); + const lines = []; + for (const entry of entries) { + const filePath = path.join(outDir, entry); + const hash = await sha256File(filePath); + lines.push(`${hash} ${entry}`); + } fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + function run(command, args, options = {}) { try { execFileSync(command, args, { @@ -605,4 +598,8 @@ function run(command, args, options = {}) { } } -export { TARGETS, writeSha256Sums }; +function fail(message) { + throw new Error(`Error: ${message}`); +} + +export { writeSha256Sums }; diff --git a/scripts/release-asset-config.js b/scripts/release-asset-config.js deleted file mode 100644 index 86615a24eaf..00000000000 --- a/scripts/release-asset-config.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -const STANDALONE_ARCHIVE_PREFIX = 'qwen-code-'; -// Keep this extension allowlist in sync with the standalone packager target -// output extensions and the release workflow upload globs. -const STANDALONE_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip']; - -function isStandaloneArchiveName(fileName) { - return ( - fileName.startsWith(STANDALONE_ARCHIVE_PREFIX) && - STANDALONE_ARCHIVE_EXTENSIONS.some((extension) => - fileName.endsWith(extension), - ) - ); -} - -export { isStandaloneArchiveName }; diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js deleted file mode 100644 index bf7d1027bd3..00000000000 --- a/scripts/release-script-utils.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; -import { fileURLToPath } from 'node:url'; - -function isMainModule(importMetaUrl) { - const filename = fileURLToPath(importMetaUrl); - return process.argv[1] && path.resolve(process.argv[1]) === filename; -} - -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - -function parseCliArgs(argv, options, defaults = {}) { - const args = { ...defaults }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - let key = arg; - let inlineValue; - if (arg.startsWith('--')) { - const equalsIndex = arg.indexOf('='); - if (equalsIndex > -1) { - key = arg.slice(0, equalsIndex); - inlineValue = arg.slice(equalsIndex + 1); - } - } - - const option = options[key]; - if (!option) { - fail(`Unknown option: ${arg}`); - } - - if (option.type === 'boolean') { - if (inlineValue !== undefined) { - fail(`${key} does not accept a value`); - } - args[option.name] = true; - continue; - } - - let value; - if (inlineValue !== undefined) { - if (inlineValue === '') { - fail(`${key} requires a value`); - } - value = inlineValue; - } else { - value = readOptionValue(argv, index, key); - index += 1; - } - if (option.validate) { - option.validate(value); - } - args[option.name] = value; - } - - return args; -} - -function parseSha256Sums(content) { - const checksums = new Map(); - for (const [index, line] of content.split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (!match) { - fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); - } - checksums.set(match[2], match[1].toLowerCase()); - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - -export { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - readOptionValue, - sha256File, -}; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 2867b4b030b..72973f03357 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -14,7 +14,6 @@ const { mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, symlinkSync, writeFileSync, @@ -28,27 +27,16 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; -const standalonePackageScriptUrl = pathToFileURL( - path.resolve('scripts/create-standalone-package.js'), -).href; const hostedInstallationScriptUrl = pathToFileURL( path.resolve('scripts/build-hosted-installation-assets.js'), ).href; const installationReleaseVerificationScriptUrl = pathToFileURL( path.resolve('scripts/verify-installation-release.js'), ).href; -const releaseAssetConfigUrl = pathToFileURL( - path.resolve('scripts/release-asset-config.js'), -).href; -const releaseScriptUtilsUrl = pathToFileURL( - path.resolve('scripts/release-script-utils.js'), -).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; -// Windows CI can spend several seconds inside PowerShell zip operations. -const WINDOWS_INSTALLER_TEST_TIMEOUT = 15_000; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { @@ -94,9 +82,6 @@ describe('installation scripts', () => { ); expect(script).toContain('validate_archive_contents()'); expect(script).toContain('Archive contains unsafe path'); - expect(script).toContain( - 'Archive contains unsafe path with control character', - ); expect(script).toContain('qwen-code-${target}'); expect(script).toContain('*.tar.xz)'); expect(script).toContain('METHOD="${METHOD:-detect}"'); @@ -116,14 +101,6 @@ describe('installation scripts', () => { expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); expect(script).toContain('not a Qwen Code standalone install'); - expect(script).toContain('is_qwen_standalone_install_dir()'); - expect(script).toContain('Manifest format is produced by writeManifest'); - expect(script).toContain( - '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"', - ); - expect(script).toContain( - '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"', - ); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', ); @@ -145,13 +122,6 @@ describe('installation scripts', () => { expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); - // Invoke-WebRequest is now used in :DownloadFile so the user sees a - // progress bar while the standalone tarball downloads. Net.WebClient is - // silent and was previously preferred, but the UX hit (the user thinks - // the install is hung on slow GitHub release CDN) outweighed the small - // PS5 startup overhead. The :UrlExists / :RaceMirrorHead helpers still - // use Net.WebRequest for HEAD probes since those are sub-second and - // benefit from the leaner cold-start path. expect(script).toContain('Invoke-WebRequest'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); @@ -188,17 +158,6 @@ describe('installation scripts', () => { expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); - expect(script).toContain(':ValidateArchiveContents'); - expect(script).toContain('Archive contains unsafe path entries'); - expect(script).toContain( - 'Archive could not be inspected before extraction', - ); - expect(script).toContain('if %PS_STATUS% EQU 1'); - expect(script).toContain('if %PS_STATUS% EQU 2'); - expect(script).toContain('System.IO.Compression.FileSystem'); - expect(script).toContain('[IO.Compression.ZipFile]::OpenRead'); - expect(script).toContain('[IO.Path]::GetRandomFileName()'); - expect(script).not.toContain('qwen-code-install-%RANDOM%%RANDOM%'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); @@ -215,13 +174,6 @@ describe('installation scripts', () => { expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); expect(script).toContain(':ValidateVersion'); - expect(script).not.toContain('^v*'); - expect(script).toContain('/C:"^v[0-9]'); - expect(script).toContain(':EnsureDir'); - expect(script).toContain('Failed to create directory'); - expect(script).toContain('ConvertFrom-Json'); - expect(script).toContain("$data.name -ne '@qwen-code/qwen-code'"); - expect(script).toContain("$data.target -notmatch '^win-(x64|arm64)$'"); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); @@ -239,12 +191,8 @@ describe('installation scripts', () => { expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); - expect(script).toContain('ERROR: Unknown option.'); - expect(script).not.toContain('ERROR: Unknown option: %~1'); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); - expect(script).toContain('WARNING: Failed to restore previous install'); - expect(script).toContain('WARNING: Failed to remove failed install'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); }); @@ -266,9 +214,6 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['verify:installation-release']).toBe( 'node scripts/verify-installation-release.js', ); - // Per-release installer publishing was removed in favor of a stable hosted - // entrypoint with --version pinning, so no package:installation-assets - // script should exist. expect(packageJson.scripts['package:installation-assets']).toBeUndefined(); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); @@ -277,8 +222,6 @@ describe('standalone release packaging', () => { ); expect(existsSync('scripts/verify-installation-release.js')).toBe(true); expect(existsSync('scripts/build-installation-assets.js')).toBe(false); - expect(existsSync('scripts/release-asset-config.js')).toBe(true); - expect(existsSync('scripts/release-script-utils.js')).toBe(true); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -286,36 +229,19 @@ describe('standalone release packaging', () => { expect(packageScript).toContain('DIST_ALLOWED_ENTRIES'); expect(packageScript).toContain('Unexpected dist asset'); expect(packageScript).toContain('topLevelDistEntryForPath(outDir)'); - expect(packageScript).toContain("path.join(distDir, 'package.json')"); - expect(packageScript).toContain( - "fs.copyFileSync(packageJsonPath, path.join(packageRoot, 'package.json'))", - ); + expect(packageScript).toContain("path.join(packageRoot, 'package.json')"); expect(packageScript).toContain('validateNodeRuntime'); expect(packageScript).toContain('copyNodeRuntimeEntry'); - expect(packageScript).toContain('TARGETS must stay in sync with'); expect(packageScript).toContain('symlink cycle'); expect(packageScript).toContain('refusing to write empty SHA256SUMS'); expect(packageScript).toContain('--skip-checksums'); expect(packageScript).toContain('dereference: true'); + expect(packageScript).toContain('fs.createReadStream'); expect(packageScript).toContain('Expand-Archive'); expect(packageScript).toContain('Compress-Archive'); - expect(packageScript).toContain('Rebuild SHA256SUMS from scratch'); - expect(packageScript).toContain('Promise.all('); - expect(packageScript).toContain( - "import { isStandaloneArchiveName } from './release-asset-config.js';", - ); - expect(packageScript).toContain( - "import {\n fail,\n isMainModule,\n parseCliArgs,\n sha256File,\n} from './release-script-utils.js';", - ); - expect(packageScript).toContain( - 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', - ); - expect(packageScript).not.toContain('function parseArgs'); const releaseScript = readScript('scripts/build-standalone-release.js'); expect(releaseScript).toContain('Copyright 2025 Qwen Team'); - expect(releaseScript).toContain('normalizeNodeVersion('); - expect(releaseScript).toContain("version.replace(/^v/i, '')"); expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}'); expect(releaseScript).toContain('SHASUMS256.txt'); expect(releaseScript).toContain('verifyNodeArchive'); @@ -323,24 +249,12 @@ describe('standalone release packaging', () => { 'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length', ); expect(releaseScript).toContain('nodeArchiveExtension'); + expect(releaseScript).toContain('fs.createReadStream'); expect(releaseScript).toContain('expectedArchiveNames'); - expect(releaseScript).toContain('standaloneArchiveName(qwenTarget)'); - expect(releaseScript).toContain('TARGETS.get(qwenTarget)'); - expect(releaseScript).toContain( - 'RELEASE_TARGETS must stay in sync with TARGETS', - ); - expect(releaseScript).toContain('cleanOutputDirectory(outDir)'); + expect(releaseScript).toContain('qwen-code-${qwenTarget}'); expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); - expect(releaseScript).toContain('Promise.allSettled('); - expect(releaseScript).toContain( - "import { isStandaloneArchiveName } from './release-asset-config.js';", - ); - expect(releaseScript).toContain( - 'parseCliArgs(process.argv.slice(2), CLI_OPTIONS', - ); - expect(releaseScript).not.toContain('function parseArgs'); const hostedInstallScript = readScript( 'scripts/build-hosted-installation-assets.js', @@ -348,6 +262,8 @@ describe('standalone release packaging', () => { expect(hostedInstallScript).toContain('Copyright 2025 Qwen Team'); expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); + expect(hostedInstallScript).toContain("output: 'install-qwen.sh'"); + expect(hostedInstallScript).toContain("output: 'install-qwen.bat'"); expect(hostedInstallScript).not.toContain("output: 'install'"); const releaseVerifyScript = readScript( @@ -358,81 +274,8 @@ describe('standalone release packaging', () => { expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES'); - // The verifier targets only standalone archives + SHA256SUMS; hosted - // installer scripts have their own staging path and are intentionally - // not part of the GitHub release surface. Asserting absence of the - // alias / installer-asset *helper functions* is enough — comments may - // legitimately reference the hosted filenames as context. expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES'); - expect(releaseVerifyScript).not.toContain('isReleaseChecksumAsset'); expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches'); - expect(releaseVerifyScript).not.toContain('assertInstallAliasBuffersMatch'); - expect(releaseVerifyScript).not.toContain('assertUnixInstallersExecutable'); - - const releaseAssetConfig = readScript('scripts/release-asset-config.js'); - expect(releaseAssetConfig).toContain('Copyright 2025 Qwen Team'); - expect(releaseAssetConfig).toContain('isStandaloneArchiveName'); - // Per-release installer publishing was removed; the config no longer - // exports installer-asset helpers. - expect(releaseAssetConfig).not.toContain('INSTALLATION_ASSETS'); - expect(releaseAssetConfig).not.toContain('isInstallationAssetName'); - expect(releaseAssetConfig).not.toContain('isReleaseChecksumAsset'); - - const releaseScriptUtils = readScript('scripts/release-script-utils.js'); - expect(releaseScriptUtils).toContain('Copyright 2025 Qwen Team'); - expect(releaseScriptUtils).toContain('function parseCliArgs'); - expect(releaseScriptUtils).toContain('function parseSha256Sums'); - expect(releaseScriptUtils).toContain('async function sha256File'); - expect(releaseScriptUtils).toContain('function readOptionValue'); - expect(releaseScriptUtils).toContain('function isMainModule'); - }); - - it('parses release script CLI options through the shared helper', async () => { - const { parseCliArgs } = await import(releaseScriptUtilsUrl); - - const args = parseCliArgs( - ['--name', 'qwen', '--flag', '-h'], - { - '--name': { name: 'name' }, - '--flag': { name: 'flag', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - }, - { flag: false, help: false, name: undefined }, - ); - - expect(args).toEqual({ - flag: true, - help: true, - name: 'qwen', - }); - expect(() => parseCliArgs(['--unknown'], {}, {})).toThrow( - /Unknown option: --unknown/, - ); - expect(() => - parseCliArgs(['--name'], { '--name': { name: 'name' } }, {}), - ).toThrow(/--name requires a value/); - - const equalsArgs = parseCliArgs( - ['--name=qwen', '--flag'], - { - '--name': { name: 'name' }, - '--flag': { name: 'flag', type: 'boolean' }, - }, - { flag: false, name: undefined }, - ); - expect(equalsArgs).toEqual({ flag: true, name: 'qwen' }); - - expect(() => - parseCliArgs( - ['--flag=true'], - { '--flag': { name: 'flag', type: 'boolean' } }, - {}, - ), - ).toThrow(/--flag does not accept a value/); - - expect(() => - parseCliArgs(['--name='], { '--name': { name: 'name' } }, {}), - ).toThrow(/--name requires a value/); }); it('loads the standalone release packaging helper', () => { @@ -446,34 +289,23 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); - it('normalizes Node.js versions passed to the release helper', async () => { - const { normalizeNodeVersion } = await import(standaloneReleaseScriptUrl); - - expect(normalizeNodeVersion('v22.0.0')).toBe('22.0.0'); - expect(normalizeNodeVersion('22.0.0')).toBe('22.0.0'); - }); - - it('loads the hosted installation asset staging helper', () => { - const output = execFileSync( + it('loads the hosted installation release helpers', () => { + const hostedOutput = execFileSync( process.execPath, ['scripts/build-hosted-installation-assets.js', '--help'], { encoding: 'utf8' }, ); - - expect(output).toContain('package:hosted-installation'); - expect(output).toContain('--out-dir PATH'); - }); - - it('loads the installation release verification helper', () => { - const output = execFileSync( + const verifierOutput = execFileSync( process.execPath, ['scripts/verify-installation-release.js', '--help'], { encoding: 'utf8' }, ); - expect(output).toContain('verify:installation-release'); - expect(output).toContain('--dir PATH'); - expect(output).toContain('--base-url URL'); + expect(hostedOutput).toContain('package:hosted-installation'); + expect(hostedOutput).toContain('--out-dir PATH'); + expect(verifierOutput).toContain('verify:installation-release'); + expect(verifierOutput).toContain('--dir PATH'); + expect(verifierOutput).toContain('--base-url URL'); }); it('rejects invalid installation release verification CLI arguments', () => { @@ -487,6 +319,7 @@ describe('standalone release packaging', () => { } catch (error) { caughtError = error; } + expect(caughtError).toBeTruthy(); expect( [ @@ -517,20 +350,6 @@ describe('standalone release packaging', () => { ); }); - it('exposes only standalone archive classification', async () => { - const config = await import(releaseAssetConfigUrl); - - expect(config.isStandaloneArchiveName('qwen-code-linux-x64.tar.gz')).toBe( - true, - ); - expect(config.isStandaloneArchiveName('qwen-code-win-x64.zip')).toBe(true); - expect(config.isStandaloneArchiveName('install-qwen.sh')).toBe(false); - // Per-release installer publishing helpers must no longer be exported. - expect(config.INSTALLATION_ASSET_NAMES).toBeUndefined(); - expect(config.isInstallationAssetName).toBeUndefined(); - expect(config.isReleaseChecksumAsset).toBeUndefined(); - }); - it('parses Node.js SHASUMS entries', async () => { const { parseChecksums } = await import(standaloneReleaseScriptUrl); @@ -546,30 +365,16 @@ describe('standalone release packaging', () => { expect(checksums.get('node-v22.0.0-win-x64.zip')).toBe('b'.repeat(64)); }); - it('rejects malformed SHA256SUMS entries', async () => { - const { parseSha256Sums } = await import(releaseScriptUtilsUrl); - - expect(() => - parseSha256Sums( - [ - `${'a'.repeat(64)} qwen-code-linux-x64.tar.gz`, - `${'b'.repeat(63)} qwen-code-win-x64.zip`, - ].join('\n'), - ), - ).toThrow(/Malformed SHA256SUMS line 2/); - }); - it('validates standalone release checksum output', async () => { const { assertStandaloneOutput, RELEASE_TARGETS } = await import( standaloneReleaseScriptUrl ); - const { TARGETS } = await import(standalonePackageScriptUrl); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-')); try { const lines = RELEASE_TARGETS.map(({ qwenTarget }) => { - const extension = TARGETS.get(qwenTarget).outputExtension; - return `${'A'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; + const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'; + return `${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`; }); writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); @@ -586,11 +391,6 @@ describe('standalone release packaging', () => { }); it('installer scripts honor --version for hosted entrypoints', () => { - // The hosted entrypoint flow relies on the installer scripts accepting a - // --version flag (and QWEN_INSTALL_VERSION env var) so that - // curl URL | bash -s -- --version vX.Y.Z - // and the equivalent Windows incantation can pin a specific standalone - // release without per-release installer assets. const installShellSource = readScript( 'scripts/installation/install-qwen.sh', ); @@ -615,8 +415,6 @@ describe('standalone release packaging', () => { const { HOSTED_INSTALLATION_ASSET_NAMES, HOSTED_INSTALLATION_ASSETS, - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS, - HOSTED_INSTALLER_REQUIRED_FRAGMENTS, assertHostedInstallationAssetChecksums, buildHostedInstallationAssets, } = await import(hostedInstallationScriptUrl); @@ -637,32 +435,6 @@ describe('standalone release packaging', () => { expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, ); - expect(HOSTED_INSTALLER_REQUIRED_FRAGMENTS).toEqual([ - '--version', - 'QWEN_INSTALL_VERSION', - ]); - // The default-version regex pins `latest` semantically rather than as a - // loose substring, so a stray `latest` in a comment cannot satisfy it. - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( - 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', - ), - ).toBe(true); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.sh'].test( - '# defaults to latest', - ), - ).toBe(false); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( - 'set "VERSION=latest"', - ), - ).toBe(true); - expect( - HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS['install-qwen.bat'].test( - 'rem defaults to latest', - ), - ).toBe(false); expect(readScript(installSh)).toBe( readScript('scripts/installation/install-qwen.sh'), ); @@ -674,12 +446,12 @@ describe('standalone release packaging', () => { ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); expect(existsSync(path.join(tmpDir, 'install-qwen.ps1'))).toBe(false); - const checksumNames = checksumLines.map((line) => line.split(' ')[1]); - expect(checksumNames).toEqual([...checksumNames].sort()); + expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ + 'install-qwen.bat', + 'install-qwen.sh', + ]); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); - expect(checksums).not.toMatch(/ {2}install-qwen\.ps1$/m); - expect(checksums).not.toMatch(/ {2}install$/m); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); } @@ -693,7 +465,7 @@ describe('standalone release packaging', () => { } }); - it('rejects hosted installer sources missing pinned install behavior', async () => { + it('rejects hosted installer sources without pinned hosted behavior', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl ); @@ -703,49 +475,15 @@ describe('standalone release packaging', () => { try { mkdirSync(sourceDir, { recursive: true }); - writeFileSync( - path.join(sourceDir, 'install-qwen.sh'), - '#!/usr/bin/env bash\nVERSION="${QWEN_INSTALL_VERSION:-latest}"\n', - ); - writeFileSync( - path.join(sourceDir, 'install-qwen.bat'), - '@echo off\r\nset "VERSION=latest"\r\n', - ); - - await expect( - buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), - ).rejects.toThrow( - /install-qwen\.sh is missing hosted installer behavior: --version/, - ); - } finally { - rmSync(tmpRoot, { recursive: true, force: true }); - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('rejects hosted installer sources whose default version is not latest', async () => { - const { buildHostedInstallationAssets } = await import( - hostedInstallationScriptUrl - ); - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); - const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); - - try { - mkdirSync(sourceDir, { recursive: true }); - // Both fragments are present, but the default version was changed to - // something other than `latest`. The default-version pattern guard - // catches this, even though loose substring matching would not. writeFileSync( path.join(sourceDir, 'install-qwen.sh'), '#!/usr/bin/env bash\n' + - '# Defaults to latest unless --version is passed.\n' + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', ); writeFileSync( path.join(sourceDir, 'install-qwen.bat'), - '@echo off\r\nset "VERSION=stable"\r\n', + '@echo off\r\nset "VERSION=latest"\r\n', ); await expect( @@ -785,7 +523,6 @@ describe('standalone release packaging', () => { writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); await expect(verifyReleaseDirectory(tmpDir)).resolves.not.toThrow(); - // Tampering an archive must be caught by the per-asset hash check. appendFileSync( path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]), 'tamper', @@ -820,44 +557,6 @@ describe('standalone release packaging', () => { await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, ); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - writeFileSync(path.join(tmpDir, 'qwen-code-stale.tar.gz'), 'stale'); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset: qwen-code-stale\.tar\.gz/, - ); - rmSync(path.join(tmpDir, 'qwen-code-stale.tar.gz'), { force: true }); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - writeFileSync(path.join(tmpDir, 'payload.bin'), 'unexpected'); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset: payload\.bin/, - ); - rmSync(path.join(tmpDir, 'payload.bin'), { force: true }); - - writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); - appendFileSync( - path.join(tmpDir, 'SHA256SUMS'), - `${'c'.repeat(64)} payload.bin\n`, - ); - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /Unexpected release asset checksum: payload\.bin/, - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('rejects a release directory without SHA256SUMS', async () => { - const { verifyReleaseDirectory } = await import( - installationReleaseVerificationScriptUrl - ); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); - - try { - await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( - /SHA256SUMS was not found at /, - ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -884,9 +583,6 @@ describe('standalone release packaging', () => { }, }), ).resolves.not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('checks URL reachability only'), - ); } finally { warnSpy.mockRestore(); } @@ -901,130 +597,63 @@ describe('standalone release packaging', () => { 'HEAD', ]); } - // Hosted installer scripts must not be fetched: the verifier targets - // GitHub release assets only. for (const [url] of fetchedUrls) { expect(url).not.toMatch(/install-qwen\.(sh|bat)$/); expect(url).not.toMatch(/\/install$/); } }); - it('falls back to ranged GET when remote HEAD is unavailable', async () => { - const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = - await import(installationReleaseVerificationScriptUrl); - const checksumContent = placeholderChecksumContent( - EXPECTED_STANDALONE_ARCHIVE_NAMES, - ); - const observedMethods = []; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url, options = {}) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - const method = options.method || 'GET'; - observedMethods.push(method); - if (method === 'HEAD') { - return new Response(null, { status: 405 }); - } - // Ranged GET fallback succeeds. - return new Response(null, { status: 206 }); - }, - }), - ).resolves.not.toThrow(); - } finally { - warnSpy.mockRestore(); - } - - expect(observedMethods).toContain('HEAD'); - expect(observedMethods).toContain('GET'); - }); - - it('rejects a release base URL with no archives reachable', async () => { - const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = - await import(installationReleaseVerificationScriptUrl); - const checksumContent = placeholderChecksumContent( - EXPECTED_STANDALONE_ARCHIVE_NAMES, - ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - await expect( - verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { - fetchImpl: async (url) => { - if (url.endsWith('/SHA256SUMS')) { - return new Response(checksumContent); - } - return new Response(null, { status: 404 }); - }, - }), - ).rejects.toThrow(/All 5 release asset URLs are unavailable/); - } finally { - warnSpy.mockRestore(); - } - }); - it('rejects a release base URL that is not https', async () => { const { verifyReleaseBaseUrl } = await import( installationReleaseVerificationScriptUrl ); - // file:// must be rejected as a URL the verifier cannot reach safely. await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( /--base-url must use https/, ); - - // Plain http must also be rejected even though it is technically a valid - // URL — release URLs are always HTTPS, and accepting http would let an - // operator silently target a stale or attacker-controlled mirror. await expect( verifyReleaseBaseUrl('http://example.com/release/'), ).rejects.toThrow(/--base-url must use https/); }); - it( - 'rejects a runtime archive without a Node executable', - () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); + it('rejects a runtime archive without a Node executable', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); - try { - const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; - const fakeRuntimeArchive = - process.platform === 'win32' - ? createBadWindowsNodeArchive(tmpDir) - : createBadUnixNodeArchive(tmpDir); + try { + const target = process.platform === 'win32' ? 'win-x64' : 'linux-x64'; + const fakeRuntimeArchive = + process.platform === 'win32' + ? createBadWindowsNodeArchive(tmpDir) + : createBadUnixNodeArchive(tmpDir); - expect(() => - execFileSync( - 'node', - [ - 'scripts/create-standalone-package.js', - '--target', - target, - '--node-archive', - fakeRuntimeArchive, - '--out-dir', - path.join(tmpDir, 'out'), - '--version', - '0.0.0-test', - ], - { stdio: 'pipe' }, - ), - ).toThrow(/Node\.js runtime for .* must contain/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + expect(() => + execFileSync( + 'node', + [ + 'scripts/create-standalone-package.js', + '--target', + target, + '--node-archive', + fakeRuntimeArchive, + '--out-dir', + path.join(tmpDir, 'out'), + '--version', + '0.0.0-test', + ], + { stdio: 'pipe' }, + ), + ).toThrow(/Node\.js runtime for .* must contain/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); + } + }); it('packages a win-x64 standalone archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1057,24 +686,19 @@ describe('standalone release packaging', () => { expect( existsSync(path.join(extractDir, 'qwen-code', 'node', 'node.exe')), ).toBe(true); - const packagedPackageJson = JSON.parse( - readScript(path.join(extractDir, 'qwen-code', 'package.json')), - ); - expect(packagedPackageJson).toEqual({ - name: '@qwen-code/qwen-code', - version: '0.0.0', - }); expect(readScript(path.join(outDir, 'SHA256SUMS'))).toContain( 'qwen-code-win-x64.zip', ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }, 30_000); itOnUnix('dereferences safe Node.js runtime symlinks', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1096,12 +720,14 @@ describe('standalone release packaging', () => { expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects Node.js runtime symlinks that escape the archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1126,12 +752,14 @@ describe('standalone release packaging', () => { ).toThrow(/symlink escapes the archive/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects Node.js runtime symlink cycles', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1156,12 +784,14 @@ describe('standalone release packaging', () => { ).toThrow(/symlink cycle/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); it('rejects unexpected dist assets', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); try { @@ -1186,55 +816,18 @@ describe('standalone release packaging', () => { ).toThrow(/Unexpected dist asset/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } else { + rmSync('dist/debug-cache.tmp', { force: true }); + } } }); - it('ignores non-runtime esbuild metadata in dist', () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); - - try { - const outDir = path.join(tmpDir, 'out'); - writeFileSync('dist/esbuild.json', '{}\n'); - - execFileSync( - 'node', - [ - 'scripts/create-standalone-package.js', - '--target', - 'win-x64', - '--node-archive', - createFakeWindowsNodeArchive(tmpDir), - '--out-dir', - outDir, - '--version', - '0.0.0-test', - ], - { stdio: 'pipe' }, - ); - - const archive = path.join(outDir, 'qwen-code-win-x64.zip'); - const extractDir = path.join(tmpDir, 'extract'); - mkdirSync(extractDir, { recursive: true }); - extractZipForTest(archive, extractDir); - - expect( - existsSync(path.join(extractDir, 'qwen-code', 'lib', 'esbuild.json')), - ).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); - } - }, 30_000); - it('uploads standalone archives during release', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); - // Per-release installer publishing was removed in favor of a stable hosted - // entrypoint, so the release workflow no longer builds or uploads installer - // scripts as release assets. expect(workflow).not.toContain('package:installation-assets'); expect(workflow).not.toContain('install-qwen.sh'); expect(workflow).not.toContain('install-qwen.bat'); @@ -1243,15 +836,9 @@ describe('standalone release packaging', () => { expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); expect(workflow).toContain('dist/standalone/qwen-code-*.zip'); expect(workflow).toContain('dist/standalone/SHA256SUMS'); - // The verify step must run after the build step so a broken release - // directory is caught before publishing. expect(workflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); - const buildIndex = workflow.indexOf('npm run package:standalone:release'); - const verifyIndex = workflow.indexOf('npm run verify:installation-release'); - expect(buildIndex).toBeGreaterThan(-1); - expect(verifyIndex).toBeGreaterThan(buildIndex); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -1269,9 +856,6 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen.sh'); expect(guide).toContain('installation/install-qwen.bat'); expect(guide).toContain('release operators must sync these staged files'); - // The hosted-endpoint status callout must keep flagging the transition - // window so users do not assume the documented --version flow works - // before the next OSS sync. expect(guide).toContain('Hosted endpoint status'); expect(guide).toContain('legacy NVM-based installer'); expect(guide).toContain('node-pty'); @@ -1283,7 +867,7 @@ describe('Linux/macOS installer end-to-end', () => { itOnUnix( 'installs a local standalone archive with checksum verification', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1310,13 +894,15 @@ describe('Linux/macOS installer end-to-end', () => { expect(version).toBe('0.0.0-smoke'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }, ); itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1346,12 +932,14 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects a tampered local archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1367,12 +955,14 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/Checksum verification failed/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); itOnUnix('rejects a local archive when SHA256SUMS is missing', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1388,7 +978,9 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); @@ -1432,7 +1024,7 @@ describe('Linux/macOS installer end-to-end', () => { ); itOnUnix('refuses to overwrite a non-managed install directory', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1450,42 +1042,14 @@ describe('Linux/macOS installer end-to-end', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); - itOnUnix( - 'refuses to overwrite a directory with an unrelated manifest', - () => { - const restoreDist = ensureMinimalDist(); - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = packageFakeStandalone(tmpDir); - const installRoot = path.join(tmpDir, 'install'); - const installDir = path.join(installRoot, 'lib', 'qwen-code'); - mkdirSync(installDir, { recursive: true }); - writeFileSync( - path.join(installDir, 'manifest.json'), - JSON.stringify({ name: 'other-app', target: 'linux-x64' }), - ); - writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - - expect(() => - runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); - } - }, - ); - itOnUnix('does not fall back to npm when detect finds a bad archive', () => { - const restoreDist = ensureMinimalDist(); + const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); try { @@ -1509,7 +1073,9 @@ describe('Linux/macOS installer end-to-end', () => { expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - restoreDist(); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } } }); @@ -1680,208 +1246,55 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects a tampered local archive', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - appendFileSync(archive, 'tamper'); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/Checksum verification failed/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects a local archive when SHA256SUMS is missing', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - rmSync(path.join(path.dirname(archive), 'SHA256SUMS'), { force: true }); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/SHA256SUMS not found; cannot verify archive/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects a local archive missing required entries', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir, { - includeNode: false, - }); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/qwen-code\\node\\node.exe/); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects standalone archives containing path traversal entries', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createWindowsTraversalStandaloneArchive(tmpDir); - - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - ), - ).toThrow(/Archive contains unsafe path/); - expect(existsSync(path.join(tmpDir, 'qwen-slip'))).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, ); - itOnWindows( - 'refuses to overwrite a directory with an unrelated manifest', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - const installRoot = path.join(tmpDir, 'install'); - const installDir = path.join(installRoot, 'qwen-code'); - mkdirSync(installDir, { recursive: true }); - writeFileSync( - path.join(installDir, 'manifest.json'), - JSON.stringify({ name: 'other-app', target: 'win-x64' }), - ); - writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - - expect(() => - runWindowsInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', - ); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); - - itOnWindows( - 'rejects unsafe environment-derived install paths', - () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + itOnWindows('rejects a tampered local archive', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); - try { - const archive = createFakeWindowsStandaloneArchive(tmpDir); - const marker = path.join(tmpDir, 'pwned.txt'); + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + appendFileSync(archive, 'tamper'); - expect(() => - runWindowsInstaller( - archive, - path.join(tmpDir, 'install'), - path.join(tmpDir, 'home'), - 'standalone', - { - QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, - }, - ), - ).toThrow(/unsafe command characters/); - expect(existsSync(marker)).toBe(false); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, - WINDOWS_INSTALLER_TEST_TIMEOUT, - ); -}); + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Checksum verification failed/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); -// Tracks pending dist/ backups so a crashed test cannot leave the working tree -// without dist/. process.on('exit') runs synchronous handlers, which is enough -// for renameSync; SIGINT/SIGTERM force re-entry through 'exit'. -const pendingDistBackups = new Set(); -let distBackupHandlersRegistered = false; + itOnWindows('rejects unsafe environment-derived install paths', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); -function registerDistBackupSafetyNet() { - if (distBackupHandlersRegistered) { - return; - } - distBackupHandlersRegistered = true; + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const marker = path.join(tmpDir, 'pwned.txt'); - const drain = () => { - for (const restore of pendingDistBackups) { - try { - restore(); - } catch { - // best-effort restore; nothing we can do at exit - } + expect(() => + runWindowsInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + QWEN_INSTALL_ROOT: `${path.join(tmpDir, 'install')}" & echo pwned > "${marker}" & "`, + }, + ), + ).toThrow(/unsafe command characters/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); } - pendingDistBackups.clear(); - }; - - process.on('exit', drain); - for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { - process.on(signal, () => { - drain(); - process.exit(1); - }); - } -} + }); +}); function ensureMinimalDist() { - registerDistBackupSafetyNet(); - - const distPath = path.resolve('dist'); - // Backup root must live on the same volume as dist/ so that renameSync - // is atomic. On Windows GitHub runners the workspace lives on D: while - // os.tmpdir() returns a path on C:; renaming across drives raises - // EXDEV. Keeping the backup as a sibling of dist/ avoids that. - const backupRoot = mkdtempSync( - path.join(path.dirname(distPath), '.qwen-dist-backup-'), - ); - const backupDist = path.join(backupRoot, 'dist'); - const hadExistingDist = existsSync(distPath); - - if (hadExistingDist) { - renameSync(distPath, backupDist); + if (existsSync('dist')) { + return false; } mkdirSync('dist/vendor', { recursive: true }); @@ -1891,23 +1304,7 @@ function ensureMinimalDist() { 'dist/package.json', JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - - let restored = false; - const restore = () => { - if (restored) { - return; - } - restored = true; - pendingDistBackups.delete(restore); - rmSync(distPath, { recursive: true, force: true }); - if (hadExistingDist) { - renameSync(backupDist, distPath); - } - rmSync(backupRoot, { recursive: true, force: true }); - }; - - pendingDistBackups.add(restore); - return restore; + return true; } function createFakeNodeArchive(tmpDir, options = {}) { @@ -1980,11 +1377,7 @@ function createFakeWindowsNodeArchive(tmpDir) { return archive; } -function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { - const { - includeNode = true, - manifest = { name: '@qwen-code/qwen-code', target: 'win-x64' }, - } = options; +function createFakeWindowsStandaloneArchive(tmpDir) { const packageRoot = path.join(tmpDir, 'qwen-code'); const outDir = path.join(tmpDir, 'out'); mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); @@ -1995,15 +1388,10 @@ function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { path.join(packageRoot, 'bin', 'qwen.cmd'), ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), ); - if (includeNode) { - writeFileSync( - path.join(packageRoot, 'node', 'node.exe'), - 'fake node.exe\n', - ); - } + writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify(manifest), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }), ); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); @@ -2012,62 +1400,8 @@ function createFakeWindowsStandaloneArchive(tmpDir, options = {}) { return archive; } -function createWindowsTraversalStandaloneArchive(tmpDir) { - const outDir = path.join(tmpDir, 'out'); - mkdirSync(outDir, { recursive: true }); - - const archive = path.join(outDir, 'qwen-code-win-x64.zip'); - // PowerShell's `-Command` parser is fragile for multi-line scripts that - // include function definitions and quoted entry names. Joining with - // `; ` produces lines like `function f() {; ...; }; }` that older - // PowerShell versions reject. Write the script to a .ps1 file and run - // `-File` instead, which uses the same parser as a real script. - const scriptPath = path.join(tmpDir, 'create-traversal-archive.ps1'); - writeFileSync( - scriptPath, - [ - "$ErrorActionPreference = 'Stop'", - 'Add-Type -AssemblyName System.IO.Compression', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem', - 'function Add-ZipEntry($zip, $name, $content) {', - ' $entry = $zip.CreateEntry($name)', - ' $writer = [System.IO.StreamWriter]::new($entry.Open())', - ' try { $writer.Write($content) } finally { $writer.Dispose() }', - '}', - 'if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }', - '$zip = [System.IO.Compression.ZipFile]::Open($env:QWEN_TEST_ZIP_ARCHIVE, [System.IO.Compression.ZipArchiveMode]::Create)', - 'try {', - " Add-ZipEntry $zip '../qwen-slip' 'path traversal'", - ' Add-ZipEntry $zip \'qwen-code/bin/qwen.cmd\' "@echo off`r`necho 0.0.0-smoke`r`n"', - " Add-ZipEntry $zip 'qwen-code/node/node.exe' 'fake node.exe'", - ' Add-ZipEntry $zip \'qwen-code/manifest.json\' \'{"name":"@qwen-code/qwen-code","target":"win-x64"}\'', - '} finally { $zip.Dispose() }', - '', - ].join('\r\n'), - ); - - execFileSync( - 'powershell', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], - { - env: { - ...process.env, - QWEN_TEST_ZIP_ARCHIVE: archive, - }, - stdio: 'pipe', - }, - ); - writeChecksumFile(outDir, path.basename(archive)); - return archive; -} - function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { - // Mirror create-standalone-package.js: use CreateFromDirectory so - // entry names use forward slashes and match what the production - // builder ships. Compress-Archive would write backslashes, which - // the .bat installer's ValidateArchiveContents normalizes but the - // production archive shouldn't depend on that leniency. execFileSync( 'powershell', [ @@ -2075,7 +1409,7 @@ function createZipForTest(archive, cwd, entry) { '-ExecutionPolicy', 'Bypass', '-Command', - 'Add-Type -AssemblyName System.IO.Compression.FileSystem; if (Test-Path -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE) { Remove-Item -LiteralPath $env:QWEN_TEST_ZIP_ARCHIVE -Force }; [IO.Compression.ZipFile]::CreateFromDirectory($env:QWEN_TEST_ZIP_ENTRY, $env:QWEN_TEST_ZIP_ARCHIVE, [IO.Compression.CompressionLevel]::Optimal, $true)', + 'Compress-Archive -LiteralPath $env:QWEN_TEST_ZIP_ENTRY -DestinationPath $env:QWEN_TEST_ZIP_ARCHIVE -Force', ], { env: { @@ -2247,7 +1581,7 @@ function createSymlinkStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); const outDir = path.join(tmpDir, 'out'); @@ -2282,7 +1616,7 @@ function createTraversalStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n'); @@ -2306,9 +1640,6 @@ function writeChecksumFile(outDir, archiveName) { writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); } -// Writes a synthetic standalone release directory: each archive name in -// `archiveNames` becomes a small file whose content equals the asset name, -// and SHA256SUMS is regenerated to match. function writeStandaloneReleaseAssets(outDir, archiveNames) { mkdirSync(outDir, { recursive: true }); for (const assetName of archiveNames) { @@ -2320,8 +1651,6 @@ function writeStandaloneReleaseAssets(outDir, archiveNames) { function writeStandaloneReleaseChecksums(outDir, archiveNames) { const lines = archiveNames.map((assetName) => { const filePath = path.join(outDir, assetName); - // Allow callers to list a not-yet-written archive name (e.g. an - // "unexpected extra" entry) without requiring the file to exist. const hash = existsSync(filePath) ? crypto.createHash('sha256').update(readFileSync(filePath)).digest('hex') : 'a'.repeat(64); @@ -2330,10 +1659,6 @@ function writeStandaloneReleaseChecksums(outDir, archiveNames) { writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); } -// Generates a SHA256SUMS-formatted string for the given archive names. The -// hash values are placeholders — the remote verifier (verifyReleaseBaseUrl) -// only checks that SHA256SUMS lists the expected entries and that each -// archive URL is reachable; it does not download archives or compare hashes. function placeholderChecksumContent(archiveNames) { return `${archiveNames .map( diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index f3536a8f794..e84f75ca5c2 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -6,30 +6,23 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { - RELEASE_TARGETS, - standaloneArchiveName, -} from './build-standalone-release.js'; -import { - fail, - isMainModule, - parseCliArgs, - parseSha256Sums, - sha256File, -} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -// This import-time computation intentionally asserts release/package target -// consistency via standaloneArchiveName(); keep RELEASE_TARGETS backed by TARGETS. -const EXPECTED_STANDALONE_ARCHIVE_NAMES = RELEASE_TARGETS.map( - ({ qwenTarget }) => standaloneArchiveName(qwenTarget), -); +const EXPECTED_STANDALONE_ARCHIVE_NAMES = [ + 'qwen-code-darwin-arm64.tar.gz', + 'qwen-code-darwin-x64.tar.gz', + 'qwen-code-linux-arm64.tar.gz', + 'qwen-code-linux-x64.tar.gz', + 'qwen-code-win-x64.zip', +]; // Release artifacts that the installer chain expects in a GitHub Release. // Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served // from a separate hosted endpoint and are @@ -40,13 +33,6 @@ const EXPECTED_RELEASE_ASSET_NAMES = [ 'SHA256SUMS', ]; -const CLI_OPTIONS = { - '--help': { name: 'help', type: 'boolean' }, - '-h': { name: 'help', type: 'boolean' }, - '--dir': { name: 'dir' }, - '--base-url': { name: 'baseUrl' }, -}; - if (isMainModule(import.meta.url)) { try { await main(); @@ -57,11 +43,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseCliArgs(process.argv.slice(2), CLI_OPTIONS, { - help: false, - dir: undefined, - baseUrl: undefined, - }); + const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); return; @@ -94,6 +76,36 @@ Options: `); } +function parseArgs(argv) { + const args = { + help: false, + dir: undefined, + baseUrl: undefined, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--help': + case '-h': + args.help = true; + break; + case '--dir': + args.dir = readOptionValue(argv, index, arg); + index += 1; + break; + case '--base-url': + args.baseUrl = readOptionValue(argv, index, arg); + index += 1; + break; + default: + fail(`Unknown option: ${arg}`); + } + } + + return args; +} + async function verifyReleaseDirectory(dir) { const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); @@ -172,25 +184,20 @@ function assertExpectedArchiveFiles(dir) { } async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { - const results = await Promise.allSettled( - EXPECTED_STANDALONE_ARCHIVE_NAMES.map(async (assetName) => { + const failures = []; + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + try { await assertRemoteAssetAvailable( new URL(assetName, normalizedBaseUrl).toString(), fetchImpl, ); - return assetName; - }), - ); - const failures = results.flatMap((result, index) => - result.status === 'rejected' - ? [ - { - assetName: EXPECTED_STANDALONE_ARCHIVE_NAMES[index], - reason: formatErrorReason(result.reason), - }, - ] - : [], - ); + } catch (reason) { + failures.push({ + assetName, + reason: formatErrorReason(reason), + }); + } + } if (failures.length === 0) { return; @@ -265,6 +272,46 @@ function normalizeHttpsBaseUrl(baseUrl) { return parsed.toString(); } +function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +function parseSha256Sums(content) { + const checksums = new Map(); + for (const [index, line] of content.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +function fail(message) { + throw new Error(`ERROR: ${message}`); +} + export { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl, From af64da874fed556a6e4091b65df043a169282c6a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 18:53:45 +0800 Subject: [PATCH 060/129] feat(installer): restore hosted PowerShell entrypoint --- scripts/build-hosted-installation-assets.js | 4 +++ scripts/installation/INSTALLATION_GUIDE.md | 25 ++++++++------ scripts/installation/install-qwen.ps1 | 37 +++++++++++++++++++++ scripts/tests/install-script.test.js | 27 +++++++++++++-- 4 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 scripts/installation/install-qwen.ps1 diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index b308057292f..d5e3f45fca4 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -27,6 +27,10 @@ const HOSTED_INSTALLATION_ASSETS = [ output: 'install-qwen.bat', lineEndings: 'crlf', }, + { + sourcePath: ['scripts', 'installation', 'install-qwen.ps1'], + output: 'install-qwen.ps1', + }, ]; const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 33607431c9f..4acf5a87075 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -23,7 +23,7 @@ are only required when the installer falls back to npm or when ## Installation Scripts - Linux/macOS: `install-qwen.sh` -- Windows: `install-qwen.bat` +- Windows: `install-qwen.ps1` ## Release Artifacts @@ -37,7 +37,7 @@ GitHub releases publish these standalone archives: - `SHA256SUMS` The installer scripts (`install-qwen.sh`, -`install-qwen.bat`) are not republished per release. They are +`install-qwen.ps1`) are not republished per release. They are served from a hosted installation endpoint and accept `--version` to pin a specific standalone release. This keeps the public install command on a stable hosted entrypoint while still allowing version pinning, rather than using @@ -58,16 +58,15 @@ curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/in ``` ```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 -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" ``` -To pin a release with the hosted Windows entrypoint, download `install-qwen.bat` -and pass `--version`: +To pin a release with the hosted Windows entrypoint, set +`QWEN_INSTALL_VERSION` before invoking `install-qwen.ps1`: ```powershell -$installer = Join-Path $env:TEMP 'install-qwen.bat' -Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer -& $installer --version vX.Y.Z +$env:QWEN_INSTALL_VERSION = 'vX.Y.Z' +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex ``` `QWEN_INSTALL_VERSION` is the equivalent environment variable when arguments @@ -76,7 +75,9 @@ cannot be passed through. Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen.sh` is the Linux/macOS hosted entrypoint. -- `install-qwen.bat` is the Windows hosted entrypoint (also runnable directly). +- `install-qwen.ps1` is the Windows hosted entrypoint for `irm | iex`. +- `install-qwen.bat` is the Windows installer implementation used by + `install-qwen.ps1` and can also be downloaded and run directly. Build them with: @@ -84,9 +85,11 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged `install-qwen.sh` and `install-qwen.bat` files map to the fixed +The staged `install-qwen.sh`, `install-qwen.ps1`, and `install-qwen.bat` files +map to the fixed hosted URLs shown above. Upload their contents byte-for-byte to -`installation/install-qwen.sh` and `installation/install-qwen.bat`; the staging +`installation/install-qwen.sh`, `installation/install-qwen.ps1`, and +`installation/install-qwen.bat`; the staging command also writes `SHA256SUMS` for upload verification. The hosted installers intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone release. OSS/CDN upload automation is still a follow-up release diff --git a/scripts/installation/install-qwen.ps1 b/scripts/installation/install-qwen.ps1 new file mode 100644 index 00000000000..8fcf0b11349 --- /dev/null +++ b/scripts/installation/install-qwen.ps1 @@ -0,0 +1,37 @@ +# Qwen Code Windows hosted PowerShell entrypoint. +# Pairs with install-qwen.bat: this shim downloads the .bat into TEMP and runs +# it, so the documented one-liner can use the standard irm | iex pattern. +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +# +# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, +# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing +# --version vX.Y.Z to install-qwen.bat directly. + +$ErrorActionPreference = 'Stop' + +$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' +$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen.bat' + +try { + Invoke-WebRequest -Uri $qwenInstallerUrl ` + -OutFile $qwenInstallerPath ` + -UseBasicParsing ` + -MaximumRedirection 10 +} catch { + Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" + exit 1 +} + +$qwenInstallerExitCode = 0 +try { + & $qwenInstallerPath @args + $qwenInstallerExitCode = $LASTEXITCODE +} finally { + Remove-Item -Path $qwenInstallerPath -Force -ErrorAction SilentlyContinue +} + +if ($qwenInstallerExitCode -ne 0) { + exit $qwenInstallerExitCode +} diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 72973f03357..3fc7713141c 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -264,6 +264,7 @@ describe('standalone release packaging', () => { expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); expect(hostedInstallScript).toContain("output: 'install-qwen.sh'"); expect(hostedInstallScript).toContain("output: 'install-qwen.bat'"); + expect(hostedInstallScript).toContain("output: 'install-qwen.ps1'"); expect(hostedInstallScript).not.toContain("output: 'install'"); const releaseVerifyScript = readScript( @@ -409,6 +410,15 @@ describe('standalone release packaging', () => { ); expect(installBatchSource).toContain('"%~1"=="--version"'); expect(installBatchSource).toContain('--version requires a value'); + + const installPowerShellSource = readScript( + 'scripts/installation/install-qwen.ps1', + ); + expect(installPowerShellSource).toContain('install-qwen.bat'); + expect(installPowerShellSource).toContain('Invoke-WebRequest'); + expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); + expect(installPowerShellSource).toContain('--version vX.Y.Z'); + expect(installPowerShellSource).toContain('@args'); }); it('stages hosted installation assets with checksums', async () => { @@ -425,12 +435,14 @@ describe('standalone release packaging', () => { const installSh = path.join(tmpDir, 'install-qwen.sh'); const installBat = path.join(tmpDir, 'install-qwen.bat'); + const installPs1 = path.join(tmpDir, 'install-qwen.ps1'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); const checksumLines = checksums.trim().split('\n'); expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ 'install-qwen.sh', 'install-qwen.bat', + 'install-qwen.ps1', ]); expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, @@ -444,14 +456,18 @@ describe('standalone release packaging', () => { '\r\n', ), ); + expect(readScript(installPs1)).toBe( + readScript('scripts/installation/install-qwen.ps1'), + ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); - expect(existsSync(path.join(tmpDir, 'install-qwen.ps1'))).toBe(false); expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ 'install-qwen.bat', + 'install-qwen.ps1', 'install-qwen.sh', ]); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); + expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.ps1$/m); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); } @@ -485,6 +501,10 @@ describe('standalone release packaging', () => { path.join(sourceDir, 'install-qwen.bat'), '@echo off\r\nset "VERSION=latest"\r\n', ); + writeFileSync( + path.join(sourceDir, 'install-qwen.ps1'), + "# --version vX.Y.Z\n$env:QWEN_INSTALL_VERSION = 'latest'\n", + ); await expect( buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), @@ -598,7 +618,7 @@ describe('standalone release packaging', () => { ]); } for (const [url] of fetchedUrls) { - expect(url).not.toMatch(/install-qwen\.(sh|bat)$/); + expect(url).not.toMatch(/install-qwen\.(sh|bat|ps1)$/); expect(url).not.toMatch(/\/install$/); } }); @@ -831,6 +851,7 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain('package:installation-assets'); expect(workflow).not.toContain('install-qwen.sh'); expect(workflow).not.toContain('install-qwen.bat'); + expect(workflow).not.toContain('install-qwen.ps1'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); @@ -855,6 +876,8 @@ describe('standalone release packaging', () => { expect(guide).toContain('package:hosted-installation'); expect(guide).toContain('installation/install-qwen.sh'); expect(guide).toContain('installation/install-qwen.bat'); + expect(guide).toContain('installation/install-qwen.ps1'); + expect(guide).toContain('irm https://qwen-code-assets'); expect(guide).toContain('release operators must sync these staged files'); expect(guide).toContain('Hosted endpoint status'); expect(guide).toContain('legacy NVM-based installer'); From 5f649c952395a35bb394059c7de44fb9246ddff7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 19:09:20 +0800 Subject: [PATCH 061/129] chore(installer): stage standalone hosted entrypoints --- scripts/build-hosted-installation-assets.js | 17 +-- scripts/installation/INSTALLATION_GUIDE.md | 84 +++++++------- ...l-qwen.bat => install-qwen-standalone.bat} | 2 +- ...l-qwen.ps1 => install-qwen-standalone.ps1} | 10 +- ...all-qwen.sh => install-qwen-standalone.sh} | 12 +- scripts/tests/install-script.test.js | 104 +++++++++++------- 6 files changed, 127 insertions(+), 102 deletions(-) rename scripts/installation/{install-qwen.bat => install-qwen-standalone.bat} (99%) rename scripts/installation/{install-qwen.ps1 => install-qwen-standalone.ps1} (73%) rename scripts/installation/{install-qwen.sh => install-qwen-standalone.sh} (98%) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index d5e3f45fca4..6931af59d8a 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -18,18 +18,18 @@ const rootDir = path.resolve(__dirname, '..'); const HOSTED_INSTALLATION_ASSETS = [ { - sourcePath: ['scripts', 'installation', 'install-qwen.sh'], - output: 'install-qwen.sh', + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.sh'], + output: 'install-qwen-standalone.sh', mode: 0o755, }, { - sourcePath: ['scripts', 'installation', 'install-qwen.bat'], - output: 'install-qwen.bat', + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.bat'], + output: 'install-qwen-standalone.bat', lineEndings: 'crlf', }, { - sourcePath: ['scripts', 'installation', 'install-qwen.ps1'], - output: 'install-qwen.ps1', + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.ps1'], + output: 'install-qwen-standalone.ps1', }, ]; const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( @@ -44,8 +44,9 @@ const HOSTED_INSTALLER_REQUIRED_FRAGMENTS = [ // or help text even when the actual default has been changed. The patterns // allow whitespace flexibility but require the literal default value. const HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS = { - 'install-qwen.sh': /VERSION\s*=\s*"\$\{QWEN_INSTALL_VERSION:-latest\}"/, - 'install-qwen.bat': /set\s+"VERSION=latest"/, + 'install-qwen-standalone.sh': + /VERSION\s*=\s*"\$\{QWEN_INSTALL_VERSION:-latest\}"/, + 'install-qwen-standalone.bat': /set\s+"VERSION=latest"/, }; // SHA256SUMS is allowed in an existing output directory because every staging // run rewrites it from scratch after copying the hosted installer assets. diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 4acf5a87075..c3c3b2d437b 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -22,8 +22,8 @@ are only required when the installer falls back to npm or when ## Installation Scripts -- Linux/macOS: `install-qwen.sh` -- Windows: `install-qwen.ps1` +- Linux/macOS: `install-qwen-standalone.sh` +- Windows: `install-qwen-standalone.ps1` ## Release Artifacts @@ -36,37 +36,38 @@ GitHub releases publish these standalone archives: - `qwen-code-win-x64.zip` - `SHA256SUMS` -The installer scripts (`install-qwen.sh`, -`install-qwen.ps1`) are not republished per release. They are -served from a hosted installation endpoint and accept `--version` to pin a -specific standalone release. This keeps the public install command on a stable -hosted entrypoint while still allowing version pinning, rather than using -per-release installer URLs. +The new standalone-first installer scripts (`install-qwen-standalone.sh`, +`install-qwen-standalone.ps1`) are not republished per release. They are served +from a hosted installation endpoint and accept `--version` to pin a specific +standalone release. The `standalone` suffix intentionally avoids overwriting the +existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during +the staged rollout. > **Hosted endpoint status**: Until the hosted endpoint is re-synced after the > next release, the URL below still serves the legacy NVM-based installer, > which does not honor `--version` or `QWEN_INSTALL_VERSION` in the way > documented here. To get the standalone-archive-first behavior immediately, -> run `install-qwen.sh` from a local checkout of this repository. +> run `install-qwen-standalone.sh` from a local checkout of this repository, or +> upload the staged standalone-suffixed files to separate OSS keys for testing. > The `--version` examples below describe the post-sync behavior. -Latest hosted entrypoints used today: +Standalone hosted entrypoints for staged rollout: ```bash -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.sh | bash -s -- --version vX.Y.Z +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --version vX.Y.Z ``` ```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex" ``` To pin a release with the hosted Windows entrypoint, set -`QWEN_INSTALL_VERSION` before invoking `install-qwen.ps1`: +`QWEN_INSTALL_VERSION` before invoking `install-qwen-standalone.ps1`: ```powershell $env:QWEN_INSTALL_VERSION = 'vX.Y.Z' -irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` `QWEN_INSTALL_VERSION` is the equivalent environment variable when arguments @@ -74,10 +75,10 @@ cannot be passed through. Hosted installer assets are staged separately from GitHub Release archives: -- `install-qwen.sh` is the Linux/macOS hosted entrypoint. -- `install-qwen.ps1` is the Windows hosted entrypoint for `irm | iex`. -- `install-qwen.bat` is the Windows installer implementation used by - `install-qwen.ps1` and can also be downloaded and run directly. +- `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint. +- `install-qwen-standalone.ps1` is the Windows hosted entrypoint for `irm | iex`. +- `install-qwen-standalone.bat` is the Windows installer implementation used by + `install-qwen-standalone.ps1` and can also be downloaded and run directly. Build them with: @@ -85,14 +86,15 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged `install-qwen.sh`, `install-qwen.ps1`, and `install-qwen.bat` files -map to the fixed -hosted URLs shown above. Upload their contents byte-for-byte to -`installation/install-qwen.sh`, `installation/install-qwen.ps1`, and -`installation/install-qwen.bat`; the staging -command also writes `SHA256SUMS` for upload verification. The hosted installers -intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to -pin a standalone release. OSS/CDN upload automation is still a follow-up release +The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, and +`install-qwen-standalone.bat` files map to the standalone-suffixed hosted URLs +shown above. Upload their contents byte-for-byte to +`installation/install-qwen-standalone.sh`, +`installation/install-qwen-standalone.ps1`, and +`installation/install-qwen-standalone.bat`; the staging command also writes +`SHA256SUMS` for upload verification. The hosted installers intentionally +default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a +standalone release. OSS/CDN upload automation is still a follow-up release operation; until then, release operators must sync these staged files manually. Archive layout: @@ -121,13 +123,13 @@ The default method is `detect`: You can force a method: ```bash -bash install-qwen.sh --method standalone -bash install-qwen.sh --method npm +bash install-qwen-standalone.sh --method standalone +bash install-qwen-standalone.sh --method npm ``` ```bat -install-qwen.bat --method standalone -install-qwen.bat --method npm +install-qwen-standalone.bat --method standalone +install-qwen-standalone.bat --method npm ``` ## Optional Native Modules @@ -145,20 +147,20 @@ modules for the current machine. ```bash # Default: standalone archive with npm fallback -bash install-qwen.sh +bash install-qwen-standalone.sh # Record a source value -bash install-qwen.sh --source github +bash install-qwen-standalone.sh --source github # Use npm explicitly -bash install-qwen.sh --method npm --registry https://registry.npmjs.org +bash install-qwen-standalone.sh --method npm --registry https://registry.npmjs.org # Use the Aliyun standalone mirror -bash install-qwen.sh --mirror aliyun +bash install-qwen-standalone.sh --mirror aliyun # Install an offline archive # SHA256SUMS must be in the same directory. -bash install-qwen.sh --archive ./qwen-code-linux-x64.tar.gz +bash install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz ``` Standalone installs to: @@ -173,20 +175,20 @@ Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, ```bat REM Default: standalone archive with npm fallback -install-qwen.bat +install-qwen-standalone.bat REM Record a source value -install-qwen.bat --source github +install-qwen-standalone.bat --source github REM Use npm explicitly -install-qwen.bat --method npm --registry https://registry.npmjs.org +install-qwen-standalone.bat --method npm --registry https://registry.npmjs.org REM Use the Aliyun standalone mirror -install-qwen.bat --mirror aliyun +install-qwen-standalone.bat --mirror aliyun REM Install an offline archive REM SHA256SUMS must be in the same directory. -install-qwen.bat --archive qwen-code-win-x64.zip +install-qwen-standalone.bat --archive qwen-code-win-x64.zip ``` Standalone installs to: diff --git a/scripts/installation/install-qwen.bat b/scripts/installation/install-qwen-standalone.bat similarity index 99% rename from scripts/installation/install-qwen.bat rename to scripts/installation/install-qwen-standalone.bat index d1c52ebd527..fc9192a4f2b 100644 --- a/scripts/installation/install-qwen.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -230,7 +230,7 @@ exit /b 1 :PrintUsage echo Qwen Code Installer echo. -echo Usage: install-qwen.bat [OPTIONS] +echo Usage: install-qwen-standalone.bat [OPTIONS] echo. echo Options: echo -s, --source SOURCE Record the installation source. diff --git a/scripts/installation/install-qwen.ps1 b/scripts/installation/install-qwen-standalone.ps1 similarity index 73% rename from scripts/installation/install-qwen.ps1 rename to scripts/installation/install-qwen-standalone.ps1 index 8fcf0b11349..e19c3407444 100644 --- a/scripts/installation/install-qwen.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -1,18 +1,18 @@ # Qwen Code Windows hosted PowerShell entrypoint. -# Pairs with install-qwen.bat: this shim downloads the .bat into TEMP and runs +# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP and runs # it, so the documented one-liner can use the standard irm | iex pattern. # # Usage: -# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.ps1 | iex" +# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex" # # To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, # e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing -# --version vX.Y.Z to install-qwen.bat directly. +# --version vX.Y.Z to install-qwen-standalone.bat directly. $ErrorActionPreference = 'Stop' -$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen.bat' +$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' +$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen-standalone.bat' try { Invoke-WebRequest -Uri $qwenInstallerUrl ` diff --git a/scripts/installation/install-qwen.sh b/scripts/installation/install-qwen-standalone.sh similarity index 98% rename from scripts/installation/install-qwen.sh rename to scripts/installation/install-qwen-standalone.sh index 7890666eace..27e06ccd386 100755 --- a/scripts/installation/install-qwen.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -5,8 +5,8 @@ # This script intentionally does not install Node.js or change npm config. # # Usage: -# install-qwen.sh --source [github|npm|internal|local-build] -# install-qwen.sh --method [detect|standalone|npm] +# install-qwen-standalone.sh --source [github|npm|internal|local-build] +# install-qwen-standalone.sh --method [detect|standalone|npm] if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then if command -v bash >/dev/null 2>&1; then @@ -98,10 +98,10 @@ Options: -h, --help Show this help message. Examples: - 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.sh | bash -s -- --source github - curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --method standalone - ./install-qwen.sh --archive ./qwen-code-linux-x64.tar.gz + curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash + curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --source github + curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --method standalone + ./install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz EOF } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 3fc7713141c..6e33393edeb 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -40,7 +40,9 @@ const itOnWindows = process.platform === 'win32' ? it : it.skip; describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { - const script = readScript('scripts/installation/install-qwen.sh'); + const script = readScript( + 'scripts/installation/install-qwen-standalone.sh', + ); expect(script).not.toContain('install_nvm'); expect(script).not.toContain('install_nvm.sh'); @@ -65,7 +67,9 @@ describe('installation scripts', () => { }); it('supports code-server-style standalone install on Linux/macOS', () => { - const script = readScript('scripts/installation/install-qwen.sh'); + const script = readScript( + 'scripts/installation/install-qwen-standalone.sh', + ); expect(script).toContain('--method METHOD'); expect(script).toContain('--mirror MIRROR'); @@ -117,7 +121,9 @@ describe('installation scripts', () => { }); it('keeps the Windows installer lightweight', () => { - const script = readScript('scripts/installation/install-qwen.bat'); + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); @@ -141,7 +147,9 @@ describe('installation scripts', () => { }); it('supports code-server-style standalone install on Windows', () => { - const script = readScript('scripts/installation/install-qwen.bat'); + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); expect(script).toContain('--method METHOD'); expect(script).toContain('--mirror MIRROR'); @@ -262,9 +270,15 @@ describe('standalone release packaging', () => { expect(hostedInstallScript).toContain('Copyright 2025 Qwen Team'); expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); - expect(hostedInstallScript).toContain("output: 'install-qwen.sh'"); - expect(hostedInstallScript).toContain("output: 'install-qwen.bat'"); - expect(hostedInstallScript).toContain("output: 'install-qwen.ps1'"); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.sh'", + ); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.bat'", + ); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.ps1'", + ); expect(hostedInstallScript).not.toContain("output: 'install'"); const releaseVerifyScript = readScript( @@ -393,7 +407,7 @@ describe('standalone release packaging', () => { it('installer scripts honor --version for hosted entrypoints', () => { const installShellSource = readScript( - 'scripts/installation/install-qwen.sh', + 'scripts/installation/install-qwen-standalone.sh', ); expect(installShellSource).toContain( 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', @@ -402,7 +416,7 @@ describe('standalone release packaging', () => { expect(installShellSource).toContain('--version requires a value'); const installBatchSource = readScript( - 'scripts/installation/install-qwen.bat', + 'scripts/installation/install-qwen-standalone.bat', ); expect(installBatchSource).toContain('set "VERSION=latest"'); expect(installBatchSource).toContain( @@ -412,9 +426,9 @@ describe('standalone release packaging', () => { expect(installBatchSource).toContain('--version requires a value'); const installPowerShellSource = readScript( - 'scripts/installation/install-qwen.ps1', + 'scripts/installation/install-qwen-standalone.ps1', ); - expect(installPowerShellSource).toContain('install-qwen.bat'); + expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); expect(installPowerShellSource).toContain('Invoke-WebRequest'); expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); expect(installPowerShellSource).toContain('--version vX.Y.Z'); @@ -433,41 +447,47 @@ describe('standalone release packaging', () => { try { await buildHostedInstallationAssets(tmpDir); - const installSh = path.join(tmpDir, 'install-qwen.sh'); - const installBat = path.join(tmpDir, 'install-qwen.bat'); - const installPs1 = path.join(tmpDir, 'install-qwen.ps1'); + const installSh = path.join(tmpDir, 'install-qwen-standalone.sh'); + const installBat = path.join(tmpDir, 'install-qwen-standalone.bat'); + const installPs1 = path.join(tmpDir, 'install-qwen-standalone.ps1'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); const checksumLines = checksums.trim().split('\n'); expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ - 'install-qwen.sh', - 'install-qwen.bat', - 'install-qwen.ps1', + 'install-qwen-standalone.sh', + 'install-qwen-standalone.bat', + 'install-qwen-standalone.ps1', ]); expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, ); expect(readScript(installSh)).toBe( - readScript('scripts/installation/install-qwen.sh'), + readScript('scripts/installation/install-qwen-standalone.sh'), ); expect(readScript(installBat)).toBe( - readScript('scripts/installation/install-qwen.bat').replace( + readScript('scripts/installation/install-qwen-standalone.bat').replace( /\r?\n/g, '\r\n', ), ); expect(readScript(installPs1)).toBe( - readScript('scripts/installation/install-qwen.ps1'), + readScript('scripts/installation/install-qwen-standalone.ps1'), ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ - 'install-qwen.bat', - 'install-qwen.ps1', - 'install-qwen.sh', + 'install-qwen-standalone.bat', + 'install-qwen-standalone.ps1', + 'install-qwen-standalone.sh', ]); - expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.sh$/m); - expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.bat$/m); - expect(checksums).toMatch(/^[0-9a-f]{64} {2}install-qwen\.ps1$/m); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.sh$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.bat$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.ps1$/m, + ); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); } @@ -475,7 +495,9 @@ describe('standalone release packaging', () => { writeFileSync(installSh, 'tampered'); await expect( assertHostedInstallationAssetChecksums(tmpDir), - ).rejects.toThrow(/Checksum verification failed for install-qwen\.sh/); + ).rejects.toThrow( + /Checksum verification failed for install-qwen-standalone\.sh/, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -492,24 +514,24 @@ describe('standalone release packaging', () => { try { mkdirSync(sourceDir, { recursive: true }); writeFileSync( - path.join(sourceDir, 'install-qwen.sh'), + path.join(sourceDir, 'install-qwen-standalone.sh'), '#!/usr/bin/env bash\n' + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', ); writeFileSync( - path.join(sourceDir, 'install-qwen.bat'), + path.join(sourceDir, 'install-qwen-standalone.bat'), '@echo off\r\nset "VERSION=latest"\r\n', ); writeFileSync( - path.join(sourceDir, 'install-qwen.ps1'), + path.join(sourceDir, 'install-qwen-standalone.ps1'), "# --version vX.Y.Z\n$env:QWEN_INSTALL_VERSION = 'latest'\n", ); await expect( buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), ).rejects.toThrow( - /install-qwen\.sh default install version must be 'latest'/, + /install-qwen-standalone\.sh default install version must be 'latest'/, ); } finally { rmSync(tmpRoot, { recursive: true, force: true }); @@ -849,9 +871,9 @@ describe('standalone release packaging', () => { expect(workflow).toContain('npm run package:standalone:release --'); expect(workflow).not.toContain('package:installation-assets'); - expect(workflow).not.toContain('install-qwen.sh'); - expect(workflow).not.toContain('install-qwen.bat'); - expect(workflow).not.toContain('install-qwen.ps1'); + expect(workflow).not.toContain('install-qwen-standalone.sh'); + expect(workflow).not.toContain('install-qwen-standalone.bat'); + expect(workflow).not.toContain('install-qwen-standalone.ps1'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); @@ -874,9 +896,9 @@ describe('standalone release packaging', () => { expect(guide).toContain('Optional Native Modules'); expect(guide).toContain('package:hosted-installation'); - expect(guide).toContain('installation/install-qwen.sh'); - expect(guide).toContain('installation/install-qwen.bat'); - expect(guide).toContain('installation/install-qwen.ps1'); + expect(guide).toContain('installation/install-qwen-standalone.sh'); + expect(guide).toContain('installation/install-qwen-standalone.bat'); + expect(guide).toContain('installation/install-qwen-standalone.ps1'); expect(guide).toContain('irm https://qwen-code-assets'); expect(guide).toContain('release operators must sync these staged files'); expect(guide).toContain('Hosted endpoint status'); @@ -1157,7 +1179,7 @@ describe('Linux/macOS installer end-to-end', () => { const output = execFileSync( 'bash', [ - 'scripts/installation/install-qwen.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -1201,7 +1223,7 @@ describe('Linux/macOS installer end-to-end', () => { execFileSync( 'bash', [ - 'scripts/installation/install-qwen.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -1513,7 +1535,7 @@ function runUnixInstaller( return execFileSync( 'bash', [ - 'scripts/installation/install-qwen.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', method, '--archive', @@ -1554,7 +1576,7 @@ function runWindowsInstaller( try { return runWindowsCommand( [ - `call "${path.resolve('scripts/installation/install-qwen.bat')}"`, + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, '--method', method, '--archive', From 984b1a9a0ed488cac7352795b9111d7524c2d5a7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 19:29:49 +0800 Subject: [PATCH 062/129] fix(installer): address hosted installer review followups --- .gitignore | 3 - scripts/build-hosted-installation-assets.js | 42 ++++- .../installation/install-qwen-standalone.bat | 87 ++++++++-- .../installation/install-qwen-standalone.sh | 15 +- scripts/tests/install-script.test.js | 161 +++++++++++++++++- scripts/verify-installation-release.js | 17 +- 6 files changed, 291 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 90d51cfdf4e..5010fb81026 100644 --- a/.gitignore +++ b/.gitignore @@ -47,9 +47,6 @@ Thumbs.db # Ignore built ts files dist -# Backup of dist/ created by script tests (see ensureMinimalDist) -.qwen-dist-backup-* - # Docker folder to help skip auth refreshes .docker diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 6931af59d8a..5b704189597 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -35,10 +35,38 @@ const HOSTED_INSTALLATION_ASSETS = [ const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, ); -const HOSTED_INSTALLER_REQUIRED_FRAGMENTS = [ - '--version', - 'QWEN_INSTALL_VERSION', -]; +const HOSTED_INSTALLER_BEHAVIOR_PATTERNS = { + 'install-qwen-standalone.sh': [ + { + name: 'QWEN_INSTALL_VERSION', + pattern: /QWEN_INSTALL_VERSION/, + }, + { + name: '--version parser', + pattern: /--version\)|--version=\*\)/, + }, + ], + 'install-qwen-standalone.bat': [ + { + name: 'QWEN_INSTALL_VERSION', + pattern: /QWEN_INSTALL_VERSION/, + }, + { + name: '--version parser', + pattern: /ARG_KEY!"=="--version"|"%~1"=="--version"/, + }, + ], + 'install-qwen-standalone.ps1': [ + { + name: 'argument forwarding', + pattern: /& \$qwenInstallerPath @args/, + }, + { + name: 'QWEN_INSTALL_VERSION documentation', + pattern: /QWEN_INSTALL_VERSION/, + }, + ], +}; // Narrow regexes that pin the default-version assignment to `latest`. // Substring matching alone would let the word "latest" leak in via comments // or help text even when the actual default has been changed. The patterns @@ -159,9 +187,9 @@ function copyHostedInstallationAsset(source, destination, asset) { function assertHostedInstallerSource(source, output) { const contents = fs.readFileSync(source, 'utf8'); - const missing = HOSTED_INSTALLER_REQUIRED_FRAGMENTS.filter( - (fragment) => !contents.includes(fragment), - ); + const missing = (HOSTED_INSTALLER_BEHAVIOR_PATTERNS[output] || []) + .filter(({ pattern }) => !pattern.test(contents)) + .map(({ name }) => name); if (missing.length > 0) { fail( `${output} is missing hosted installer behavior: ${missing.join(', ')}`, diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index fc9192a4f2b..dfe7f3c1ce4 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -34,7 +34,18 @@ if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" REM Parse flags before any network or filesystem work. :parse_args if "%~1"=="" goto end_parse -if /i "%~1"=="--source" ( +set "ARG_KEY=%~1" +set "ARG_VALUE=" +for /f "tokens=1,* delims==" %%A in ("%~1") do ( + set "ARG_KEY=%%~A" + set "ARG_VALUE=%%~B" +) +if /i "!ARG_KEY!"=="--source" ( + if not "!ARG_VALUE!"=="" ( + set "SOURCE=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --source requires a value exit /b 1 @@ -54,7 +65,12 @@ if /i "%~1"=="-s" ( shift goto parse_args ) -if /i "%~1"=="--method" ( +if /i "!ARG_KEY!"=="--method" ( + if not "!ARG_VALUE!"=="" ( + set "METHOD=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --method requires a value exit /b 1 @@ -64,7 +80,12 @@ if /i "%~1"=="--method" ( shift goto parse_args ) -if /i "%~1"=="--mirror" ( +if /i "!ARG_KEY!"=="--mirror" ( + if not "!ARG_VALUE!"=="" ( + set "MIRROR=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --mirror requires a value exit /b 1 @@ -74,7 +95,12 @@ if /i "%~1"=="--mirror" ( shift goto parse_args ) -if /i "%~1"=="--base-url" ( +if /i "!ARG_KEY!"=="--base-url" ( + if not "!ARG_VALUE!"=="" ( + set "BASE_URL=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --base-url requires a value exit /b 1 @@ -84,7 +110,12 @@ if /i "%~1"=="--base-url" ( shift goto parse_args ) -if /i "%~1"=="--archive" ( +if /i "!ARG_KEY!"=="--archive" ( + if not "!ARG_VALUE!"=="" ( + set "ARCHIVE_PATH=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --archive requires a value exit /b 1 @@ -94,7 +125,12 @@ if /i "%~1"=="--archive" ( shift goto parse_args ) -if /i "%~1"=="--version" ( +if /i "!ARG_KEY!"=="--version" ( + if not "!ARG_VALUE!"=="" ( + set "VERSION=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --version requires a value exit /b 1 @@ -104,7 +140,12 @@ if /i "%~1"=="--version" ( shift goto parse_args ) -if /i "%~1"=="--registry" ( +if /i "!ARG_KEY!"=="--registry" ( + if not "!ARG_VALUE!"=="" ( + set "NPM_REGISTRY=!ARG_VALUE!" + shift + goto parse_args + ) if "%~2"=="" ( echo ERROR: --registry requires a value exit /b 1 @@ -154,16 +195,16 @@ REM reliably simulate the user's PATH ordering, so enumerate well-known REM per-tool bin directories plus everything `where qwen` returns. set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('where qwen 2^>nul') do call echo %%i>>"!PRE_INSTALL_QWENS_FILE!" for %%c in ( "!USERPROFILE!\.opencode\bin\qwen.cmd" "!APPDATA!\npm\qwen.cmd" "!USERPROFILE!\.bun\bin\qwen.cmd" "!LOCALAPPDATA!\bun\bin\qwen.cmd" "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" -) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +) do if exist %%c call echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( - if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" + if exist "%%i\qwen.cmd" call echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" ) set "PRE_INSTALL_QWENS_LIST=" if exist "!PRE_INSTALL_QWENS_FILE!" ( @@ -333,6 +374,9 @@ if %ERRORLEVEL% NEQ 0 exit /b 1 call :ValidateVersion if %ERRORLEVEL% NEQ 0 exit /b 1 +call :ValidateGithubRepo +if %ERRORLEVEL% NEQ 0 exit /b 1 + call :ValidateSource exit /b %ERRORLEVEL% @@ -347,13 +391,22 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul -if !ERRORLEVEL! EQU 0 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 +:ValidateGithubRepo +if not defined QWEN_INSTALL_GITHUB_REPO exit /b 0 +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_INSTALL_GITHUB_REPO; if ($value -match '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { exit 0 }; exit 1" +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: QWEN_INSTALL_GITHUB_REPO must be in owner/repo format. +exit /b 1 + :ValidateSource if "!SOURCE!"=="unknown" exit /b 0 echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul @@ -800,7 +853,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) { $name = $entry.FullName -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); 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 @@ -997,6 +1050,10 @@ if defined OTHER_QWENS ( echo !EXTRA_BIN! ) else ( call :MaybeUpdateUserPath "!EXTRA_BIN!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to update user PATH. Add the directory manually: + echo !EXTRA_BIN! + ) echo. echo If you prefer not to modify user PATH, rerun with --no-modify-path echo and pick one of: diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 27e06ccd386..7f611f6ebda 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -165,6 +165,16 @@ validate_version() { exit 1 } +validate_github_repo() { + local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}" + if [[ "${github_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + return 0 + fi + + log_error "QWEN_INSTALL_GITHUB_REPO must be in owner/repo format." + exit 1 +} + validate_install_path() { local value="$1" local option_name="$2" @@ -211,6 +221,7 @@ validate_options() { validate_https_url "${BASE_URL}" "--base-url" validate_https_url "${NPM_REGISTRY}" "--registry" validate_version + validate_github_repo validate_install_path "${INSTALL_ROOT}" "QWEN_INSTALL_ROOT" validate_install_path "${INSTALL_LIB_PARENT}" "QWEN_INSTALL_LIB_PARENT" validate_install_path "${INSTALL_LIB_DIR}" "QWEN_INSTALL_LIB_DIR" @@ -567,9 +578,9 @@ race_mirror_head() { tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" mkdir -p "${tmpdir}" 2>/dev/null || true - (curl -fsI -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & + (curl -fsIL -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & local oss_pid=$! - (curl -fsI -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & + (curl -fsIL -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & local gh_pid=$! local winner="" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 6e33393edeb..a176ccbb6a7 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -117,6 +117,11 @@ describe('installation scripts', () => { ); expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"'); expect(script).toContain('TEMP_DIRS+='); + expect(script).toContain('validate_github_repo()'); + expect(script).toContain( + 'QWEN_INSTALL_GITHUB_REPO must be in owner/repo format', + ); + expect(script).toContain('curl -fsIL -m "${timeout}"'); expect(script).not.toContain('-print -quit'); }); @@ -196,11 +201,20 @@ 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('set "ARG_KEY=%~1"'); + expect(script).toContain('if /i "!ARG_KEY!"=="--version"'); + expect(script).toContain('$value -match'); + expect(script).toContain('QWEN_INSTALL_GITHUB_REPO'); + expect(script).toContain( + 'QWEN_INSTALL_GITHUB_REPO must be in owner/repo format', + ); expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); expect(script).toContain('qwen-code\\node\\node.exe'); 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('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); }); @@ -422,7 +436,7 @@ describe('standalone release packaging', () => { expect(installBatchSource).toContain( 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', ); - expect(installBatchSource).toContain('"%~1"=="--version"'); + expect(installBatchSource).toContain('!ARG_KEY!"=="--version"'); expect(installBatchSource).toContain('--version requires a value'); const installPowerShellSource = readScript( @@ -539,6 +553,40 @@ describe('standalone release packaging', () => { } }); + it('rejects hosted installer sources without real version parsing', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'echo "Usage: --version VERSION"\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=latest"\r\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '& $qwenInstallerPath @args\n# QWEN_INSTALL_VERSION\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow(/install-qwen-standalone\.sh.*--version parser/); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects stale hosted installation assets in the output directory', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl @@ -617,7 +665,7 @@ describe('standalone release packaging', () => { await expect( verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { fetchImpl: async (url, options = {}) => { - fetchedUrls.push([url, options.method || 'GET']); + fetchedUrls.push([url, options.method || 'GET', !!options.signal]); if (url.endsWith('/SHA256SUMS')) { return new Response(checksumContent); } @@ -632,11 +680,13 @@ describe('standalone release packaging', () => { expect(fetchedUrls).toContainEqual([ 'https://example.com/qwen-code/v0.0.0/SHA256SUMS', 'GET', + true, ]); for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { expect(fetchedUrls).toContainEqual([ `https://example.com/qwen-code/v0.0.0/${assetName}`, 'HEAD', + true, ]); } for (const [url] of fetchedUrls) { @@ -1335,6 +1385,90 @@ describe('Windows installer end-to-end', () => { rmSync(tmpDir, { recursive: true, force: true }); } }); + + itOnWindows( + 'falls back to npm in detect mode when archive is unavailable', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const npmLog = path.join(tmpDir, 'npm-install.log'); + createFakeWindowsNpmTools(fakeBin); + + const output = runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + PATH: `${fakeBin};${process.env.PATH}`, + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ).toString(); + + expect(output).toContain('Falling back to npm installation'); + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@latest --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnWindows('preserves context when npm fallback also fails', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'node.cmd'), + ['@echo off', 'exit /b 1', ''].join('\r\n'), + ); + + let failureMessage = ''; + try { + runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + PATH: `${fakeBin};${process.env.PATH}`, + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ); + } catch (error) { + failureMessage = [ + error.message, + error.stdout?.toString() || '', + error.stderr?.toString() || '', + ].join('\n'); + } + + expect(failureMessage).toContain('Falling back to npm installation'); + expect(failureMessage).toContain('Unable to determine Node.js version'); + expect(failureMessage).toContain('npm fallback also failed'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); function ensureMinimalDist() { @@ -1445,6 +1579,29 @@ function createFakeWindowsStandaloneArchive(tmpDir) { return archive; } +function createFakeWindowsNpmTools(fakeBin) { + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'node.cmd'), + ['@echo off', 'if "%~1"=="-p" echo 22.0.0', 'exit /b 0', ''].join('\r\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm.cmd'), + [ + '@echo off', + 'if "%~1"=="-v" echo 10.0.0 & exit /b 0', + 'if "%~1"=="prefix" echo %QWEN_FAKE_NPM_PREFIX% & exit /b 0', + 'if "%~1"=="install" echo %* > "%QWEN_FAKE_NPM_LOG%" & exit /b 0', + 'exit /b 0', + '', + ].join('\r\n'), + ); + writeFileSync( + path.join(fakeBin, 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-npm', ''].join('\r\n'), + ); +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index e84f75ca5c2..63b90d67930 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -24,14 +24,14 @@ const EXPECTED_STANDALONE_ARCHIVE_NAMES = [ 'qwen-code-win-x64.zip', ]; // Release artifacts that the installer chain expects in a GitHub Release. -// Hosted installer scripts (install-qwen.sh / install-qwen.bat) are served -// from a separate hosted endpoint and are +// Hosted installer scripts are served from a separate endpoint and are // intentionally not part of this set; they have their own staging path in // `package:hosted-installation`. const EXPECTED_RELEASE_ASSET_NAMES = [ ...EXPECTED_STANDALONE_ARCHIVE_NAMES, 'SHA256SUMS', ]; +const REMOTE_FETCH_TIMEOUT_MS = 30_000; if (isMainModule(import.meta.url)) { try { @@ -215,7 +215,7 @@ async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { } async function assertRemoteAssetAvailable(url, fetchImpl) { - let response = await fetchImpl(url, { method: 'HEAD' }); + let response = await fetchWithTimeout(fetchImpl, url, { method: 'HEAD' }); if (response.ok) { await response.body?.cancel?.(); return; @@ -225,7 +225,7 @@ async function assertRemoteAssetAvailable(url, fetchImpl) { // Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET // so the verifier can still confirm reachability without downloading the // full archive. - response = await fetchImpl(url, { + response = await fetchWithTimeout(fetchImpl, url, { headers: { Range: 'bytes=0-0', }, @@ -244,7 +244,7 @@ function formatErrorReason(reason) { } async function fetchText(url, fetchImpl) { - const response = await fetchImpl(url); + const response = await fetchWithTimeout(fetchImpl, url); if (!response.ok) { fail( `Failed to download ${url}: ${response.status} ${response.statusText}`, @@ -253,6 +253,13 @@ async function fetchText(url, fetchImpl) { return response.text(); } +function fetchWithTimeout(fetchImpl, url, options = {}) { + return fetchImpl(url, { + ...options, + signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), + }); +} + function normalizeHttpsBaseUrl(baseUrl) { let parsed; try { From 90935774a23b6247443863d94b7b4f894ee38f9f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 19:58:07 +0800 Subject: [PATCH 063/129] fix(installer): stabilize Windows installer tests --- scripts/installation/install-qwen-standalone.bat | 5 ++++- scripts/tests/install-script.test.js | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index dfe7f3c1ce4..bc7c349bb0a 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -303,8 +303,11 @@ set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" set "QWEN_VALIDATE_SOURCE=!SOURCE!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +REM Keep this PowerShell script encoded so cmd.exe does not parse its metacharacters first. +set "QWEN_VALIDATE_OPTIONS_COMMAND=JAB1AG4AcwBhAGYAZQAgAD0AIABbAGMAaABhAHIAWwBdAF0AKAAxADAALAAxADMALAAzADMALAAzADQALAAzADcALAAzADgALAA2ADAALAA2ADIALAA5ADQALAA5ADYALAAxADIANAApAAoAZgBvAHIAZQBhAGMAaAAgACgAJABuAGEAbQBlACAAaQBuACAAJwBNAEUAVABIAE8ARAAnACwAJwBNAEkAUgBSAE8AUgAnACwAJwBCAEEAUwBFAF8AVQBSAEwAJwAsACcAQQBSAEMASABJAFYARQBfAFAAQQBUAEgAJwAsACcAVgBFAFIAUwBJAE8ATgAnACwAJwBOAFAATQBfAFIARQBHAEkAUwBUAFIAWQAnACwAJwBJAE4AUwBUAEEATABMAF8AQgBBAFMARQAnACwAJwBJAE4AUwBUAEEATABMAF8ARABJAFIAJwAsACcASQBOAFMAVABBAEwATABfAEIASQBOAF8ARABJAFIAJwAsACcAUwBPAFUAUgBDAEUAJwApACAAewAKACAAIAAkAHYAYQBsAHUAZQAgAD0AIABbAEUAbgB2AGkAcgBvAG4AbQBlAG4AdABdADoAOgBHAGUAdABFAG4AdgBpAHIAbwBuAG0AZQBuAHQAVgBhAHIAaQBhAGIAbABlACgAJwBRAFcARQBOAF8AVgBBAEwASQBEAEEAVABFAF8AJwAgACsAIAAkAG4AYQBtAGUAKQAKACAAIABpAGYAIAAoACQAbgB1AGwAbAAgAC0AbgBlACAAJAB2AGEAbAB1AGUAIAAtAGEAbgBkACAAJAB2AGEAbAB1AGUALgBJAG4AZABlAHgATwBmAEEAbgB5ACgAJAB1AG4AcwBhAGYAZQApACAALQBnAGUAIAAwACkAIAB7ACAAZQB4AGkAdAAgADEAIAB9AAoAfQAKAGUAeABpAHQAIAAwAA==" +powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand !QWEN_VALIDATE_OPTIONS_COMMAND! set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VALIDATE_OPTIONS_COMMAND=" set "QWEN_VALIDATE_METHOD=" set "QWEN_VALIDATE_MIRROR=" set "QWEN_VALIDATE_BASE_URL=" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a176ccbb6a7..88c901ca1cc 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -182,7 +182,8 @@ describe('installation scripts', () => { expect(script).toContain( 'installer options contain unsafe command characters', ); - expect(script).toContain('[char[]](10,13,33,34'); + expect(script).toContain('-EncodedCommand'); + expect(script).toContain('QWEN_VALIDATE_OPTIONS_COMMAND'); expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); @@ -1409,7 +1410,7 @@ describe('Windows installer end-to-end', () => { QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), QWEN_FAKE_NPM_LOG: npmLog, QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), - PATH: `${fakeBin};${process.env.PATH}`, + ...prependWindowsPath(fakeBin), PROCESSOR_ARCHITECTURE: 'ARM64', PROCESSOR_ARCHITEW6432: '', }, @@ -1449,7 +1450,7 @@ describe('Windows installer end-to-end', () => { { USERPROFILE: path.join(tmpDir, 'home'), QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), - PATH: `${fakeBin};${process.env.PATH}`, + ...prependWindowsPath(fakeBin), PROCESSOR_ARCHITECTURE: 'ARM64', PROCESSOR_ARCHITEW6432: '', }, @@ -1602,6 +1603,15 @@ function createFakeWindowsNpmTools(fakeBin) { ); } +function prependWindowsPath(directory) { + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || + 'Path'; + return { + [pathKey]: `${directory};${process.env[pathKey] || ''}`, + }; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( From 72a5efe74490f916ee7d649b889a6ccefd597452 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 20:43:30 +0800 Subject: [PATCH 064/129] fix(installer): make Windows option validation readable --- scripts/installation/install-qwen-standalone.bat | 15 +++++++++++---- scripts/tests/install-script.test.js | 8 ++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index bc7c349bb0a..bb9b378d70a 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -303,11 +303,18 @@ set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" set "QWEN_VALIDATE_SOURCE=!SOURCE!" -REM Keep this PowerShell script encoded so cmd.exe does not parse its metacharacters first. -set "QWEN_VALIDATE_OPTIONS_COMMAND=JAB1AG4AcwBhAGYAZQAgAD0AIABbAGMAaABhAHIAWwBdAF0AKAAxADAALAAxADMALAAzADMALAAzADQALAAzADcALAAzADgALAA2ADAALAA2ADIALAA5ADQALAA5ADYALAAxADIANAApAAoAZgBvAHIAZQBhAGMAaAAgACgAJABuAGEAbQBlACAAaQBuACAAJwBNAEUAVABIAE8ARAAnACwAJwBNAEkAUgBSAE8AUgAnACwAJwBCAEEAUwBFAF8AVQBSAEwAJwAsACcAQQBSAEMASABJAFYARQBfAFAAQQBUAEgAJwAsACcAVgBFAFIAUwBJAE8ATgAnACwAJwBOAFAATQBfAFIARQBHAEkAUwBUAFIAWQAnACwAJwBJAE4AUwBUAEEATABMAF8AQgBBAFMARQAnACwAJwBJAE4AUwBUAEEATABMAF8ARABJAFIAJwAsACcASQBOAFMAVABBAEwATABfAEIASQBOAF8ARABJAFIAJwAsACcAUwBPAFUAUgBDAEUAJwApACAAewAKACAAIAAkAHYAYQBsAHUAZQAgAD0AIABbAEUAbgB2AGkAcgBvAG4AbQBlAG4AdABdADoAOgBHAGUAdABFAG4AdgBpAHIAbwBuAG0AZQBuAHQAVgBhAHIAaQBhAGIAbABlACgAJwBRAFcARQBOAF8AVgBBAEwASQBEAEEAVABFAF8AJwAgACsAIAAkAG4AYQBtAGUAKQAKACAAIABpAGYAIAAoACQAbgB1AGwAbAAgAC0AbgBlACAAJAB2AGEAbAB1AGUAIAAtAGEAbgBkACAAJAB2AGEAbAB1AGUALgBJAG4AZABlAHgATwBmAEEAbgB5ACgAJAB1AG4AcwBhAGYAZQApACAALQBnAGUAIAAwACkAIAB7ACAAZQB4AGkAdAAgADEAIAB9AAoAfQAKAGUAeABpAHQAIAAwAA==" -powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand !QWEN_VALIDATE_OPTIONS_COMMAND! +set "QWEN_VALIDATE_OPTIONS_SCRIPT=%TEMP%\qwen-validate-options-%RANDOM%-%RANDOM%.ps1" +> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $names = @('METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo foreach ($name in $names) { +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo exit 0 +powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!" set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_VALIDATE_OPTIONS_COMMAND=" +del /F /Q "!QWEN_VALIDATE_OPTIONS_SCRIPT!" >nul 2>&1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=" set "QWEN_VALIDATE_METHOD=" set "QWEN_VALIDATE_MIRROR=" set "QWEN_VALIDATE_BASE_URL=" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 88c901ca1cc..9f7d2432b63 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -182,8 +182,12 @@ describe('installation scripts', () => { expect(script).toContain( 'installer options contain unsafe command characters', ); - expect(script).toContain('-EncodedCommand'); - expect(script).toContain('QWEN_VALIDATE_OPTIONS_COMMAND'); + expect(script).not.toContain('-EncodedCommand'); + expect(script).toContain('QWEN_VALIDATE_OPTIONS_SCRIPT'); + expect(script).toContain('$unsafe = [char[]](10,13,33,34'); + expect(script).toContain( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!"', + ); expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); From 7d328be1145ce9d6608813e25c8dfce1a85a9815 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 21:22:39 +0800 Subject: [PATCH 065/129] feat(installer): wire Aliyun OSS sync, address review followups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Aliyun OSS sync steps to release workflow: package hosted assets, install pinned ossutil, configure credentials, upload versioned and latest paths, and verify upload via verify:installation-release plus curl probes against the hosted installer endpoint. - Document required production-release environment secrets and bucket variables in INSTALLATION_GUIDE.md. - Restructure hosted endpoint guidance to lead with the pre-sync warning, splitting "Run today" (local checkout) from "After the OSS sync" (hosted one-liners) so users no longer copy a one-liner that silently installs latest. - Distinguish mirror auto-selection timeout from successful selection in install-qwen-standalone.sh and install-qwen-standalone.bat: emit a "timed out; defaulting to github" log instead of pretending the HEAD probe picked github. - Support QWEN_INSTALLER_BAT_URL override (https only) in the PowerShell shim so staging mirrors can be exercised without forking the file. - Strip a leading UTF-8 BOM in verify-installation-release.js parseSha256Sums so BOM-prefixed SHA256SUMS reports a useful "Missing checksum entry" error instead of "Malformed SHA256SUMS line 1". - Add tests for verifier HEAD→Range fallback, partial-failure formatting, all-failure wording, and BOM tolerance. --- .github/workflows/release.yml | 107 ++++++++++++ scripts/installation/INSTALLATION_GUIDE.md | 66 ++++++-- .../installation/install-qwen-standalone.bat | 22 ++- .../installation/install-qwen-standalone.ps1 | 17 +- .../installation/install-qwen-standalone.sh | 13 +- scripts/tests/install-script.test.js | 153 +++++++++++++++++- scripts/verify-installation-release.js | 6 +- 7 files changed, 352 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 696832a8853..55e692437e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -389,6 +389,56 @@ jobs: - name: 'Verify Installation Release Assets' run: 'npm run verify:installation-release -- --dir dist/standalone' + - name: 'Package Hosted Installation Assets' + run: 'npm run package:hosted-installation -- --out-dir dist/installation' + + - name: 'Install ossutil' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + OSSUTIL_URL: 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' + OSSUTIL_SHA256: 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' + run: |- + set -euo pipefail + + tmp_dir="$(mktemp -d)" + curl -fsSL "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" + echo "${OSSUTIL_SHA256} ${tmp_dir}/ossutil.zip" | sha256sum -c - + unzip -q "${tmp_dir}/ossutil.zip" -d "${tmp_dir}" + + ossutil_path="$(find "${tmp_dir}" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)" + if [[ -z "${ossutil_path}" ]]; then + echo "::error::ossutil binary not found in downloaded archive" + exit 1 + fi + + chmod +x "${ossutil_path}" + sudo install -m 0755 "${ossutil_path}" /usr/local/bin/ossutil + rm -rf "${tmp_dir}" + ossutil >/dev/null + + - name: 'Configure Aliyun OSS Credentials' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' + ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' + ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}" + run: |- + set -euo pipefail + + if [[ -z "${ALIYUN_OSS_ACCESS_KEY_ID}" || -z "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]; then + echo "::error::Missing Aliyun OSS credentials. Set ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET in the production-release environment secrets." + exit 1 + fi + + ossutil config \ + -e "${ALIYUN_OSS_ENDPOINT}" \ + -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ + -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ + -L EN \ + -c "${RUNNER_TEMP}/.ossutilconfig" + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -430,6 +480,63 @@ jobs: --generate-notes \ ${PRERELEASE_FLAG} + - name: 'Sync Release Assets to Aliyun OSS' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' + IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + upload_asset() { + local source="$1" + local key="$2" + ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f + } + + upload_release_assets() { + local prefix="$1" + for asset in dist/standalone/qwen-code-*.tar.gz dist/standalone/qwen-code-*.zip dist/standalone/SHA256SUMS; do + upload_asset "${asset}" "${prefix}/$(basename "${asset}")" + done + } + + upload_release_assets "releases/qwen-code/${RELEASE_TAG}" + + if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then + upload_release_assets "releases/qwen-code/latest" + fi + + upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" + upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" + upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" + upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + + - name: 'Verify Aliyun OSS Release Assets' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' + IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" + + if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then + npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest" + fi + + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat SHA256SUMS; do + url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" + curl -fsSIL "${url}" >/dev/null || curl -fsSL --range 0-0 "${url}" >/dev/null + done + - name: 'Create PR to merge release branch into main' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index c3c3b2d437b..761dbc0c798 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -43,15 +43,37 @@ standalone release. The `standalone` suffix intentionally avoids overwriting the existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during the staged rollout. -> **Hosted endpoint status**: Until the hosted endpoint is re-synced after the -> next release, the URL below still serves the legacy NVM-based installer, -> which does not honor `--version` or `QWEN_INSTALL_VERSION` in the way -> documented here. To get the standalone-archive-first behavior immediately, -> run `install-qwen-standalone.sh` from a local checkout of this repository, or -> upload the staged standalone-suffixed files to separate OSS keys for testing. -> The `--version` examples below describe the post-sync behavior. +> ⚠️ **Hosted endpoint status — read before copying the one-liners below.** +> Until the hosted endpoint is re-synced after the next non-dry-run release with +> OSS credentials configured, the +> `qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/` URLs still +> serve the legacy NVM-based installer. The legacy installer ignores +> `--version` and `QWEN_INSTALL_VERSION`, so the pinning examples below will +> silently install `latest`. To get the standalone-archive-first behavior +> today, run `install-qwen-standalone.sh` from a local checkout of this +> repository (see "Run today, before the OSS sync" below), or upload the +> staged standalone-suffixed files to separate OSS keys for testing. -Standalone hosted entrypoints for staged rollout: +### Run today, before the OSS sync + +From a local checkout of this repository: + +```bash +bash scripts/installation/install-qwen-standalone.sh +bash scripts/installation/install-qwen-standalone.sh --version vX.Y.Z +``` + +```cmd +scripts\installation\install-qwen-standalone.bat +scripts\installation\install-qwen-standalone.bat --version vX.Y.Z +``` + +### After the OSS sync + +These hosted one-liners only honor `--version` / `QWEN_INSTALL_VERSION` once +the OSS objects have been re-synced as described in +"Hosted endpoint status" above; before that they fall back to the legacy +NVM-based installer. ```bash curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash @@ -88,14 +110,29 @@ npm run package:hosted-installation -- --out-dir dist/installation The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, and `install-qwen-standalone.bat` files map to the standalone-suffixed hosted URLs -shown above. Upload their contents byte-for-byte to +shown above. During a non-dry-run release, the publish workflow uploads their +contents byte-for-byte to `installation/install-qwen-standalone.sh`, `installation/install-qwen-standalone.ps1`, and `installation/install-qwen-standalone.bat`; the staging command also writes `SHA256SUMS` for upload verification. The hosted installers intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a -standalone release. OSS/CDN upload automation is still a follow-up release -operation; until then, release operators must sync these staged files manually. +standalone release. + +Configure the `production-release` GitHub environment with these required +secrets before enabling OSS sync: + +- `ALIYUN_OSS_ACCESS_KEY_ID` +- `ALIYUN_OSS_ACCESS_KEY_SECRET` + +The workflow defaults to the production OSS bucket and Hangzhou endpoint. Set +these GitHub Actions variables only when the bucket, endpoint, or public base +URL changes: + +- `ALIYUN_OSS_BUCKET` (default: `qwen-code-assets`) +- `ALIYUN_OSS_ENDPOINT` (default: `https://oss-cn-hangzhou.aliyuncs.com`) +- `ALIYUN_OSS_PUBLIC_BASE_URL` (default: + `https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com`) Archive layout: @@ -226,9 +263,10 @@ Use `--base-url` for private mirrors. The URL must contain `qwen-code-` archives and `SHA256SUMS` in the same directory. Custom base URLs must use `https://`. -For Aliyun OSS/CDN, release publishing must upload byte-identical artifacts to -both the versioned directory, for example `vX.Y.Z/`, and the `latest/` -directory used by the default installer path. +For Aliyun OSS/CDN, release publishing uploads byte-identical artifacts to the +versioned directory, for example `vX.Y.Z/`. Stable releases also update the +`latest/` directory used by the default installer path; nightly and preview +releases do not overwrite `latest/`. ## Supported Source Values diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index bb9b378d70a..4e8a4e9c928 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -464,15 +464,16 @@ exit /b 0 :RaceMirrorHead rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url -rem Sets QWEN_RACE_RESULT to "aliyun" or "github". Sequential (OSS first, GH -rem fallback) keeps the PowerShell snippet small; a true parallel race adds a -rem lot of escaping for marginal speedup since OSS HEAD is sub-second when -rem reachable. Default fallback: github. +rem Sets QWEN_RACE_RESULT to "aliyun", "github", or "timeout". Sequential +rem (OSS first, GH fallback) keeps the PowerShell snippet small; a true +rem parallel race adds a lot of escaping for marginal speedup since OSS HEAD +rem is sub-second when reachable. Caller decides what to do with "timeout" +rem (currently: log it and fall back to github). set "QWEN_RACE_TIMEOUT=%~1" set "QWEN_RACE_GH_URL=%~2" set "QWEN_RACE_OSS_URL=%~3" -set "QWEN_RACE_RESULT=github" -for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'github'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_RESULT=timeout" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'timeout'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" set "QWEN_RACE_TIMEOUT=" set "QWEN_RACE_GH_URL=" set "QWEN_RACE_OSS_URL=" @@ -490,8 +491,13 @@ if /i "!MIRROR!"=="auto" ( call :GithubBaseUrlForVersion "!VERSION_PATH!" call :AliyunBaseUrlForVersion "!VERSION_PATH!" call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" - set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !MIRROR! + if /i "!QWEN_RACE_RESULT!"=="timeout" ( + echo INFO: 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! + ) set "QWEN_GH_BASE_URL=" set "QWEN_OSS_BASE_URL=" set "QWEN_RACE_RESULT=" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index e19c3407444..4ed8d918fbb 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -8,10 +8,25 @@ # To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, # e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing # --version vX.Y.Z to install-qwen-standalone.bat directly. +# +# To point this shim at a non-production hosted endpoint (staging buckets, +# private mirrors), set $env:QWEN_INSTALLER_BAT_URL to the alternate .bat URL. +# The override is required to be HTTPS so a misconfigured value can't silently +# downgrade the download channel. The downstream .bat continues to honor +# QWEN_INSTALL_BASE_URL for archive resolution. $ErrorActionPreference = 'Stop' -$qwenInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' +$qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' +if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) { + $qwenInstallerUrl = $qwenDefaultInstallerUrl +} else { + if ($env:QWEN_INSTALLER_BAT_URL -notmatch '^https://') { + Write-Error "QWEN_INSTALLER_BAT_URL must start with https://" + exit 1 + } + $qwenInstallerUrl = $env:QWEN_INSTALLER_BAT_URL +} $qwenInstallerPath = Join-Path $env:TEMP 'install-qwen-standalone.bat' try { diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 7f611f6ebda..a3600bf1d15 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -569,7 +569,9 @@ aliyun_base_url_for_version() { } # Race two HEAD probes; print "aliyun" or "github" based on which mirror's -# SHA256SUMS responds first. Default to github if both time out. +# SHA256SUMS responds first, or "timeout" if neither responds before the +# deadline. Caller decides what to do with "timeout" (currently: log it and +# fall back to github). race_mirror_head() { local timeout="${1:-2}" local gh_url="$2" @@ -598,7 +600,7 @@ race_mirror_head() { wait "${oss_pid}" "${gh_pid}" 2>/dev/null || true rm -rf "${tmpdir}" 2>/dev/null || true - echo "${winner:-github}" + echo "${winner:-timeout}" } standalone_base_url() { @@ -615,7 +617,12 @@ standalone_base_url() { gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") - log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 + 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 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9f7d2432b63..8a3c0bed2d6 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -713,6 +713,134 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); + it('falls back to a ranged GET when a release asset HEAD fails', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const fetchedUrls = []; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + const method = options.method || 'GET'; + const range = options.headers?.Range || ''; + fetchedUrls.push([url, method, range]); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + // Simulate an object-storage host that disables HEAD: the verifier + // must retry with a 1-byte ranged GET before treating the asset as + // unavailable. + if (method === 'HEAD') { + return new Response(null, { status: 405 }); + } + if (range === 'bytes=0-0') { + return new Response('a', { status: 206 }); + } + return new Response(null, { status: 500 }); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + const assetUrl = `https://example.com/qwen-code/v0.0.0/${assetName}`; + expect(fetchedUrls).toContainEqual([assetUrl, 'HEAD', '']); + expect(fetchedUrls).toContainEqual([assetUrl, 'GET', 'bytes=0-0']); + } + }); + + it('reports each unavailable asset with its reason', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const unavailableAsset = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + // The first asset always fails (HEAD and Range); the rest succeed + // on HEAD. Verifier should list only the failing one in the error. + if (url.endsWith(`/${unavailableAsset}`)) { + return new Response(null, { status: 404 }); + } + return new Response(null, { status: 200 }); + }, + }), + ).rejects.toThrow( + new RegExp( + `Unavailable release asset URL\\(s\\): ${escapeRegExp(unavailableAsset)} \\(.*\\)`, + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('reports a single error when every asset URL is unavailable', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 503 }); + }, + }), + ).rejects.toThrow( + new RegExp( + `All ${EXPECTED_STANDALONE_ARCHIVE_NAMES.length} release asset URLs are unavailable; check --base-url: https://example\\.com/qwen-code/v0\\.0\\.0/`, + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('parses SHA256SUMS even when the file starts with a UTF-8 BOM', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = + '\uFEFF' + placeholderChecksumContent(EXPECTED_STANDALONE_ARCHIVE_NAMES); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 200 }); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -921,14 +1049,14 @@ describe('standalone release packaging', () => { } }); - it('uploads standalone archives during release', () => { + it('syncs standalone and hosted installation assets during release', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); + expect(workflow).toContain( + 'npm run package:hosted-installation -- --out-dir dist/installation', + ); expect(workflow).not.toContain('package:installation-assets'); - expect(workflow).not.toContain('install-qwen-standalone.sh'); - expect(workflow).not.toContain('install-qwen-standalone.bat'); - expect(workflow).not.toContain('install-qwen-standalone.ps1'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); @@ -937,6 +1065,18 @@ describe('standalone release packaging', () => { expect(workflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_ID'); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET'); + expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); + expect(workflow).toContain('vars.ALIYUN_OSS_ENDPOINT'); + expect(workflow).toContain('releases/qwen-code/${RELEASE_TAG}'); + expect(workflow).toContain('releases/qwen-code/latest'); + expect(workflow).toContain('installation/install-qwen-standalone.sh'); + expect(workflow).toContain('installation/install-qwen-standalone.bat'); + expect(workflow).toContain('installation/install-qwen-standalone.ps1'); + expect(workflow).toContain( + 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', + ); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -955,7 +1095,10 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen-standalone.bat'); expect(guide).toContain('installation/install-qwen-standalone.ps1'); expect(guide).toContain('irm https://qwen-code-assets'); - expect(guide).toContain('release operators must sync these staged files'); + expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_ID'); + expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); + expect(guide).toContain('ALIYUN_OSS_BUCKET'); + expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); expect(guide).toContain('Hosted endpoint status'); expect(guide).toContain('legacy NVM-based installer'); expect(guide).toContain('node-pty'); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 63b90d67930..9b5a25005cf 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -293,8 +293,12 @@ function readOptionValue(argv, index, optionName) { } function parseSha256Sums(content) { + // Strip a leading UTF-8 BOM so a SHA256SUMS file uploaded via a Windows tool + // that prepends one still reports a useful "Missing checksum entry" error + // instead of "Malformed SHA256SUMS line 1". + const normalized = content.replace(/^\uFEFF/, ''); const checksums = new Map(); - for (const [index, line] of content.split(/\r?\n/).entries()) { + for (const [index, line] of normalized.split(/\r?\n/).entries()) { const trimmed = line.trim(); if (!trimmed) { continue; From b0ae7f89f9b25f9ca1bb405be91101ce9584c1b9 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 23:02:30 +0800 Subject: [PATCH 066/129] ci(installer): add temporary OSS smoke test --- .github/workflows/oss-smoke-test.yml | 166 +++++++++++++++++++++++++++ scripts/tests/install-script.test.js | 30 +++++ 2 files changed, 196 insertions(+) create mode 100644 .github/workflows/oss-smoke-test.yml diff --git a/.github/workflows/oss-smoke-test.yml b/.github/workflows/oss-smoke-test.yml new file mode 100644 index 00000000000..2f35eb8cdc9 --- /dev/null +++ b/.github/workflows/oss-smoke-test.yml @@ -0,0 +1,166 @@ +name: 'OSS Smoke Test' + +on: + push: + branches: + - 'codex/installer-release-assets' + paths: + - '.github/workflows/oss-smoke-test.yml' + workflow_dispatch: + inputs: + prefix: + description: 'Temporary OSS prefix for the upload/delete smoke test.' + required: true + type: 'string' + default: 'qwen-code-oss-ak-check' + +jobs: + smoke: + name: 'Verify Aliyun OSS Credentials' + runs-on: 'ubuntu-latest' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + environment: + name: 'production-release' + permissions: + contents: 'read' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: 'package-lock.json' + + - name: 'Install Dependencies' + env: + NPM_CONFIG_PREFER_OFFLINE: 'true' + run: |- + npm ci --no-audit --progress=false + + - name: 'Install ossutil' + env: + OSSUTIL_URL: 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' + OSSUTIL_SHA256: 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' + run: |- + set -euo pipefail + + tmp_dir="$(mktemp -d)" + curl -fsSL "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" + echo "${OSSUTIL_SHA256} ${tmp_dir}/ossutil.zip" | sha256sum -c - + unzip -q "${tmp_dir}/ossutil.zip" -d "${tmp_dir}" + + ossutil_path="$(find "${tmp_dir}" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)" + if [[ -z "${ossutil_path}" ]]; then + echo "::error::ossutil binary not found in downloaded archive" + exit 1 + fi + + chmod +x "${ossutil_path}" + sudo install -m 0755 "${ossutil_path}" /usr/local/bin/ossutil + rm -rf "${tmp_dir}" + ossutil >/dev/null + + - name: 'Configure Aliyun OSS Credentials' + env: + ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' + ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' + ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}" + run: |- + set -euo pipefail + + if [[ -z "${ALIYUN_OSS_ACCESS_KEY_ID}" || -z "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]; then + echo "::error::Missing Aliyun OSS credentials. Set ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET in the production-release environment secrets." + exit 1 + fi + + ossutil config \ + -e "${ALIYUN_OSS_ENDPOINT}" \ + -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ + -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ + -L EN \ + -c "${RUNNER_TEMP}/.ossutilconfig" + + - name: 'Upload and Delete OSS Test Object' + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + TEST_PREFIX: '${{ inputs.prefix }}' + run: |- + set -euo pipefail + + safe_prefix="${TEST_PREFIX:-qwen-code-oss-ak-check}" + safe_prefix="${safe_prefix#/}" + safe_prefix="${safe_prefix%/}" + if [[ -z "${safe_prefix}" || "${safe_prefix}" == "." || "${safe_prefix}" == *".."* ]]; then + echo "::error::Unsafe OSS test prefix: ${TEST_PREFIX}" + exit 1 + fi + + test_object="${safe_prefix}/github-actions-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt" + test_file="${RUNNER_TEMP}/oss-smoke-test.txt" + + printf 'qwen-code OSS smoke test\nrun_id=%s\nattempt=%s\n' \ + "${GITHUB_RUN_ID}" \ + "${GITHUB_RUN_ATTEMPT}" > "${test_file}" + + ossutil ls "oss://${ALIYUN_OSS_BUCKET}" -c "${RUNNER_TEMP}/.ossutilconfig" >/dev/null + ossutil cp "${test_file}" "oss://${ALIYUN_OSS_BUCKET}/${test_object}" -c "${RUNNER_TEMP}/.ossutilconfig" -f + ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${test_object}" -c "${RUNNER_TEMP}/.ossutilconfig" -f + + - name: 'Build, Upload, Install, and Cleanup Staged Release Assets' + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + TEST_PREFIX: '${{ inputs.prefix }}' + run: |- + set -euo pipefail + + safe_prefix="${TEST_PREFIX:-qwen-code-oss-ak-check}" + safe_prefix="${safe_prefix#/}" + safe_prefix="${safe_prefix%/}" + if [[ -z "${safe_prefix}" || "${safe_prefix}" == "." || "${safe_prefix}" == *".."* ]]; then + echo "::error::Unsafe OSS test prefix: ${TEST_PREFIX}" + exit 1 + fi + + release_prefix="${safe_prefix}/github-actions-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/release" + cleanup_release_assets() { + ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f >/dev/null 2>&1 || true + ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f >/dev/null 2>&1 || true + } + trap cleanup_release_assets EXIT + + npm run bundle + npm run prepare:package + + node_version="$(node -p 'process.versions.node')" + node_archive="node-v${node_version}-linux-x64.tar.xz" + node_dist_url="https://nodejs.org/dist/v${node_version}" + runtime_dir="$(mktemp -d)" + curl -fsSL "${node_dist_url}/SHASUMS256.txt" -o "${runtime_dir}/SHASUMS256.txt" + curl -fsSL "${node_dist_url}/${node_archive}" -o "${runtime_dir}/${node_archive}" + grep " ${node_archive}$" "${runtime_dir}/SHASUMS256.txt" > "${runtime_dir}/SHASUMS256-linux-x64.txt" + (cd "${runtime_dir}" && sha256sum -c SHASUMS256-linux-x64.txt) + + node scripts/create-standalone-package.js \ + --target linux-x64 \ + --node-archive "${runtime_dir}/${node_archive}" \ + --out-dir dist/oss-smoke \ + --version "0.0.0-oss-smoke.${GITHUB_RUN_ID}" + + ossutil cp "dist/oss-smoke/qwen-code-linux-x64.tar.gz" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f + ossutil cp "dist/oss-smoke/SHA256SUMS" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f + + public_release_base="${ALIYUN_OSS_PUBLIC_BASE_URL%/}/${release_prefix}" + curl -fsSIL "${public_release_base}/SHA256SUMS" >/dev/null || curl -fsSL --range 0-0 "${public_release_base}/SHA256SUMS" >/dev/null + curl -fsSIL "${public_release_base}/qwen-code-linux-x64.tar.gz" >/dev/null || curl -fsSL --range 0-0 "${public_release_base}/qwen-code-linux-x64.tar.gz" >/dev/null + + export HOME="${RUNNER_TEMP}/qwen-home" + export QWEN_INSTALL_ROOT="${RUNNER_TEMP}/qwen-install-root" + mkdir -p "${HOME}" + bash scripts/installation/install-qwen-standalone.sh --method standalone --base-url "${public_release_base}" --source oss-smoke + "${QWEN_INSTALL_ROOT}/bin/qwen" --version diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 8a3c0bed2d6..e3ab85197be 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1079,6 +1079,36 @@ describe('standalone release packaging', () => { ); }); + it('defines a temporary OSS credentials smoke workflow', () => { + const workflow = readScript('.github/workflows/oss-smoke-test.yml'); + + expect(workflow).toContain("name: 'OSS Smoke Test'"); + expect(workflow).toContain( + "branches:\n - 'codex/installer-release-assets'", + ); + expect(workflow).toContain('workflow_dispatch:'); + expect(workflow).toContain("name: 'production-release'"); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_ID'); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET'); + expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); + expect(workflow).toContain('qwen-code-oss-ak-check'); + expect(workflow).toContain('ossutil cp'); + expect(workflow).toContain('ossutil rm'); + expect(workflow).toContain('actions/checkout'); + expect(workflow).toContain('actions/setup-node'); + expect(workflow).toContain('npm run bundle'); + expect(workflow).toContain('scripts/create-standalone-package.js'); + expect(workflow).toContain('--target linux-x64'); + expect(workflow).toContain('qwen-code-linux-x64.tar.gz'); + expect(workflow).toContain( + 'install-qwen-standalone.sh --method standalone', + ); + expect(workflow).toContain('"${QWEN_INSTALL_ROOT}/bin/qwen" --version'); + expect(workflow).not.toContain('npm publish'); + expect(workflow).not.toContain('gh release create'); + expect(workflow).not.toContain('releases/qwen-code/latest'); + }); + it('does not whitelist internal planning documents in gitignore', () => { const gitignore = readScript('.gitignore'); From 48977aeef9a11221fc44320c47328c84bb4e4109 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 23:19:30 +0800 Subject: [PATCH 067/129] fix(installer): make OSS release assets public-readable --- .github/workflows/oss-smoke-test.yml | 4 ++-- .github/workflows/release.yml | 2 +- scripts/tests/install-script.test.js | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/oss-smoke-test.yml b/.github/workflows/oss-smoke-test.yml index 2f35eb8cdc9..f7c318d6961 100644 --- a/.github/workflows/oss-smoke-test.yml +++ b/.github/workflows/oss-smoke-test.yml @@ -152,8 +152,8 @@ jobs: --out-dir dist/oss-smoke \ --version "0.0.0-oss-smoke.${GITHUB_RUN_ID}" - ossutil cp "dist/oss-smoke/qwen-code-linux-x64.tar.gz" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f - ossutil cp "dist/oss-smoke/SHA256SUMS" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f + ossutil cp "dist/oss-smoke/qwen-code-linux-x64.tar.gz" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read + ossutil cp "dist/oss-smoke/SHA256SUMS" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read public_release_base="${ALIYUN_OSS_PUBLIC_BASE_URL%/}/${release_prefix}" curl -fsSIL "${public_release_base}/SHA256SUMS" >/dev/null || curl -fsSL --range 0-0 "${public_release_base}/SHA256SUMS" >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55e692437e7..f98d6c3fbe2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -494,7 +494,7 @@ jobs: upload_asset() { local source="$1" local key="$2" - ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f + ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read } upload_release_assets() { diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index e3ab85197be..b3b4ec4fb76 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1074,6 +1074,7 @@ describe('standalone release packaging', () => { expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); expect(workflow).toContain('installation/install-qwen-standalone.ps1'); + expect(workflow).toContain('--acl public-read'); expect(workflow).toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', ); @@ -1093,6 +1094,7 @@ describe('standalone release packaging', () => { expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); expect(workflow).toContain('qwen-code-oss-ak-check'); expect(workflow).toContain('ossutil cp'); + expect(workflow).toContain('--acl public-read'); expect(workflow).toContain('ossutil rm'); expect(workflow).toContain('actions/checkout'); expect(workflow).toContain('actions/setup-node'); From 645edb8ec247ea639572192adc38d8656402a41f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 23:34:20 +0800 Subject: [PATCH 068/129] chore(installer): remove temporary OSS smoke workflow --- .github/workflows/oss-smoke-test.yml | 166 --------------------- .github/workflows/release.yml | 20 +-- .gitignore | 2 +- scripts/installation/INSTALLATION_GUIDE.md | 55 +------ scripts/tests/install-script.test.js | 35 +---- 5 files changed, 16 insertions(+), 262 deletions(-) delete mode 100644 .github/workflows/oss-smoke-test.yml diff --git a/.github/workflows/oss-smoke-test.yml b/.github/workflows/oss-smoke-test.yml deleted file mode 100644 index f7c318d6961..00000000000 --- a/.github/workflows/oss-smoke-test.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: 'OSS Smoke Test' - -on: - push: - branches: - - 'codex/installer-release-assets' - paths: - - '.github/workflows/oss-smoke-test.yml' - workflow_dispatch: - inputs: - prefix: - description: 'Temporary OSS prefix for the upload/delete smoke test.' - required: true - type: 'string' - default: 'qwen-code-oss-ak-check' - -jobs: - smoke: - name: 'Verify Aliyun OSS Credentials' - runs-on: 'ubuntu-latest' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} - environment: - name: 'production-release' - permissions: - contents: 'read' - - steps: - - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 - - - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - - - name: 'Install Dependencies' - env: - NPM_CONFIG_PREFER_OFFLINE: 'true' - run: |- - npm ci --no-audit --progress=false - - - name: 'Install ossutil' - env: - OSSUTIL_URL: 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' - OSSUTIL_SHA256: 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' - run: |- - set -euo pipefail - - tmp_dir="$(mktemp -d)" - curl -fsSL "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" - echo "${OSSUTIL_SHA256} ${tmp_dir}/ossutil.zip" | sha256sum -c - - unzip -q "${tmp_dir}/ossutil.zip" -d "${tmp_dir}" - - ossutil_path="$(find "${tmp_dir}" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)" - if [[ -z "${ossutil_path}" ]]; then - echo "::error::ossutil binary not found in downloaded archive" - exit 1 - fi - - chmod +x "${ossutil_path}" - sudo install -m 0755 "${ossutil_path}" /usr/local/bin/ossutil - rm -rf "${tmp_dir}" - ossutil >/dev/null - - - name: 'Configure Aliyun OSS Credentials' - env: - ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' - ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' - ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}" - run: |- - set -euo pipefail - - if [[ -z "${ALIYUN_OSS_ACCESS_KEY_ID}" || -z "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]; then - echo "::error::Missing Aliyun OSS credentials. Set ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET in the production-release environment secrets." - exit 1 - fi - - ossutil config \ - -e "${ALIYUN_OSS_ENDPOINT}" \ - -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ - -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ - -L EN \ - -c "${RUNNER_TEMP}/.ossutilconfig" - - - name: 'Upload and Delete OSS Test Object' - env: - ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" - TEST_PREFIX: '${{ inputs.prefix }}' - run: |- - set -euo pipefail - - safe_prefix="${TEST_PREFIX:-qwen-code-oss-ak-check}" - safe_prefix="${safe_prefix#/}" - safe_prefix="${safe_prefix%/}" - if [[ -z "${safe_prefix}" || "${safe_prefix}" == "." || "${safe_prefix}" == *".."* ]]; then - echo "::error::Unsafe OSS test prefix: ${TEST_PREFIX}" - exit 1 - fi - - test_object="${safe_prefix}/github-actions-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt" - test_file="${RUNNER_TEMP}/oss-smoke-test.txt" - - printf 'qwen-code OSS smoke test\nrun_id=%s\nattempt=%s\n' \ - "${GITHUB_RUN_ID}" \ - "${GITHUB_RUN_ATTEMPT}" > "${test_file}" - - ossutil ls "oss://${ALIYUN_OSS_BUCKET}" -c "${RUNNER_TEMP}/.ossutilconfig" >/dev/null - ossutil cp "${test_file}" "oss://${ALIYUN_OSS_BUCKET}/${test_object}" -c "${RUNNER_TEMP}/.ossutilconfig" -f - ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${test_object}" -c "${RUNNER_TEMP}/.ossutilconfig" -f - - - name: 'Build, Upload, Install, and Cleanup Staged Release Assets' - env: - ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" - ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" - TEST_PREFIX: '${{ inputs.prefix }}' - run: |- - set -euo pipefail - - safe_prefix="${TEST_PREFIX:-qwen-code-oss-ak-check}" - safe_prefix="${safe_prefix#/}" - safe_prefix="${safe_prefix%/}" - if [[ -z "${safe_prefix}" || "${safe_prefix}" == "." || "${safe_prefix}" == *".."* ]]; then - echo "::error::Unsafe OSS test prefix: ${TEST_PREFIX}" - exit 1 - fi - - release_prefix="${safe_prefix}/github-actions-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/release" - cleanup_release_assets() { - ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f >/dev/null 2>&1 || true - ossutil rm "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f >/dev/null 2>&1 || true - } - trap cleanup_release_assets EXIT - - npm run bundle - npm run prepare:package - - node_version="$(node -p 'process.versions.node')" - node_archive="node-v${node_version}-linux-x64.tar.xz" - node_dist_url="https://nodejs.org/dist/v${node_version}" - runtime_dir="$(mktemp -d)" - curl -fsSL "${node_dist_url}/SHASUMS256.txt" -o "${runtime_dir}/SHASUMS256.txt" - curl -fsSL "${node_dist_url}/${node_archive}" -o "${runtime_dir}/${node_archive}" - grep " ${node_archive}$" "${runtime_dir}/SHASUMS256.txt" > "${runtime_dir}/SHASUMS256-linux-x64.txt" - (cd "${runtime_dir}" && sha256sum -c SHASUMS256-linux-x64.txt) - - node scripts/create-standalone-package.js \ - --target linux-x64 \ - --node-archive "${runtime_dir}/${node_archive}" \ - --out-dir dist/oss-smoke \ - --version "0.0.0-oss-smoke.${GITHUB_RUN_ID}" - - ossutil cp "dist/oss-smoke/qwen-code-linux-x64.tar.gz" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/qwen-code-linux-x64.tar.gz" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - ossutil cp "dist/oss-smoke/SHA256SUMS" "oss://${ALIYUN_OSS_BUCKET}/${release_prefix}/SHA256SUMS" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - - public_release_base="${ALIYUN_OSS_PUBLIC_BASE_URL%/}/${release_prefix}" - curl -fsSIL "${public_release_base}/SHA256SUMS" >/dev/null || curl -fsSL --range 0-0 "${public_release_base}/SHA256SUMS" >/dev/null - curl -fsSIL "${public_release_base}/qwen-code-linux-x64.tar.gz" >/dev/null || curl -fsSL --range 0-0 "${public_release_base}/qwen-code-linux-x64.tar.gz" >/dev/null - - export HOME="${RUNNER_TEMP}/qwen-home" - export QWEN_INSTALL_ROOT="${RUNNER_TEMP}/qwen-install-root" - mkdir -p "${HOME}" - bash scripts/installation/install-qwen-standalone.sh --method standalone --base-url "${public_release_base}" --source oss-smoke - "${QWEN_INSTALL_ROOT}/bin/qwen" --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f98d6c3fbe2..32a3e4a8526 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -89,7 +89,7 @@ jobs: echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -153,13 +153,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -206,13 +206,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -247,13 +247,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -317,13 +317,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.gitignore b/.gitignore index 5010fb81026..6ff1d950be2 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ tmp/ # code graph skills .venv -.codegraph +.codegraph \ No newline at end of file diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 761dbc0c798..f0bffdec181 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -43,57 +43,10 @@ standalone release. The `standalone` suffix intentionally avoids overwriting the existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during the staged rollout. -> ⚠️ **Hosted endpoint status — read before copying the one-liners below.** -> Until the hosted endpoint is re-synced after the next non-dry-run release with -> OSS credentials configured, the -> `qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/` URLs still -> serve the legacy NVM-based installer. The legacy installer ignores -> `--version` and `QWEN_INSTALL_VERSION`, so the pinning examples below will -> silently install `latest`. To get the standalone-archive-first behavior -> today, run `install-qwen-standalone.sh` from a local checkout of this -> repository (see "Run today, before the OSS sync" below), or upload the -> staged standalone-suffixed files to separate OSS keys for testing. - -### Run today, before the OSS sync - -From a local checkout of this repository: - -```bash -bash scripts/installation/install-qwen-standalone.sh -bash scripts/installation/install-qwen-standalone.sh --version vX.Y.Z -``` - -```cmd -scripts\installation\install-qwen-standalone.bat -scripts\installation\install-qwen-standalone.bat --version vX.Y.Z -``` - -### After the OSS sync - -These hosted one-liners only honor `--version` / `QWEN_INSTALL_VERSION` once -the OSS objects have been re-synced as described in -"Hosted endpoint status" above; before that they fall back to the legacy -NVM-based installer. - -```bash -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --version vX.Y.Z -``` - -```cmd -powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex" -``` - -To pin a release with the hosted Windows entrypoint, set -`QWEN_INSTALL_VERSION` before invoking `install-qwen-standalone.ps1`: - -```powershell -$env:QWEN_INSTALL_VERSION = 'vX.Y.Z' -irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex -``` - -`QWEN_INSTALL_VERSION` is the equivalent environment variable when arguments -cannot be passed through. +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: diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index b3b4ec4fb76..e5e83e7fcc8 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1080,37 +1080,6 @@ describe('standalone release packaging', () => { ); }); - it('defines a temporary OSS credentials smoke workflow', () => { - const workflow = readScript('.github/workflows/oss-smoke-test.yml'); - - expect(workflow).toContain("name: 'OSS Smoke Test'"); - expect(workflow).toContain( - "branches:\n - 'codex/installer-release-assets'", - ); - expect(workflow).toContain('workflow_dispatch:'); - expect(workflow).toContain("name: 'production-release'"); - expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_ID'); - expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET'); - expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); - expect(workflow).toContain('qwen-code-oss-ak-check'); - expect(workflow).toContain('ossutil cp'); - expect(workflow).toContain('--acl public-read'); - expect(workflow).toContain('ossutil rm'); - expect(workflow).toContain('actions/checkout'); - expect(workflow).toContain('actions/setup-node'); - expect(workflow).toContain('npm run bundle'); - expect(workflow).toContain('scripts/create-standalone-package.js'); - expect(workflow).toContain('--target linux-x64'); - expect(workflow).toContain('qwen-code-linux-x64.tar.gz'); - expect(workflow).toContain( - 'install-qwen-standalone.sh --method standalone', - ); - expect(workflow).toContain('"${QWEN_INSTALL_ROOT}/bin/qwen" --version'); - expect(workflow).not.toContain('npm publish'); - expect(workflow).not.toContain('gh release create'); - expect(workflow).not.toContain('releases/qwen-code/latest'); - }); - it('does not whitelist internal planning documents in gitignore', () => { const gitignore = readScript('.gitignore'); @@ -1126,13 +1095,11 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen-standalone.sh'); expect(guide).toContain('installation/install-qwen-standalone.bat'); expect(guide).toContain('installation/install-qwen-standalone.ps1'); - expect(guide).toContain('irm https://qwen-code-assets'); expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_ID'); expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(guide).toContain('ALIYUN_OSS_BUCKET'); expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); - expect(guide).toContain('Hosted endpoint status'); - expect(guide).toContain('legacy NVM-based installer'); + expect(guide).toContain('Public installation documentation'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); From 0c8022d491e6460de448c7c83f13a070e4b83ab5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 13 May 2026 23:54:05 +0800 Subject: [PATCH 069/129] fix(installer): address hosted installer review gaps --- scripts/build-hosted-installation-assets.js | 8 +++ .../installation/install-qwen-standalone.bat | 69 ++++++++++++++++--- .../installation/install-qwen-standalone.ps1 | 65 ++++++++++++++++- .../installation/install-qwen-standalone.sh | 12 ++-- scripts/tests/install-script.test.js | 55 +++++++++++++++ 5 files changed, 192 insertions(+), 17 deletions(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 5b704189597..24fd295fd4f 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -61,6 +61,14 @@ const HOSTED_INSTALLER_BEHAVIOR_PATTERNS = { name: 'argument forwarding', pattern: /& \$qwenInstallerPath @args/, }, + { + name: 'SHA256SUMS verification', + pattern: /SHA256SUMS/, + }, + { + name: 'bat checksum verification', + pattern: /Get-FileHash/, + }, { name: 'QWEN_INSTALL_VERSION documentation', pattern: /QWEN_INSTALL_VERSION/, diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 4e8a4e9c928..b20968de6f9 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -34,14 +34,21 @@ if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" REM Parse flags before any network or filesystem work. :parse_args if "%~1"=="" goto end_parse +set "ARG_RAW=%~1" set "ARG_KEY=%~1" set "ARG_VALUE=" +set "ARG_HAS_INLINE_VALUE=0" for /f "tokens=1,* delims==" %%A in ("%~1") do ( set "ARG_KEY=%%~A" set "ARG_VALUE=%%~B" ) +if not "!ARG_KEY!"=="!ARG_RAW!" set "ARG_HAS_INLINE_VALUE=1" if /i "!ARG_KEY!"=="--source" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) set "SOURCE=!ARG_VALUE!" shift goto parse_args @@ -66,7 +73,11 @@ if /i "%~1"=="-s" ( goto parse_args ) if /i "!ARG_KEY!"=="--method" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) set "METHOD=!ARG_VALUE!" shift goto parse_args @@ -81,7 +92,11 @@ if /i "!ARG_KEY!"=="--method" ( goto parse_args ) if /i "!ARG_KEY!"=="--mirror" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) set "MIRROR=!ARG_VALUE!" shift goto parse_args @@ -96,7 +111,11 @@ if /i "!ARG_KEY!"=="--mirror" ( goto parse_args ) if /i "!ARG_KEY!"=="--base-url" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) set "BASE_URL=!ARG_VALUE!" shift goto parse_args @@ -111,7 +130,11 @@ if /i "!ARG_KEY!"=="--base-url" ( goto parse_args ) if /i "!ARG_KEY!"=="--archive" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) set "ARCHIVE_PATH=!ARG_VALUE!" shift goto parse_args @@ -126,7 +149,11 @@ if /i "!ARG_KEY!"=="--archive" ( goto parse_args ) if /i "!ARG_KEY!"=="--version" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) set "VERSION=!ARG_VALUE!" shift goto parse_args @@ -141,7 +168,11 @@ if /i "!ARG_KEY!"=="--version" ( goto parse_args ) if /i "!ARG_KEY!"=="--registry" ( - if not "!ARG_VALUE!"=="" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) set "NPM_REGISTRY=!ARG_VALUE!" shift goto parse_args @@ -193,8 +224,9 @@ echo. REM Discover all qwen executables on disk BEFORE we install. We can't REM reliably simulate the user's PATH ordering, so enumerate well-known REM per-tool bin directories plus everything `where qwen` returns. -set "PRE_INSTALL_QWENS_FILE=%TEMP%\qwen-pre-install-%RANDOM%-%RANDOM%.txt" -del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +call :CreateTempFile "qwen-pre-install" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "PRE_INSTALL_QWENS_FILE=!TEMP_FILE!" for /f "delims=" %%i in ('where qwen 2^>nul') do call echo %%i>>"!PRE_INSTALL_QWENS_FILE!" for %%c in ( "!USERPROFILE!\.opencode\bin\qwen.cmd" @@ -303,7 +335,9 @@ set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" set "QWEN_VALIDATE_SOURCE=!SOURCE!" -set "QWEN_VALIDATE_OPTIONS_SCRIPT=%TEMP%\qwen-validate-options-%RANDOM%-%RANDOM%.ps1" +call :CreateTempFile "qwen-validate-options" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=!TEMP_FILE!" > "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) >> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $names = @('METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') >> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo foreach ($name in $names) { @@ -562,7 +596,9 @@ if "!CHECKSUM_FILE!"=="" ( ) else ( if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( set "REQUIRE_CHECKSUM=1" - set "TEMP_CHECKSUM=%TEMP%\qwen-code-checksums-%RANDOM%%RANDOM%.txt" + call :CreateTempFile "qwen-code-checksums" + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "TEMP_CHECKSUM=!TEMP_FILE!" call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 @@ -847,6 +883,17 @@ if "!TEMP_DIR!"=="" ( ) exit /b 0 +:CreateTempFile +set "TEMP_FILE=" +set "QWEN_TEMP_FILE_PREFIX=%~1" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" +set "QWEN_TEMP_FILE_PREFIX=" +if "!TEMP_FILE!"=="" ( + echo ERROR: Failed to create a temporary file. + exit /b 1 +) +exit /b 0 + :EnsureDir set "REQUIRED_DIR=%~1" set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 4ed8d918fbb..40b3383cda5 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -14,10 +14,15 @@ # The override is required to be HTTPS so a misconfigured value can't silently # downgrade the download channel. The downstream .bat continues to honor # QWEN_INSTALL_BASE_URL for archive resolution. +# +# By default the matching SHA256SUMS file is read from the same hosted +# directory as the .bat. Set $env:QWEN_INSTALLER_CHECKSUMS_URL to override it +# when testing a custom installer endpoint. $ErrorActionPreference = 'Stop' $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' +$qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) { $qwenInstallerUrl = $qwenDefaultInstallerUrl } else { @@ -27,7 +32,27 @@ if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) { } $qwenInstallerUrl = $env:QWEN_INSTALLER_BAT_URL } -$qwenInstallerPath = Join-Path $env:TEMP 'install-qwen-standalone.bat' + +if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_CHECKSUMS_URL)) { + if ($qwenInstallerUrl -eq $qwenDefaultInstallerUrl) { + $qwenChecksumsUrl = $qwenDefaultChecksumsUrl + } else { + $qwenChecksumsUrl = [Uri]::new([Uri]$qwenInstallerUrl, 'SHA256SUMS').AbsoluteUri + } +} else { + if ($env:QWEN_INSTALLER_CHECKSUMS_URL -notmatch '^https://') { + Write-Error "QWEN_INSTALLER_CHECKSUMS_URL must start with https://" + exit 1 + } + $qwenChecksumsUrl = $env:QWEN_INSTALLER_CHECKSUMS_URL +} + +$qwenInstallerName = [IO.Path]::GetFileName(([Uri]$qwenInstallerUrl).AbsolutePath) +if ([string]::IsNullOrEmpty($qwenInstallerName)) { + $qwenInstallerName = 'install-qwen-standalone.bat' +} +$qwenInstallerPath = Join-Path $env:TEMP $qwenInstallerName +$qwenChecksumsPath = Join-Path $env:TEMP 'qwen-installation-SHA256SUMS' try { Invoke-WebRequest -Uri $qwenInstallerUrl ` @@ -39,12 +64,48 @@ try { exit 1 } +try { + Invoke-WebRequest -Uri $qwenChecksumsUrl ` + -OutFile $qwenChecksumsPath ` + -UseBasicParsing ` + -MaximumRedirection 10 +} catch { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Write-Error "Failed to download Qwen Code installer checksums from ${qwenChecksumsUrl}: $($_.Exception.Message)" + exit 1 +} + +$qwenExpectedHash = $null +foreach ($qwenChecksumLine in Get-Content -LiteralPath $qwenChecksumsPath) { + if ($qwenChecksumLine -match '^([0-9a-fA-F]{64})\s+\*?(.+)$') { + if ($Matches[2] -eq $qwenInstallerName) { + $qwenExpectedHash = $Matches[1].ToLowerInvariant() + break + } + } +} +if ([string]::IsNullOrEmpty($qwenExpectedHash)) { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue + Write-Error "Checksum entry for ${qwenInstallerName} not found in ${qwenChecksumsUrl}" + exit 1 +} + +$qwenActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $qwenInstallerPath).Hash.ToLowerInvariant() +if ($qwenActualHash -ne $qwenExpectedHash) { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue + Write-Error "Checksum verification failed for ${qwenInstallerName}." + exit 1 +} + $qwenInstallerExitCode = 0 try { & $qwenInstallerPath @args $qwenInstallerExitCode = $LASTEXITCODE } finally { - Remove-Item -Path $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue } if ($qwenInstallerExitCode -ne 0) { diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index a3600bf1d15..24c04e65e3e 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -527,11 +527,13 @@ maybe_update_shell_path() { [[ -z "${rc_file}" ]] && return 0 local marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local quoted_install_bin_dir + quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") local export_line if [[ "${rc_file}" == *config.fish ]]; then - export_line="set -gx PATH ${install_bin_dir} \$PATH" + export_line="set -gx PATH ${quoted_install_bin_dir} \$PATH" else - export_line="export PATH=\"${install_bin_dir}:\$PATH\"" + export_line="export PATH=${quoted_install_bin_dir}:\$PATH" fi if [[ -f "${rc_file}" ]] && grep -qF "${marker}" "${rc_file}" 2>/dev/null; then @@ -1069,8 +1071,10 @@ install_npm() { print_final_instructions() { local install_bin_dir="${1:-}" local installed_bin="" + local quoted_install_bin_dir="" if [[ -n "${install_bin_dir}" ]]; then installed_bin="${install_bin_dir}/qwen" + quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") fi # PRE_INSTALL_QWENS was captured by main() BEFORE the install ran @@ -1132,7 +1136,7 @@ print_final_instructions() { if [[ "${NO_MODIFY_PATH:-0}" == "1" ]]; then echo "Skipped shell rc update because --no-modify-path is set." echo "To make this install win, manually add to your shell rc:" - echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " export PATH=${quoted_install_bin_dir}:\$PATH" else maybe_update_shell_path "${install_bin_dir}" echo "" @@ -1151,7 +1155,7 @@ print_final_instructions() { if [[ -n "${install_bin_dir}" ]]; then echo "" echo "Or run this in the current shell:" - echo " export PATH=\"${install_bin_dir}:\$PATH\"" + echo " export PATH=${quoted_install_bin_dir}:\$PATH" echo " qwen" fi return 0 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index e5e83e7fcc8..a1f0cffcf9f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -121,6 +121,8 @@ describe('installation scripts', () => { expect(script).toContain( 'QWEN_INSTALL_GITHUB_REPO must be in owner/repo format', ); + expect(script).toContain('set -gx PATH ${quoted_install_bin_dir} \\$PATH'); + expect(script).toContain('export PATH=${quoted_install_bin_dir}:\\$PATH'); expect(script).toContain('curl -fsIL -m "${timeout}"'); expect(script).not.toContain('-print -quit'); }); @@ -207,6 +209,8 @@ describe('installation scripts', () => { expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); expect(script).toContain('if !STANDALONE_STATUS! EQU 2'); 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"'); expect(script).toContain('if /i "!ARG_KEY!"=="--version"'); expect(script).toContain('$value -match'); expect(script).toContain('QWEN_INSTALL_GITHUB_REPO'); @@ -222,6 +226,8 @@ describe('installation scripts', () => { expect(script).toContain('Failed to update user PATH'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); + expect(script).toContain(':CreateTempFile'); + expect(script).not.toContain('%RANDOM%'); }); }); @@ -451,6 +457,9 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('Invoke-WebRequest'); expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); expect(installPowerShellSource).toContain('--version vX.Y.Z'); + expect(installPowerShellSource).toContain('SHA256SUMS'); + expect(installPowerShellSource).toContain('Get-FileHash'); + expect(installPowerShellSource).toContain('Checksum verification failed'); expect(installPowerShellSource).toContain('@args'); }); @@ -1180,6 +1189,52 @@ describe('Linux/macOS installer end-to-end', () => { } }); + itOnUnix( + 'shell-quotes PATH updates written to shell rc files', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'shadow-bin'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const marker = path.join(tmpDir, 'qwen-pwned'); + const unsafeBinDir = path.join( + installRoot, + 'bin path $(touch qwen-pwned)', + ); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'qwen'), '#!/usr/bin/env sh\n'); + chmodSync(path.join(fakeBin, 'qwen'), 0o755); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: unsafeBinDir, + }); + + const bashrc = path.join(home, '.bashrc'); + expect(readScript(bashrc)).toContain( + `export PATH='${unsafeBinDir}':$PATH`, + ); + execFileSync('bash', ['-c', `source "${bashrc}"`], { + cwd: tmpDir, + stdio: 'pipe', + }); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + 15000, + ); + itOnUnix('rejects a tampered local archive', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); From c6005a74bb55fe04727fdb8515f8adc88dfdef10 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 01:11:19 +0800 Subject: [PATCH 070/129] feat(installer): refactor argument parsing and utility functions for release scripts --- scripts/build-hosted-installation-assets.js | 80 ++---------- .../installation/install-qwen-standalone.ps1 | 2 + .../installation/install-qwen-standalone.sh | 8 +- scripts/release-script-utils.js | 115 ++++++++++++++++++ scripts/tests/install-script.test.js | 96 +++++++++++++++ scripts/verify-installation-release.js | 96 ++------------- 6 files changed, 242 insertions(+), 155 deletions(-) create mode 100644 scripts/release-script-utils.js diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 24fd295fd4f..b8f975c80da 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -6,11 +6,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { + fail, + isMainModule, + parseArgs, + parseSha256Sums, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -91,6 +96,10 @@ const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ 'SHA256SUMS', ]); +const ARG_DEFS = { + '--out-dir': { key: 'outDir', type: 'value' }, +}; + if (isMainModule(import.meta.url)) { try { await main(); @@ -101,7 +110,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseArgs(process.argv.slice(2)); + const args = parseArgs(process.argv.slice(2), ARG_DEFS); if (args.help) { printUsage(); return; @@ -124,31 +133,6 @@ Options: `); } -function parseArgs(argv) { - const args = { - help: false, - outDir: undefined, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case '--help': - case '-h': - args.help = true; - break; - case '--out-dir': - args.outDir = readOptionValue(argv, index, arg); - index += 1; - break; - default: - fail(`Unknown option: ${arg}`); - } - } - - return args; -} - async function buildHostedInstallationAssets(outDir, options = {}) { const root = options.root || rootDir; fs.mkdirSync(outDir, { recursive: true }); @@ -241,46 +225,6 @@ async function assertHostedInstallationAssetChecksums(outDir) { } } -function isMainModule(importMetaUrl) { - const filename = fileURLToPath(importMetaUrl); - return process.argv[1] && path.resolve(process.argv[1]) === filename; -} - -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - -function parseSha256Sums(content) { - const checksums = new Map(); - for (const [index, line] of content.split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (!match) { - fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); - } - checksums.set(match[2], match[1].toLowerCase()); - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { HOSTED_INSTALLATION_ASSETS, HOSTED_INSTALLATION_ASSET_NAMES, diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 40b3383cda5..65e58d82f3a 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -1,6 +1,8 @@ # Qwen Code Windows hosted PowerShell entrypoint. # Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP and runs # it, so the documented one-liner can use the standard irm | iex pattern. +# Note: irm (Invoke-RestMethod) and iwr (Invoke-WebRequest) both return the raw +# text of a .ps1 file unchanged, so the one-liner works with either alias. # # Usage: # powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex" diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 24c04e65e3e..d6735bb74ff 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -32,19 +32,19 @@ BLUE='\033[0;34m' NC='\033[0m' log_info() { - echo -e "${BLUE}INFO:${NC} $1" + printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" } log_success() { - echo -e "${GREEN}SUCCESS:${NC} $1" + printf '%bSUCCESS:%b %s\n' "${GREEN}" "${NC}" "$1" } log_warning() { - echo -e "${YELLOW}WARNING:${NC} $1" + printf '%bWARNING:%b %s\n' "${YELLOW}" "${NC}" "$1" } log_error() { - echo -e "${RED}ERROR:${NC} $1" + printf '%bERROR:%b %s\n' "${RED}" "${NC}" "$1" >&2 } command_exists() { diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js new file mode 100644 index 00000000000..602c9ab427b --- /dev/null +++ b/scripts/release-script-utils.js @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +export function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +/** + * Parse a SHA256SUMS file. Handles: + * - optional leading UTF-8 BOM (uploaded via Windows tools) + * - binary-prefix markers (`*` before filename) + * - empty lines and CRLF / LF line endings + */ +export function parseSha256Sums(content) { + // Strip a leading UTF-8 BOM so a SHA256SUMS file uploaded via a Windows tool + // that prepends one still reports a useful "Missing checksum entry" error + // instead of "Malformed SHA256SUMS line 1". + const normalized = content.replace(/^\uFEFF/, ''); + const checksums = new Map(); + for (const [index, line] of normalized.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +export function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +export function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +/** + * Parse CLI arguments. Supports: + * - --flag → args[def.key] = true + * - --key value → args[def.key] = value + * - --key=value → args[def.key] = value + * - -h, --help → args.help = true (always recognised) + * + * @param {string[]} argv + * @param {Record} definitions + * @returns {{help: false} & Record} + */ +export function parseArgs(argv, definitions) { + const args = { help: false }; + + for (let index = 0; index < argv.length; index += 1) { + const raw = argv[index]; + if (raw === '--help' || raw === '-h') { + args.help = true; + continue; + } + + // --key=value form + const eqIndex = raw.indexOf('='); + if (eqIndex >= 0) { + const key = raw.slice(0, eqIndex); + const value = raw.slice(eqIndex + 1); + const def = definitions[key]; + if (!def) { + fail(`Unknown option: ${key}`); + } + if (def.type === 'flag') { + fail(`${key} does not accept a value`); + } + args[def.key] = value; + continue; + } + + const def = definitions[raw]; + if (!def) { + fail(`Unknown option: ${raw}`); + } + + if (def.type === 'flag') { + args[def.key] = true; + continue; + } + + args[def.key] = readOptionValue(argv, index, raw); + index += 1; + } + + return args; +} diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a1f0cffcf9f..45f8cc92c4c 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -33,6 +33,9 @@ const hostedInstallationScriptUrl = pathToFileURL( const installationReleaseVerificationScriptUrl = pathToFileURL( path.resolve('scripts/verify-installation-release.js'), ).href; +const releaseScriptUtilsUrl = pathToFileURL( + path.resolve('scripts/release-script-utils.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; @@ -231,6 +234,87 @@ describe('installation scripts', () => { }); }); +describe('release-script-utils', () => { + it('parses SHA256SUMS with BOM, empty lines, and CRLF', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + + const checksums = parseSha256Sums( + `\uFEFF${'a'.repeat(64)} install-qwen-standalone.sh\n\n${'b'.repeat(64)} *install-qwen-standalone.bat\r\n${'c'.repeat(64)} install-qwen-standalone.ps1\n`, + ); + + expect(checksums.get('install-qwen-standalone.sh')).toBe('a'.repeat(64)); + expect(checksums.get('install-qwen-standalone.bat')).toBe('b'.repeat(64)); + expect(checksums.get('install-qwen-standalone.ps1')).toBe('c'.repeat(64)); + }); + + it('rejects malformed SHA256SUMS entries', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + + expect(() => + parseSha256Sums('short-hash install-qwen-standalone.sh\n'), + ).toThrow(/Malformed SHA256SUMS line 1/); + }); + + it('supports --key=value form in parseArgs', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { + '--out-dir': { key: 'outDir', type: 'value' }, + '--verbose': { key: 'verbose', type: 'flag' }, + }; + + const args = parseArgs(['--out-dir=/tmp/build', '--verbose'], defs); + expect(args.outDir).toBe('/tmp/build'); + expect(args.verbose).toBe(true); + expect(args.help).toBe(false); + }); + + it('supports --key value form in parseArgs', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--out-dir': { key: 'outDir', type: 'value' } }; + + const args = parseArgs(['--out-dir', '/tmp/build'], defs); + expect(args.outDir).toBe('/tmp/build'); + }); + + it('rejects unknown options and missing values', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--out-dir': { key: 'outDir', type: 'value' } }; + + expect(() => parseArgs(['--unknown'], defs)).toThrow( + /Unknown option: --unknown/, + ); + expect(() => parseArgs(['--out-dir'], defs)).toThrow( + /--out-dir requires a value/, + ); + expect(() => parseArgs(['--out-dir', '--help'], defs)).toThrow( + /--out-dir requires a value/, + ); + }); + + it('rejects --key=value for flag-type options', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--verbose': { key: 'verbose', type: 'flag' } }; + + expect(() => parseArgs(['--verbose=true'], defs)).toThrow( + /--verbose does not accept a value/, + ); + }); + + it('recognises -h and --help without definitions', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + + expect(parseArgs(['--help'], {}).help).toBe(true); + expect(parseArgs(['-h'], {}).help).toBe(true); + }); + + it('fail() wraps messages with ERROR: prefix', async () => { + const { fail } = await import(releaseScriptUtilsUrl); + expect(() => fail('something went wrong')).toThrow( + 'ERROR: something went wrong', + ); + }); +}); + describe('standalone release packaging', () => { it('defines a standalone packaging script', () => { const packageJson = JSON.parse(readScript('package.json')); @@ -388,6 +472,18 @@ describe('standalone release packaging', () => { ], /Pass --dir or --base-url, not both/, ); + expectFail( + [ + 'scripts/verify-installation-release.js', + '--dir=/tmp', + '--base-url=https://example.com/r/', + ], + /Pass --dir or --base-url, not both/, + ); + expectFail( + ['scripts/verify-installation-release.js', '--unknown=foo'], + /Unknown option: --unknown/, + ); }); it('parses Node.js SHASUMS entries', async () => { diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 9b5a25005cf..6aa351449ad 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -6,11 +6,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { + fail, + isMainModule, + parseArgs, + parseSha256Sums, + sha256File, +} from './release-script-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -33,6 +38,11 @@ const EXPECTED_RELEASE_ASSET_NAMES = [ ]; const REMOTE_FETCH_TIMEOUT_MS = 30_000; +const ARG_DEFS = { + '--dir': { key: 'dir', type: 'value' }, + '--base-url': { key: 'baseUrl', type: 'value' }, +}; + if (isMainModule(import.meta.url)) { try { await main(); @@ -43,7 +53,7 @@ if (isMainModule(import.meta.url)) { } async function main() { - const args = parseArgs(process.argv.slice(2)); + const args = parseArgs(process.argv.slice(2), ARG_DEFS); if (args.help) { printUsage(); return; @@ -76,36 +86,6 @@ Options: `); } -function parseArgs(argv) { - const args = { - help: false, - dir: undefined, - baseUrl: undefined, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case '--help': - case '-h': - args.help = true; - break; - case '--dir': - args.dir = readOptionValue(argv, index, arg); - index += 1; - break; - case '--base-url': - args.baseUrl = readOptionValue(argv, index, arg); - index += 1; - break; - default: - fail(`Unknown option: ${arg}`); - } - } - - return args; -} - async function verifyReleaseDirectory(dir) { const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); @@ -222,9 +202,6 @@ async function assertRemoteAssetAvailable(url, fetchImpl) { } await response.body?.cancel?.(); - // Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET - // so the verifier can still confirm reachability without downloading the - // full archive. response = await fetchWithTimeout(fetchImpl, url, { headers: { Range: 'bytes=0-0', @@ -267,9 +244,6 @@ function normalizeHttpsBaseUrl(baseUrl) { } catch { fail(`--base-url must be a valid URL: ${baseUrl}`); } - // Real release URLs are always HTTPS. Tests use injected fetchImpl, so - // they don't need a real protocol. Rejecting non-https early prevents an - // operator from accidentally pointing the verifier at a plain-http mirror. if (parsed.protocol !== 'https:') { fail(`--base-url must use https: ${baseUrl}`); } @@ -279,50 +253,6 @@ function normalizeHttpsBaseUrl(baseUrl) { return parsed.toString(); } -function isMainModule(importMetaUrl) { - const filename = fileURLToPath(importMetaUrl); - return process.argv[1] && path.resolve(process.argv[1]) === filename; -} - -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - fail(`${optionName} requires a value`); - } - return value; -} - -function parseSha256Sums(content) { - // Strip a leading UTF-8 BOM so a SHA256SUMS file uploaded via a Windows tool - // that prepends one still reports a useful "Missing checksum entry" error - // instead of "Malformed SHA256SUMS line 1". - const normalized = content.replace(/^\uFEFF/, ''); - const checksums = new Map(); - for (const [index, line] of normalized.split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); - if (!match) { - fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); - } - checksums.set(match[2], match[1].toLowerCase()); - } - return checksums; -} - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await pipeline(fs.createReadStream(filePath), hash); - return hash.digest('hex'); -} - -function fail(message) { - throw new Error(`ERROR: ${message}`); -} - export { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl, From 540bcb8c280546ae4c2bf03fa747a8735ecbc553 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 10:34:46 +0800 Subject: [PATCH 071/129] fix(installer): harden hosted release script checks --- .gitattributes | 3 +++ scripts/release-script-utils.js | 3 +++ scripts/tests/install-script.test.js | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/.gitattributes b/.gitattributes index deab5ae88bd..3c21e997137 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ *.bash eol=lf Makefile eol=lf +# Windows cmd.exe expects batch installers to be checked out with CRLF. +scripts/installation/install-qwen-standalone.bat text eol=crlf + # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. *.png binary diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index 602c9ab427b..3e09751a6be 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -93,6 +93,9 @@ export function parseArgs(argv, definitions) { if (def.type === 'flag') { fail(`${key} does not accept a value`); } + if (!value) { + fail(`${key} requires a value`); + } args[def.key] = value; continue; } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 45f8cc92c4c..fa8d520f54f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -41,6 +41,8 @@ const releaseScriptUtilsUrl = pathToFileURL( const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; +vi.setConfig({ testTimeout: 30_000 }); + describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { const script = readScript( @@ -232,6 +234,23 @@ describe('installation scripts', () => { expect(script).toContain(':CreateTempFile'); expect(script).not.toContain('%RANDOM%'); }); + + it('checks out the Windows standalone batch installer with CRLF line endings', () => { + const attrs = execFileSync( + 'git', + [ + 'check-attr', + 'eol', + '--', + 'scripts/installation/install-qwen-standalone.bat', + ], + { encoding: 'utf8' }, + ); + + expect(attrs).toContain( + 'scripts/installation/install-qwen-standalone.bat: eol: crlf', + ); + }); }); describe('release-script-utils', () => { @@ -286,6 +305,9 @@ describe('release-script-utils', () => { expect(() => parseArgs(['--out-dir'], defs)).toThrow( /--out-dir requires a value/, ); + expect(() => parseArgs(['--out-dir='], defs)).toThrow( + /--out-dir requires a value/, + ); expect(() => parseArgs(['--out-dir', '--help'], defs)).toThrow( /--out-dir requires a value/, ); From 4095171f64a65e8a0a0badf9ffb27d2652cae153 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 13:29:01 +0800 Subject: [PATCH 072/129] fix(installer): suppress PowerShell progress bar in hosted entrypoint shim Add $ProgressPreference = 'SilentlyContinue' to the .ps1 wrapper so Invoke-WebRequest downloads don't render a progress bar when invoked via the irm | iex one-liner. --- scripts/installation/install-qwen-standalone.bat | 6 ++++-- scripts/installation/install-qwen-standalone.ps1 | 1 + scripts/tests/install-script.test.js | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index b20968de6f9..aac05d1e5c2 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -335,7 +335,7 @@ set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" set "QWEN_VALIDATE_SOURCE=!SOURCE!" -call :CreateTempFile "qwen-validate-options" +call :CreateTempFile "qwen-validate-options" ".ps1" if !ERRORLEVEL! NEQ 0 exit /b 1 set "QWEN_VALIDATE_OPTIONS_SCRIPT=!TEMP_FILE!" > "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) @@ -886,8 +886,10 @@ exit /b 0 :CreateTempFile set "TEMP_FILE=" set "QWEN_TEMP_FILE_PREFIX=%~1" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" +set "QWEN_TEMP_FILE_EXTENSION=%~2" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" set "QWEN_TEMP_FILE_PREFIX=" +set "QWEN_TEMP_FILE_EXTENSION=" if "!TEMP_FILE!"=="" ( echo ERROR: Failed to create a temporary file. exit /b 1 diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 65e58d82f3a..17c7f0174b8 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -22,6 +22,7 @@ # when testing a custom installer endpoint. $ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' $qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index fa8d520f54f..5671bcb342a 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -251,6 +251,19 @@ describe('installation scripts', () => { 'scripts/installation/install-qwen-standalone.bat: eol: crlf', ); }); + + it('creates PowerShell validation scripts with a ps1 extension', () => { + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + + expect(script).toContain( + 'call :CreateTempFile "qwen-validate-options" ".ps1"', + ); + expect(script).toContain( + "($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION)", + ); + }); }); describe('release-script-utils', () => { From 4b950aba7c2521a89bd0bd814092380743c86c4d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 13:34:08 +0800 Subject: [PATCH 073/129] fix(installer): suppress PowerShell progress bar in bat installer downloads Add $ProgressPreference = 'SilentlyContinue' to DownloadFile so the full-screen progress UI does not appear during archive downloads in interactive PowerShell sessions, consistent with the .ps1 shim. --- scripts/installation/install-qwen-standalone.bat | 6 ++---- scripts/tests/install-script.test.js | 8 +++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index aac05d1e5c2..b6f33fcd84b 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -574,10 +574,8 @@ exit /b %PS_STATUS% :DownloadFile set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -rem Use Invoke-WebRequest so the user sees a progress bar (Net.WebClient is silent). -rem ProgressPreference defaults to 'Continue' which renders a bar in interactive -rem PowerShell. Tls12+Tls13 with fallback for older .NET Framework. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +rem Suppress PowerShell's full-screen progress UI; the installer logs the URL. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 5671bcb342a..b4e5ca68090 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -141,6 +141,7 @@ describe('installation scripts', () => { expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); expect(script).toContain('Invoke-WebRequest'); + expect(script).toContain("$ProgressPreference = 'SilentlyContinue'"); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -202,7 +203,9 @@ describe('installation scripts', () => { expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); - expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); + expect(script).toContain( + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; try", + ); expect(script).toContain( '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', ); @@ -586,6 +589,9 @@ describe('standalone release packaging', () => { ); expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); expect(installPowerShellSource).toContain('Invoke-WebRequest'); + expect(installPowerShellSource).toContain( + "$ProgressPreference = 'SilentlyContinue'", + ); expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); expect(installPowerShellSource).toContain('--version vX.Y.Z'); expect(installPowerShellSource).toContain('SHA256SUMS'); From 01e97674217c0b995af8ba7e42b818318909b1f5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 13:47:04 +0800 Subject: [PATCH 074/129] fix(installer): use curl.exe -# progress bar in Windows downloads Prefer curl.exe with -# (hash-mark progress bar) for archive and installer downloads on Windows 10+. Falls back to Invoke-WebRequest (which shows its own progress bar) when curl.exe is unavailable. Matches the approach used by code-server (curl -#fL) and bun.sh (curl.exe -#SfLo). --- .../installation/install-qwen-standalone.bat | 5 ++-- .../installation/install-qwen-standalone.ps1 | 23 +++++++++++-------- scripts/tests/install-script.test.js | 9 ++++---- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index b6f33fcd84b..626a608bf55 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -574,8 +574,9 @@ exit /b %PS_STATUS% :DownloadFile set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -rem Suppress PowerShell's full-screen progress UI; the installer logs the URL. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +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'; if (Get-Command curl.exe -ErrorAction SilentlyContinue) { curl.exe -#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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 17c7f0174b8..a9324f095d9 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -22,7 +22,18 @@ # when testing a custom installer endpoint. $ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' + +function Download-FileWithProgress { + param([string]$Url, [string]$OutFile) + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + curl.exe -#fSLo $OutFile $Url + if ($LASTEXITCODE -ne 0) { + throw "curl.exe download failed (exit code $LASTEXITCODE)" + } + return + } + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 +} $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' $qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' @@ -58,20 +69,14 @@ $qwenInstallerPath = Join-Path $env:TEMP $qwenInstallerName $qwenChecksumsPath = Join-Path $env:TEMP 'qwen-installation-SHA256SUMS' try { - Invoke-WebRequest -Uri $qwenInstallerUrl ` - -OutFile $qwenInstallerPath ` - -UseBasicParsing ` - -MaximumRedirection 10 + Download-FileWithProgress -Url $qwenInstallerUrl -OutFile $qwenInstallerPath } catch { Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" exit 1 } try { - Invoke-WebRequest -Uri $qwenChecksumsUrl ` - -OutFile $qwenChecksumsPath ` - -UseBasicParsing ` - -MaximumRedirection 10 + Download-FileWithProgress -Url $qwenChecksumsUrl -OutFile $qwenChecksumsPath } catch { Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue Write-Error "Failed to download Qwen Code installer checksums from ${qwenChecksumsUrl}: $($_.Exception.Message)" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index b4e5ca68090..3cf91688631 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -141,7 +141,7 @@ describe('installation scripts', () => { expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); expect(script).toContain('Invoke-WebRequest'); - expect(script).toContain("$ProgressPreference = 'SilentlyContinue'"); + expect(script).toContain('curl.exe -#fSLo'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -204,7 +204,7 @@ describe('installation scripts', () => { 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); expect(script).toContain( - "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; try", + "$ErrorActionPreference = 'Stop'; if (Get-Command curl.exe", ); expect(script).toContain( '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', @@ -589,9 +589,8 @@ describe('standalone release packaging', () => { ); expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); expect(installPowerShellSource).toContain('Invoke-WebRequest'); - expect(installPowerShellSource).toContain( - "$ProgressPreference = 'SilentlyContinue'", - ); + expect(installPowerShellSource).toContain('Download-FileWithProgress'); + expect(installPowerShellSource).toContain('curl.exe -#fSLo'); expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); expect(installPowerShellSource).toContain('--version vX.Y.Z'); expect(installPowerShellSource).toContain('SHA256SUMS'); From 046d2002939b20cfd3988a83e4e1b86857a41a4d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 15:09:45 +0800 Subject: [PATCH 075/129] fix(installer): suppress progress bars for small downloads and Expand-Archive - .ps1: replace curl.exe -# with silent mode, suppress Invoke-WebRequest progress bar; save/restore $global:ProgressPreference - .bat: add $ProgressPreference = 'SilentlyContinue' before Expand-Archive to prevent full-screen extraction progress UI - .sh: remove --progress-bar / --show-progress from download_file, always use silent curl/wget --- .../installation/install-qwen-standalone.bat | 2 +- .../installation/install-qwen-standalone.ps1 | 24 ++++++++++++------- .../installation/install-qwen-standalone.sh | 20 ++-------------- scripts/tests/install-script.test.js | 7 ++++-- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 626a608bf55..19727ba5a67 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -731,7 +731,7 @@ if !ERRORLEVEL! NEQ 0 ( ) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" set "PS_STATUS=!ERRORLEVEL!" set "QWEN_ARCHIVE_FILE=" set "QWEN_EXTRACT_DIR=" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index a9324f095d9..f35b8dadb13 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -23,16 +23,22 @@ $ErrorActionPreference = 'Stop' -function Download-FileWithProgress { +function Download-File { param([string]$Url, [string]$OutFile) - if (Get-Command curl.exe -ErrorAction SilentlyContinue) { - curl.exe -#fSLo $OutFile $Url - if ($LASTEXITCODE -ne 0) { - throw "curl.exe download failed (exit code $LASTEXITCODE)" + $prevProgressPreference = $global:ProgressPreference + $global:ProgressPreference = 'SilentlyContinue' + try { + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + curl.exe -sSfLo $OutFile $Url + if ($LASTEXITCODE -ne 0) { + throw "curl.exe download failed (exit code $LASTEXITCODE)" + } + return } - return + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 + } finally { + $global:ProgressPreference = $prevProgressPreference } - Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 } $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' @@ -69,14 +75,14 @@ $qwenInstallerPath = Join-Path $env:TEMP $qwenInstallerName $qwenChecksumsPath = Join-Path $env:TEMP 'qwen-installation-SHA256SUMS' try { - Download-FileWithProgress -Url $qwenInstallerUrl -OutFile $qwenInstallerPath + Download-File -Url $qwenInstallerUrl -OutFile $qwenInstallerPath } catch { Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" exit 1 } try { - Download-FileWithProgress -Url $qwenChecksumsUrl -OutFile $qwenChecksumsPath + Download-File -Url $qwenChecksumsUrl -OutFile $qwenChecksumsPath } catch { Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue Write-Error "Failed to download Qwen Code installer checksums from ${qwenChecksumsUrl}: $($_.Exception.Message)" diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index d6735bb74ff..aaf22ab2c23 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -640,29 +640,13 @@ download_file() { local url="$1" local destination="$2" - # Show progress only when stderr is a terminal (so CI / non-tty stays clean). - # `curl ... | bash` keeps stderr connected to the user's terminal even though - # stdin is a pipe, so progress shows correctly during normal usage. - local show_progress=0 - if [ -t 2 ] && [ "${QWEN_INSTALL_QUIET:-0}" != "1" ]; then - show_progress=1 - fi - if command_exists curl; then - if [ "${show_progress}" = "1" ]; then - curl -fL --progress-bar --retry 2 "${url}" -o "${destination}" - else - curl -fsSL --retry 2 "${url}" -o "${destination}" - fi + curl -fsSL --retry 2 "${url}" -o "${destination}" return $? fi if command_exists wget; then - if [ "${show_progress}" = "1" ]; then - wget --show-progress --tries=3 "${url}" -O "${destination}" || return 1 - else - wget -q --tries=3 "${url}" -O "${destination}" || return 1 - fi + wget -q --tries=3 "${url}" -O "${destination}" || return 1 return $? fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 3cf91688631..acd891af0eb 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -589,8 +589,11 @@ describe('standalone release packaging', () => { ); expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); expect(installPowerShellSource).toContain('Invoke-WebRequest'); - expect(installPowerShellSource).toContain('Download-FileWithProgress'); - expect(installPowerShellSource).toContain('curl.exe -#fSLo'); + expect(installPowerShellSource).toContain('Download-File'); + expect(installPowerShellSource).toContain('curl.exe -sSfLo'); + expect(installPowerShellSource).toContain( + "$global:ProgressPreference = 'SilentlyContinue'", + ); expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); expect(installPowerShellSource).toContain('--version vX.Y.Z'); expect(installPowerShellSource).toContain('SHA256SUMS'); From ccbdd34f39fa30110e093809cb8b0bb645ba8c9d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 15:23:18 +0800 Subject: [PATCH 076/129] fix(installer): auto-backup non-qwen directories and simplify output - ensure_managed_install_dir / :EnsureManagedInstallDir now back up non-qwen directories instead of refusing to install, so users upgrading from npm or old installers don't hit a hard error - Simplify header/footer output: remove banner bars, verbose INFO lines, and redundant "Installation completed!" message - Match bun.sh / code-server style: minimal, to the point --- .../installation/install-qwen-standalone.bat | 73 +++++-------------- .../installation/install-qwen-standalone.sh | 58 ++++----------- scripts/tests/install-script.test.js | 31 ++++++-- 3 files changed, 58 insertions(+), 104 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 19727ba5a67..5283d02e342 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -203,22 +203,7 @@ goto usage_error call :ValidateOptions if %ERRORLEVEL% NEQ 0 exit /b 1 -echo =========================================== -echo Qwen Code Installation Script -echo =========================================== -echo. -echo INFO: Install method: !METHOD! -if /i not "!METHOD!"=="npm" ( - echo INFO: Standalone mirror: !MIRROR! - if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! - if not "!ARCHIVE_PATH!"=="" ( - echo INFO: Standalone archive: !ARCHIVE_PATH! - ) else ( - echo INFO: Standalone version: !VERSION! - ) -) -if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! -if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo Qwen Code Installer echo. REM Discover all qwen executables on disk BEFORE we install. We can't @@ -963,9 +948,19 @@ set "PS_STATUS=!ERRORLEVEL!" set "QWEN_MANAGED_DIR=" if !PS_STATUS! EQU 0 exit /b 0 -echo ERROR: !MANAGED_DIR! exists but is not a Qwen Code standalone install. -echo ERROR: Refusing to overwrite it. Move or remove it manually, then rerun the installer. -exit /b 1 +rem Directory exists but is not a qwen-code standalone install. +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!"=="" for /f "delims=" %%s in ('powershell -NoProfile -Command "[int](Get-Date -UFormat %s)"') do set "BACKUP_DIR=!MANAGED_DIR!.backup.%%s" +echo WARNING: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo WARNING: Backing up to !BACKUP_DIR! +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. + exit /b 1 +) +exit /b 0 :RequireNode where node >nul 2>&1 @@ -1067,10 +1062,6 @@ if not "!EXTRA_BIN!"=="" ( set "PATH=!EXTRA_BIN!;!PATH!" ) -echo. -echo =========================================== -echo Installation completed! -echo =========================================== echo. set "INSTALLED_VERSION=unknown" @@ -1078,11 +1069,8 @@ if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" ) -if not "!INSTALLED_BIN!"=="" ( - echo SUCCESS: Installed at !INSTALLED_BIN!: !INSTALLED_VERSION! -) else ( - echo SUCCESS: Qwen Code installed: !INSTALLED_VERSION! -) +echo Qwen Code !INSTALLED_VERSION! installed. +if not "!INSTALLED_BIN!"=="" echo Location: !INSTALLED_BIN! rem Build OTHER_QWENS = PRE_INSTALL_QWENS_LIST minus the install we just made. set "OTHER_QWENS=" @@ -1129,31 +1117,10 @@ if defined OTHER_QWENS ( where qwen >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( - echo WARNING: Qwen Code was installed, but qwen is not on PATH in this prompt. - echo. - echo Restart your command prompt, then run: qwen - if not "!EXTRA_BIN!"=="" ( - echo. - echo Or add this directory to PATH: - echo !EXTRA_BIN! - echo Then run: - echo qwen - exit /b 0 - ) - - for /f "delims=" %%i in ('npm prefix -g 2^>nul') do set "NPM_PREFIX=%%i" - if not "!NPM_PREFIX!"=="" ( - echo. - echo Or add this npm global directory to PATH: - echo !NPM_PREFIX! - echo Then run: - echo qwen - ) + echo Run: qwen + echo ^(Restart your command prompt for PATH changes to take effect.^) + if not "!EXTRA_BIN!"=="" echo Or now: "!EXTRA_BIN!\qwen.cmd" exit /b 0 ) - -echo. -echo You can now run: qwen -echo. -echo INFO: Run qwen in your project directory to start an interactive session. +echo Run: qwen exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index aaf22ab2c23..2afe8501d56 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -309,29 +309,7 @@ done validate_options print_header() { - echo "==========================================" - echo " Qwen Code Installation Script" - echo "==========================================" - echo "" - log_info "System: $(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)" - log_info "Install method: ${METHOD}" - if [[ "${METHOD}" != "npm" ]]; then - log_info "Standalone mirror: ${MIRROR}" - if [[ -n "${BASE_URL}" ]]; then - log_info "Standalone base URL: ${BASE_URL}" - fi - if [[ -n "${ARCHIVE_PATH}" ]]; then - log_info "Standalone archive: ${ARCHIVE_PATH}" - else - log_info "Standalone version: ${VERSION}" - fi - fi - if [[ "${METHOD}" != "standalone" ]]; then - log_info "npm registry: ${NPM_REGISTRY}" - fi - if [[ "${SOURCE}" != "unknown" ]]; then - log_info "Installation source: ${SOURCE}" - fi + echo "Qwen Code Installer" echo "" } @@ -848,8 +826,14 @@ ensure_managed_install_dir() { return 0 fi - log_error "${install_dir} exists but is not a Qwen Code standalone install." - log_error "Refusing to overwrite it. Move or remove it manually, then rerun the installer." + local backup="${install_dir}.backup.$(date +%Y%m%dT%H%M%S 2>/dev/null || date +%Y%m%d%H%M%S)" + log_warning "${install_dir} exists but is not a Qwen Code standalone install." + log_warning "Backing up to: ${backup}" + if mv "${install_dir}" "${backup}"; then + return 0 + fi + + log_error "Failed to back up ${install_dir}. Move or remove it manually, then rerun the installer." return 1 } @@ -1085,10 +1069,6 @@ print_final_instructions() { export PATH="${install_bin_dir}:${PATH}" fi - echo "" - echo "==========================================" - echo "Installation completed!" - echo "==========================================" echo "" local installed_version="unknown" @@ -1098,10 +1078,9 @@ print_final_instructions() { installed_version=$(qwen --version 2>/dev/null || echo "unknown") fi + echo "Qwen Code ${installed_version} installed." if [[ -n "${installed_bin}" ]]; then - log_success "Installed at ${installed_bin}: ${installed_version}" - else - log_success "Qwen Code installed: ${installed_version}" + echo "Location: ${installed_bin}" fi if [[ -n "${other_qwens}" ]]; then @@ -1133,22 +1112,15 @@ print_final_instructions() { fi if ! command_exists qwen; then - log_warning "Qwen Code was installed, but qwen is not on PATH in this shell." - echo "" - echo "Restart your terminal, then run: qwen" + echo "Run: qwen" + echo "(Restart your terminal for PATH changes to take effect.)" if [[ -n "${install_bin_dir}" ]]; then - echo "" - echo "Or run this in the current shell:" - echo " export PATH=${quoted_install_bin_dir}:\$PATH" - echo " qwen" + echo "Or now: export PATH=${quoted_install_bin_dir}:\$PATH && qwen" fi return 0 fi - echo "" - echo "You can now run: qwen" - echo "" - log_info "Run qwen in your project directory to start an interactive session." + echo "Run: qwen" } main() { diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index acd891af0eb..47b800dabbf 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -13,6 +13,7 @@ const { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -68,7 +69,7 @@ describe('installation scripts', () => { expect(script).toContain( 'npm install -g @qwen-code/qwen-code@latest --registry', ); - expect(script).toContain('You can now run: qwen'); + expect(script).toContain('Run: qwen'); }); it('supports code-server-style standalone install on Linux/macOS', () => { @@ -156,7 +157,7 @@ describe('installation scripts', () => { expect(script).toContain( 'npm install -g @qwen-code/qwen-code@latest --registry', ); - expect(script).toContain('You can now run: qwen'); + expect(script).toContain('Run: qwen'); }); it('supports code-server-style standalone install on Windows', () => { @@ -1459,7 +1460,7 @@ describe('Linux/macOS installer end-to-end', () => { }, ); - itOnUnix('refuses to overwrite a non-managed install directory', () => { + itOnUnix('backs up and overwrites a non-managed install directory', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1470,12 +1471,26 @@ describe('Linux/macOS installer end-to-end', () => { mkdirSync(installDir, { recursive: true }); writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - expect(() => - runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', + const output = runUnixInstaller( + archive, + installRoot, + path.join(tmpDir, 'home'), + ).toString(); + + expect(output).toContain('not a Qwen Code standalone install'); + expect(output).toContain('Backing up to'); + + // Original directory should be backed up, not destroyed + const backups = readdirSync(path.join(installRoot, 'lib')).filter((e) => + e.startsWith('qwen-code.backup.'), ); + expect(backups.length).toBe(1); + expect( + readScript(path.join(installRoot, 'lib', backups[0], 'important.txt')), + ).toBe('keep me\n'); + + // New install should be at the original location + expect(existsSync(path.join(installDir, 'manifest.json'))).toBe(true); } finally { rmSync(tmpDir, { recursive: true, force: true }); if (createdDist) { From 24cb1c7a12f5baf1ccf4846e402f62a98af18822 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 15:50:33 +0800 Subject: [PATCH 077/129] fix(installer): revert Expand-Archive progress suppression in bat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline $ProgressPreference = 'SilentlyContinue' caused a cmd.exe parsing error ("此时不应有 >") on Chinese Windows. Revert to the original Expand-Archive invocation. --- scripts/installation/install-qwen-standalone.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 5283d02e342..578ffe2e66d 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -716,7 +716,7 @@ if !ERRORLEVEL! NEQ 0 ( ) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" set "PS_STATUS=!ERRORLEVEL!" set "QWEN_ARCHIVE_FILE=" set "QWEN_EXTRACT_DIR=" From 31e3c4350a3c3bac977daebcb55d0475a7bb665d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:02:07 +0800 Subject: [PATCH 078/129] fix(installer): fix cmd.exe parsing error in backup fallback code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The %s in the for /f fallback command string was interpreted as a variable reference by cmd.exe, causing "此时不应有 >" on Chinese Windows. Replace with a safe fallback and re-enable Expand-Archive progress suppression. --- scripts/installation/install-qwen-standalone.bat | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 578ffe2e66d..d9918150cf9 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -716,7 +716,7 @@ if !ERRORLEVEL! NEQ 0 ( ) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" set "PS_STATUS=!ERRORLEVEL!" set "QWEN_ARCHIVE_FILE=" set "QWEN_EXTRACT_DIR=" @@ -950,9 +950,9 @@ if !PS_STATUS! EQU 0 exit /b 0 rem Directory exists but is not a qwen-code standalone install. 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" +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!"=="" for /f "delims=" %%s in ('powershell -NoProfile -Command "[int](Get-Date -UFormat %s)"') do set "BACKUP_DIR=!MANAGED_DIR!.backup.%%s" +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! move /Y "!MANAGED_DIR!" "!BACKUP_DIR!" >nul From b7f190a75fb5f9179031c0f0572a40bb01ba8b6a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:08:07 +0800 Subject: [PATCH 079/129] fix(installer): always persist install bin to user PATH Previously MaybeUpdateUserPath was only called when shadow qwen executables were detected. When no shadow was found, the PATH update was skipped entirely, leaving the user without qwen on PATH after restarting their terminal. Now always persist the bin directory to PATH (unless --no-modify-path is set), regardless of whether other qwen installations exist. --- .../installation/install-qwen-standalone.bat | 35 +++++++------------ .../installation/install-qwen-standalone.sh | 28 ++++----------- 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index d9918150cf9..9bc83d128bc 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -1087,6 +1087,15 @@ if defined PRE_INSTALL_QWENS_LIST ( ) ) +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! + ) +) + if defined OTHER_QWENS ( echo. echo WARNING: Other 'qwen' executables exist on this system. Depending on @@ -1096,31 +1105,11 @@ if defined OTHER_QWENS ( if not "!OQ!"=="" echo WARNING: !OQ! ) echo. - if /i "!NO_MODIFY_PATH!"=="1" ( - echo Skipped user PATH update because --no-modify-path is set. - echo To make this install win, add this to your user PATH manually: - echo !EXTRA_BIN! - ) else ( - call :MaybeUpdateUserPath "!EXTRA_BIN!" - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Failed to update user PATH. Add the directory manually: - echo !EXTRA_BIN! - ) - echo. - echo If you prefer not to modify user PATH, rerun with --no-modify-path - echo and pick one of: - echo - npm uninstall -g @qwen-code/qwen-code ^(if the shadow is an npm install^) - echo - invoke directly: "!INSTALLED_BIN!" - ) + echo To make this install take priority, restart your command prompt. + echo Or invoke directly: "!INSTALLED_BIN!" exit /b 0 ) -where qwen >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo Run: qwen - echo ^(Restart your command prompt for PATH changes to take effect.^) - if not "!EXTRA_BIN!"=="" echo Or now: "!EXTRA_BIN!\qwen.cmd" - exit /b 0 -) echo Run: qwen +echo ^(Restart your command prompt for PATH changes to take effect.^) exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 2afe8501d56..d2ef1d04adf 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -1083,6 +1083,10 @@ print_final_instructions() { echo "Location: ${installed_bin}" 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" @@ -1096,31 +1100,13 @@ print_final_instructions() { done IFS="${saved_ifs}" echo "" - if [[ "${NO_MODIFY_PATH:-0}" == "1" ]]; then - echo "Skipped shell rc update because --no-modify-path is set." - echo "To make this install win, manually add to your shell rc:" - echo " export PATH=${quoted_install_bin_dir}:\$PATH" - else - maybe_update_shell_path "${install_bin_dir}" - echo "" - echo "If you prefer not to modify the shell rc, rerun with --no-modify-path" - echo "and pick one of:" - echo " - npm uninstall -g @qwen-code/qwen-code # if the shadow is an npm install" - echo " - invoke directly: ${installed_bin}" - fi - return 0 - fi - - if ! command_exists qwen; then - echo "Run: qwen" - echo "(Restart your terminal for PATH changes to take effect.)" - if [[ -n "${install_bin_dir}" ]]; then - echo "Or now: export PATH=${quoted_install_bin_dir}:\$PATH && qwen" - fi + echo "To make this install take priority, restart your terminal." + echo "Or invoke directly: ${installed_bin}" return 0 fi echo "Run: qwen" + echo "(Restart your terminal for PATH changes to take effect.)" } main() { From c3b488f9d7483da45e98a4eb39f518b89132a032 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:14:34 +0800 Subject: [PATCH 080/129] fix(installer): persist PATH to current terminal session on Windows Use the `endlocal & set` trick (same as bun/Rust installers) to export the install bin directory from the setlocal scope to the current cmd session. qwen is now usable immediately without restarting the terminal. --- scripts/installation/install-qwen-standalone.bat | 6 +++--- scripts/installation/install-qwen-standalone.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 9bc83d128bc..d596acbc156 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -241,7 +241,7 @@ if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal + endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" exit /b 0 ) @@ -257,7 +257,7 @@ call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal + endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" exit /b 0 ) @@ -1111,5 +1111,5 @@ if defined OTHER_QWENS ( ) echo Run: qwen -echo ^(Restart your command prompt for PATH changes to take effect.^) +echo qwen is ready to use in this terminal. exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index d2ef1d04adf..baf2197a329 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -1106,7 +1106,7 @@ print_final_instructions() { fi echo "Run: qwen" - echo "(Restart your terminal for PATH changes to take effect.)" + echo "(Open a new terminal for the PATH change to take effect.)" } main() { From bdf101b0ee8ba2840d0d1dfc133d6fdb1aca5c7f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:29:51 +0800 Subject: [PATCH 081/129] docs(installer): document cmd.exe one-liner for immediate PATH availability Add curl-based one-liner for cmd.exe users. Running the .bat directly in the current cmd session makes `qwen` available immediately via the `endlocal & set` trick. The `powershell -c "irm | iex"` path creates a child process so PATH changes cannot propagate to the parent. --- scripts/installation/install-qwen-standalone.ps1 | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index f35b8dadb13..d3d87e2ba00 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -1,11 +1,12 @@ # Qwen Code Windows hosted PowerShell entrypoint. -# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP and runs -# it, so the documented one-liner can use the standard irm | iex pattern. -# Note: irm (Invoke-RestMethod) and iwr (Invoke-WebRequest) both return the raw -# text of a .ps1 file unchanged, so the one-liner works with either alias. +# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP, +# verifies its checksum, and runs it with forwarded arguments. # -# Usage: -# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex" +# PowerShell (runs in current session, qwen available immediately): +# irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex +# +# cmd.exe (runs in current session, qwen available immediately): +# curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat -o %TEMP%\install-qwen.bat && %TEMP%\install-qwen.bat # # To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, # e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing From 38c4706d76a18823b42852c47a55534587c930b8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:44:41 +0800 Subject: [PATCH 082/129] feat(installer): make qwen usable immediately from PowerShell after install - .ps1: detect parent process, update current session PATH, and for cmd.exe parents emit a `set PATH=...` command - .bat: skip final instructions when called from PowerShell to avoid duplicate "Run: qwen" output --- .../installation/install-qwen-standalone.bat | 4 + .../installation/install-qwen-standalone.ps1 | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index d596acbc156..fdb810c88bb 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -1110,6 +1110,10 @@ if defined OTHER_QWENS ( exit /b 0 ) +if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( + echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. + exit /b 0 +) echo Run: qwen 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 d3d87e2ba00..c7608847b12 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -42,6 +42,98 @@ function Download-File { } } +function Get-QwenInstallBinDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + return $env:QWEN_INSTALL_BIN_DIR + } + + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + return Join-Path $env:QWEN_INSTALL_ROOT 'bin' + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + return Join-Path (Join-Path $env:LOCALAPPDATA 'qwen-code') 'bin' + } + + return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local\qwen-code') 'bin' +} + +function Update-CurrentSessionPath { + param([string]$BinDir) + + if ([string]::IsNullOrEmpty($BinDir)) { + return + } + + $entries = @($env:Path -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) }) + foreach ($entry in $entries) { + if ([string]::Equals($entry, $BinDir, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $env:Path = (@($BinDir) + $entries) -join ';' +} + +function Get-ParentProcessName { + try { + $current = Get-CimInstance Win32_Process -Filter "ProcessId = $PID" -ErrorAction Stop + if ($null -eq $current -or $null -eq $current.ParentProcessId) { + return $null + } + $parent = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" -ErrorAction Stop + if ($null -eq $parent) { + return $null + } + return $parent.Name + } catch { + return $null + } +} + +function Install-CmdSessionAlias { + param([string]$QwenCommand) + + if ([string]::IsNullOrEmpty($QwenCommand)) { + return $false + } + + $doskey = Join-Path $env:SystemRoot 'System32\doskey.exe' + if (-not (Test-Path -LiteralPath $doskey -PathType Leaf)) { + $doskey = 'doskey.exe' + } + + & $doskey /exename=cmd.exe "qwen=`"$QwenCommand`" `$*" | Out-Null + return ($LASTEXITCODE -eq 0) +} + +function Update-CurrentShell { + $qwenInstallBinDir = Get-QwenInstallBinDir + $qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd' + if (-not (Test-Path -LiteralPath $qwenCommandPath -PathType Leaf)) { + return + } + + Update-CurrentSessionPath -BinDir $qwenInstallBinDir + + Write-Output "Run: qwen" + $parentProcessName = Get-ParentProcessName + if ($parentProcessName -ieq 'cmd.exe') { + if (Install-CmdSessionAlias -QwenCommand $qwenCommandPath) { + Write-Output "INFO: Added a qwen alias for this cmd.exe window." + 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." + Write-Output "Run this in the current cmd.exe window to use qwen immediately:" + 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' $qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) { @@ -115,10 +207,17 @@ if ($qwenActualHash -ne $qwenExpectedHash) { } $qwenInstallerExitCode = 0 +$qwenPreviousParentPowerShell = $env:QWEN_INSTALLER_PARENT_POWERSHELL try { + $env:QWEN_INSTALLER_PARENT_POWERSHELL = '1' & $qwenInstallerPath @args $qwenInstallerExitCode = $LASTEXITCODE } finally { + if ($null -eq $qwenPreviousParentPowerShell) { + Remove-Item Env:\QWEN_INSTALLER_PARENT_POWERSHELL -ErrorAction SilentlyContinue + } else { + $env:QWEN_INSTALLER_PARENT_POWERSHELL = $qwenPreviousParentPowerShell + } Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue } @@ -126,3 +225,5 @@ try { if ($qwenInstallerExitCode -ne 0) { exit $qwenInstallerExitCode } + +Update-CurrentShell From 564f899359a3336ba2138e35ead5f606e8a99d60 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 16:48:41 +0800 Subject: [PATCH 083/129] fix(installer): remove non-functional doskey approach for cmd parent doskey /exename from a child PowerShell process cannot modify the parent cmd.exe session. Replace with a simple set PATH=... command that the user can copy-paste. --- .../installation/install-qwen-standalone.ps1 | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index c7608847b12..122a6ee4691 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -91,22 +91,6 @@ function Get-ParentProcessName { } } -function Install-CmdSessionAlias { - param([string]$QwenCommand) - - if ([string]::IsNullOrEmpty($QwenCommand)) { - return $false - } - - $doskey = Join-Path $env:SystemRoot 'System32\doskey.exe' - if (-not (Test-Path -LiteralPath $doskey -PathType Leaf)) { - $doskey = 'doskey.exe' - } - - & $doskey /exename=cmd.exe "qwen=`"$QwenCommand`" `$*" | Out-Null - return ($LASTEXITCODE -eq 0) -} - function Update-CurrentShell { $qwenInstallBinDir = Get-QwenInstallBinDir $qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd' @@ -119,19 +103,10 @@ function Update-CurrentShell { Write-Output "Run: qwen" $parentProcessName = Get-ParentProcessName if ($parentProcessName -ieq 'cmd.exe') { - if (Install-CmdSessionAlias -QwenCommand $qwenCommandPath) { - Write-Output "INFO: Added a qwen alias for this cmd.exe window." - 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." - Write-Output "Run this in the current cmd.exe window to use qwen immediately:" + 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' From 74130fc79e05b2bb657b2998b0f38832ee07b5e3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 17:00:03 +0800 Subject: [PATCH 084/129] fix(installer): make Windows standalone shim available in cmd --- .../installation/install-qwen-standalone.ps1 | 157 ++++++++++++++++++ scripts/tests/install-script.test.js | 29 ++++ 2 files changed, 186 insertions(+) diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 122a6ee4691..b75d4df350a 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -91,6 +91,146 @@ function Get-ParentProcessName { } } +function Get-NormalizedPath { + param([string]$PathValue) + + if ([string]::IsNullOrEmpty($PathValue)) { + return $null + } + + $trimmed = $PathValue.Trim().Trim('"') + if ([string]::IsNullOrEmpty($trimmed)) { + return $null + } + + try { + return [IO.Path]::GetFullPath($trimmed).TrimEnd('\') + } catch { + return $trimmed.TrimEnd('\') + } +} + +function Test-PathContainsDirectory { + param([string]$PathValue, [string]$Directory) + + $target = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($target)) { + return $false + } + + foreach ($entry in @($PathValue -split ';')) { + $normalizedEntry = Get-NormalizedPath -PathValue $entry + if ([string]::Equals($normalizedEntry, $target, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + return $false +} + +function Test-WritableDirectory { + param([string]$Directory) + + if ([string]::IsNullOrEmpty($Directory)) { + return $false + } + + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + return $false + } + + $probe = Join-Path $Directory ('.qwen-write-test-' + [IO.Path]::GetRandomFileName()) + try { + [IO.File]::WriteAllText($probe, '') + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + return $true + } catch { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + return $false + } +} + +function Add-PathCandidate { + param( + [System.Collections.Generic.List[string]]$Candidates, + [string]$Directory + ) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($normalizedDirectory)) { + return + } + + foreach ($candidate in $Candidates) { + $normalizedCandidate = Get-NormalizedPath -PathValue $candidate + if ([string]::Equals($normalizedCandidate, $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + [void]$Candidates.Add($Directory.Trim().Trim('"')) +} + +function Install-CurrentCmdPathShim { + param([string]$QwenCommand, [string]$PathValue) + + $pathEntries = @($PathValue -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $candidates = [System.Collections.Generic.List[string]]::new() + $preferredDirectories = @() + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + $preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' + } + if (-not [string]::IsNullOrEmpty($env:APPDATA)) { + $preferredDirectories += Join-Path $env:APPDATA 'npm' + } + if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) { + $preferredDirectories += Join-Path $env:USERPROFILE '.bun\bin' + } + + foreach ($preferredDirectory in $preferredDirectories) { + $preferredNormalized = Get-NormalizedPath -PathValue $preferredDirectory + foreach ($entry in $pathEntries) { + $entryNormalized = Get-NormalizedPath -PathValue $entry + if ([string]::Equals($entryNormalized, $preferredNormalized, [StringComparison]::OrdinalIgnoreCase)) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + } + + $userRoot = Get-NormalizedPath -PathValue $env:USERPROFILE + foreach ($entry in $pathEntries) { + $entryNormalized = Get-NormalizedPath -PathValue $entry + if ( + -not [string]::IsNullOrEmpty($userRoot) -and + -not [string]::IsNullOrEmpty($entryNormalized) -and + $entryNormalized.StartsWith($userRoot, [StringComparison]::OrdinalIgnoreCase) + ) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + + foreach ($candidate in $candidates) { + if (-not (Test-WritableDirectory -Directory $candidate)) { + continue + } + + $shimPath = Join-Path $candidate 'qwen.cmd' + if (Test-Path -LiteralPath $shimPath -PathType Leaf) { + $existingShim = Get-Content -LiteralPath $shimPath -Raw -ErrorAction SilentlyContinue + if ($existingShim -notmatch 'Qwen Code current-session shim') { + continue + } + } + + $shim = "@echo off`r`nREM Qwen Code current-session shim. Generated by install-qwen-standalone.ps1.`r`ncall `"$QwenCommand`" %*`r`n" + [IO.File]::WriteAllText($shimPath, $shim, [Text.UTF8Encoding]::new($false)) + return $shimPath + } + + return $null +} + function Update-CurrentShell { $qwenInstallBinDir = Get-QwenInstallBinDir $qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd' @@ -98,15 +238,32 @@ function Update-CurrentShell { 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/tests/install-script.test.js b/scripts/tests/install-script.test.js index 47b800dabbf..df2b4605e30 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -603,6 +603,35 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('@args'); }); + it('PowerShell hosted entrypoint refreshes the current Windows shell', () => { + const installPowerShellSource = readScript( + 'scripts/installation/install-qwen-standalone.ps1', + ); + const installBatchSource = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + + expect(installPowerShellSource).toContain('Update-CurrentSessionPath'); + expect(installPowerShellSource).toContain('Install-CurrentCmdPathShim'); + expect(installPowerShellSource).toContain('Test-WritableDirectory'); + expect(installPowerShellSource).toContain('Qwen Code current-session shim'); + 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( + 'Final PATH refresh is handled by the PowerShell entrypoint.', + ); + }); + it('stages hosted installation assets with checksums', async () => { const { HOSTED_INSTALLATION_ASSET_NAMES, From 75e5b58806ea990b0113e858455c24c1ede79f66 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 17:31:44 +0800 Subject: [PATCH 085/129] feat(installer): add standalone uninstall scripts --- .github/workflows/release.yml | 4 +- scripts/build-hosted-installation-assets.js | 41 +++ scripts/installation/INSTALLATION_GUIDE.md | 36 +- .../uninstall-qwen-standalone.ps1 | 319 ++++++++++++++++++ .../installation/uninstall-qwen-standalone.sh | 247 ++++++++++++++ scripts/tests/install-script.test.js | 140 ++++++++ 6 files changed, 783 insertions(+), 4 deletions(-) create mode 100644 scripts/installation/uninstall-qwen-standalone.ps1 create mode 100755 scripts/installation/uninstall-qwen-standalone.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a3e4a8526..583230026ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -513,6 +513,8 @@ jobs: upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" + upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" + upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" - name: 'Verify Aliyun OSS Release Assets' @@ -532,7 +534,7 @@ jobs: npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest" fi - for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat SHA256SUMS; do + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" curl -fsSIL "${url}" >/dev/null || curl -fsSL --range 0-0 "${url}" >/dev/null done diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index b8f975c80da..ee3d7be6c24 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -36,6 +36,15 @@ const HOSTED_INSTALLATION_ASSETS = [ sourcePath: ['scripts', 'installation', 'install-qwen-standalone.ps1'], output: 'install-qwen-standalone.ps1', }, + { + sourcePath: ['scripts', 'installation', 'uninstall-qwen-standalone.sh'], + output: 'uninstall-qwen-standalone.sh', + mode: 0o755, + }, + { + sourcePath: ['scripts', 'installation', 'uninstall-qwen-standalone.ps1'], + output: 'uninstall-qwen-standalone.ps1', + }, ]; const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, @@ -79,6 +88,38 @@ const HOSTED_INSTALLER_BEHAVIOR_PATTERNS = { pattern: /QWEN_INSTALL_VERSION/, }, ], + 'uninstall-qwen-standalone.sh': [ + { + name: 'standalone directory guard', + pattern: /is_qwen_standalone_install_dir/, + }, + { + name: 'PATH cleanup', + pattern: /remove_shell_path_entry/, + }, + { + name: 'config preservation', + pattern: /QWEN_UNINSTALL_PURGE/, + }, + ], + 'uninstall-qwen-standalone.ps1': [ + { + name: 'standalone directory guard', + pattern: /Test-QwenStandaloneInstallDir/, + }, + { + name: 'PATH cleanup', + pattern: /Remove-UserPathEntry/, + }, + { + name: 'current cmd shim cleanup', + pattern: /Remove-CurrentCmdPathShim/, + }, + { + name: 'config preservation', + pattern: /QWEN_UNINSTALL_PURGE/, + }, + ], }; // Narrow regexes that pin the default-version assignment to `latest`. // Substring matching alone would let the word "latest" leak in via comments diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index f0bffdec181..f7b20ced1b7 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -24,6 +24,8 @@ are only required when the installer falls back to npm or when - Linux/macOS: `install-qwen-standalone.sh` - Windows: `install-qwen-standalone.ps1` +- Linux/macOS uninstall: `uninstall-qwen-standalone.sh` +- Windows uninstall: `uninstall-qwen-standalone.ps1` ## Release Artifacts @@ -54,6 +56,8 @@ Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen-standalone.ps1` is the Windows hosted entrypoint for `irm | iex`. - `install-qwen-standalone.bat` is the Windows installer implementation used by `install-qwen-standalone.ps1` and can also be downloaded and run directly. +- `uninstall-qwen-standalone.sh` removes Linux/macOS standalone installs. +- `uninstall-qwen-standalone.ps1` removes Windows standalone installs. Build them with: @@ -61,13 +65,16 @@ Build them with: npm run package:hosted-installation -- --out-dir dist/installation ``` -The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, and -`install-qwen-standalone.bat` files map to the standalone-suffixed hosted URLs +The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, +`install-qwen-standalone.bat`, `uninstall-qwen-standalone.sh`, and +`uninstall-qwen-standalone.ps1` files map to the standalone-suffixed hosted URLs shown above. During a non-dry-run release, the publish workflow uploads their contents byte-for-byte to `installation/install-qwen-standalone.sh`, `installation/install-qwen-standalone.ps1`, and -`installation/install-qwen-standalone.bat`; the staging command also writes +`installation/install-qwen-standalone.bat`, +`installation/uninstall-qwen-standalone.sh`, and +`installation/uninstall-qwen-standalone.ps1`; the staging command also writes `SHA256SUMS` for upload verification. The hosted installers intentionally default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone release. @@ -161,6 +168,17 @@ Standalone installs to: Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, `QWEN_INSTALL_LIB_DIR`, or `QWEN_INSTALL_BIN_DIR` when needed. +Uninstall a standalone Linux/macOS install: + +```bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash +``` + +The uninstaller removes only the standalone runtime, generated `qwen` wrapper, +and installer-managed shell PATH block. It preserves `~/.qwen` by default. Set +`QWEN_UNINSTALL_PURGE=1` to remove `~/.qwen/source.json`; other config and auth +files are still preserved. + ## Windows Usage ```bat @@ -191,6 +209,18 @@ Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_DIR`, or Restart the terminal if `qwen` is not immediately available on PATH. +Uninstall a standalone Windows install: + +```bat +powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex" +``` + +The uninstaller removes only the standalone runtime, generated `qwen.cmd` +wrapper, user PATH entry, and the current-session `cmd.exe` shim created by the +hosted PowerShell installer. It preserves `%USERPROFILE%\.qwen` by default. Set +`QWEN_UNINSTALL_PURGE=1` to remove `%USERPROFILE%\.qwen\source.json`; other +config and auth files are still preserved. + ## Mirrors and Overrides Options: diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 new file mode 100644 index 00000000000..215c367bbde --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -0,0 +1,319 @@ +# Qwen Code standalone uninstaller. +# Removes files owned by install-qwen-standalone.bat/.ps1 and preserves user +# config by default. +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex" +# +# Set $env:QWEN_UNINSTALL_PURGE = '1' to also remove the installer source +# marker at %USERPROFILE%\.qwen\source.json. Other Qwen Code config and auth +# files are preserved. + +$ErrorActionPreference = 'Stop' + +function Write-Info { + param([string]$Message) + Write-Output "INFO: $Message" +} + +function Write-Success { + param([string]$Message) + Write-Output "SUCCESS: $Message" +} + +function Write-WarningMessage { + param([string]$Message) + Write-Output "WARNING: $Message" +} + +function Get-QwenInstallBase { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + return $env:QWEN_INSTALL_ROOT + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + return Join-Path $env:LOCALAPPDATA 'qwen-code' + } + + return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local') 'qwen-code' +} + +function Get-QwenInstallDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_LIB_DIR)) { + return $env:QWEN_INSTALL_LIB_DIR + } + + return Join-Path (Get-QwenInstallBase) 'qwen-code' +} + +function Get-QwenInstallBinDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + return $env:QWEN_INSTALL_BIN_DIR + } + + return Join-Path (Get-QwenInstallBase) 'bin' +} + +function Get-NormalizedPath { + param([string]$PathValue) + + if ([string]::IsNullOrEmpty($PathValue)) { + return $null + } + + $trimmed = $PathValue.Trim().Trim('"') + if ([string]::IsNullOrEmpty($trimmed)) { + return $null + } + + try { + return [IO.Path]::GetFullPath($trimmed).TrimEnd('\') + } catch { + return $trimmed.TrimEnd('\') + } +} + +function Test-PathMatches { + param([string]$Left, [string]$Right) + + $leftPath = Get-NormalizedPath -PathValue $Left + $rightPath = Get-NormalizedPath -PathValue $Right + if ([string]::IsNullOrEmpty($leftPath) -or [string]::IsNullOrEmpty($rightPath)) { + return $false + } + + return [string]::Equals($leftPath, $rightPath, [StringComparison]::OrdinalIgnoreCase) +} + +function Test-QwenStandaloneInstallDir { + param([string]$InstallDir) + + if (-not (Test-Path -LiteralPath $InstallDir -PathType Container)) { + return $false + } + + $manifestPath = Join-Path $InstallDir 'manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + return $false + } + + try { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + } catch { + return $false + } + + if ($manifest.name -ne '@qwen-code/qwen-code') { + return $false + } + + if ([string]$manifest.target -notmatch '^win-(x64|arm64)$') { + return $false + } + + if (-not (Test-Path -LiteralPath (Join-Path $InstallDir 'bin\qwen.cmd') -PathType Leaf)) { + return $false + } + + if (-not (Test-Path -LiteralPath (Join-Path $InstallDir 'node\node.exe') -PathType Leaf)) { + return $false + } + + return $true +} + +function Remove-UserPathEntry { + param([string]$BinDir) + + $target = Get-NormalizedPath -PathValue $BinDir + if ([string]::IsNullOrEmpty($target)) { + return + } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ([string]::IsNullOrEmpty($userPath)) { + return + } + + $kept = New-Object System.Collections.Generic.List[string] + $removed = $false + foreach ($entry in @($userPath -split ';')) { + if ([string]::IsNullOrEmpty($entry)) { + continue + } + + if (Test-PathMatches -Left $entry -Right $target) { + $removed = $true + continue + } + + [void]$kept.Add($entry) + } + + if ($removed) { + [Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') + Write-Success "Removed $BinDir from user PATH." + } + + $current = New-Object System.Collections.Generic.List[string] + foreach ($entry in @($env:Path -split ';')) { + if ([string]::IsNullOrEmpty($entry)) { + continue + } + if (-not (Test-PathMatches -Left $entry -Right $target)) { + [void]$current.Add($entry) + } + } + $env:Path = $current -join ';' +} + +function Add-PathCandidate { + param( + [System.Collections.Generic.List[string]]$Candidates, + [string]$Directory + ) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($normalizedDirectory)) { + return + } + + foreach ($candidate in $Candidates) { + $normalizedCandidate = Get-NormalizedPath -PathValue $candidate + if ([string]::Equals($normalizedCandidate, $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + [void]$Candidates.Add($Directory.Trim().Trim('"')) +} + +function Remove-CurrentCmdPathShim { + $candidates = [System.Collections.Generic.List[string]]::new() + foreach ($entry in @($env:Path -split ';')) { + if (-not [string]::IsNullOrEmpty($entry)) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps') + } + if (-not [string]::IsNullOrEmpty($env:APPDATA)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:APPDATA 'npm') + } + if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:USERPROFILE '.bun\bin') + } + + foreach ($candidate in $candidates) { + $shimPath = Join-Path $candidate 'qwen.cmd' + if (-not (Test-Path -LiteralPath $shimPath -PathType Leaf)) { + continue + } + + $existingShim = Get-Content -LiteralPath $shimPath -Raw -ErrorAction SilentlyContinue + if ($existingShim -notmatch 'Qwen Code current-session shim') { + continue + } + + Remove-Item -LiteralPath $shimPath -Force -ErrorAction SilentlyContinue + Write-Success "Removed current cmd.exe qwen shim: $shimPath" + } +} + +function Remove-InstallWrapper { + param([string]$InstallDir, [string]$BinDir) + + $wrapperPath = Join-Path $BinDir 'qwen.cmd' + if (-not (Test-Path -LiteralPath $wrapperPath -PathType Leaf)) { + return + } + + $wrapper = Get-Content -LiteralPath $wrapperPath -Raw -ErrorAction SilentlyContinue + $targetCommand = Join-Path (Join-Path $InstallDir 'bin') 'qwen.cmd' + if ( + $wrapper -notmatch [regex]::Escape($targetCommand) -and + $wrapper -notmatch 'Qwen Code current-session shim' + ) { + Write-WarningMessage "$wrapperPath does not point at this standalone install; skipping." + return + } + + Remove-Item -LiteralPath $wrapperPath -Force + Write-Success "Removed $wrapperPath" +} + +function Remove-EmptyDirectory { + param([string]$Directory) + + if ([string]::IsNullOrEmpty($Directory)) { + return + } + + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + return + } + + try { + Remove-Item -LiteralPath $Directory -Force -ErrorAction Stop + } catch { + return + } +} + +function Remove-SourceMarker { + if ([string]::IsNullOrEmpty($env:USERPROFILE)) { + return + } + + $qwenDir = Join-Path $env:USERPROFILE '.qwen' + $sourceJson = Join-Path $qwenDir 'source.json' + + if ($env:QWEN_UNINSTALL_PURGE -ne '1') { + Write-Info "Preserving $qwenDir (set QWEN_UNINSTALL_PURGE=1 to remove source.json)." + return + } + + if (Test-Path -LiteralPath $sourceJson -PathType Leaf) { + Remove-Item -LiteralPath $sourceJson -Force + Write-Success "Removed $sourceJson" + } + + Remove-EmptyDirectory -Directory $qwenDir +} + +Write-Output "Qwen Code Standalone Uninstaller" +Write-Output "" + +$installBase = Get-QwenInstallBase +$installDir = Get-QwenInstallDir +$installBinDir = Get-QwenInstallBinDir +$installWasManaged = Test-QwenStandaloneInstallDir -InstallDir $installDir + +if ($installWasManaged) { + Remove-Item -LiteralPath $installDir -Recurse -Force + Write-Success "Removed $installDir" +} elseif (Test-Path -LiteralPath $installDir) { + Write-WarningMessage "$installDir exists but is not a Qwen Code standalone install; skipping." +} else { + Write-Info "No standalone runtime found at $installDir." +} + +if ($installWasManaged) { + Remove-InstallWrapper -InstallDir $installDir -BinDir $installBinDir +} else { + Write-Info "Leaving $(Join-Path $installBinDir 'qwen.cmd') unchanged because no managed standalone runtime was removed." +} + +Remove-UserPathEntry -BinDir $installBinDir +Remove-CurrentCmdPathShim +Remove-SourceMarker +if ([string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + Remove-EmptyDirectory -Directory $installBinDir +} +if ([string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + Remove-EmptyDirectory -Directory $installBase +} + +Write-Success "Qwen Code standalone install removed." diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh new file mode 100755 index 00000000000..e615c2a295b --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +# Qwen Code standalone uninstaller. +# Removes files owned by install-qwen-standalone.sh and preserves user config. + +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_UNINSTALL_REEXEC:-}" ]; then + if command -v bash >/dev/null 2>&1; then + if [ -f "${0}" ]; then + export __QWEN_UNINSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the uninstaller with: curl ... | bash" + exit 1 + fi + + echo "Error: This script requires bash. Please install bash first." + exit 1 +fi + +set -eo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" +} + +log_success() { + printf '%bSUCCESS:%b %s\n' "${GREEN}" "${NC}" "$1" +} + +log_warning() { + printf '%bWARNING:%b %s\n' "${YELLOW}" "${NC}" "$1" +} + +log_error() { + printf '%bERROR:%b %s\n' "${RED}" "${NC}" "$1" >&2 +} + +print_usage() { + cat </dev/null || return 1 + grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 + [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 + [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 +} + +remove_install_wrapper() { + local wrapper_path="${INSTALL_BIN_DIR}/qwen" + local qwen_bin="${INSTALL_LIB_DIR}/bin/qwen" + + if [[ ! -e "${wrapper_path}" ]]; then + return 0 + fi + + if [[ ! -f "${wrapper_path}" || -L "${wrapper_path}" ]]; then + log_warning "${wrapper_path} exists but is not an install-owned wrapper; skipping." + return 0 + fi + + if ! grep -qF "${qwen_bin}" "${wrapper_path}" 2>/dev/null; then + log_warning "${wrapper_path} does not point at this standalone install; skipping." + return 0 + fi + + rm -f "${wrapper_path}" + log_success "Removed ${wrapper_path}" +} + +remove_shell_path_entry() { + local marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local rc_files=() + local rc_file + + [[ -n "${HOME:-}" ]] || return 0 + rc_files+=("${HOME}/.zshrc") + rc_files+=("${HOME}/.bashrc") + rc_files+=("${HOME}/.bash_profile") + rc_files+=("${HOME}/.profile") + rc_files+=("${HOME}/.config/fish/config.fish") + + for rc_file in "${rc_files[@]}"; do + [[ -f "${rc_file}" ]] || continue + grep -qF "${marker}" "${rc_file}" 2>/dev/null || continue + + local temp_file + temp_file=$(mktemp "${rc_file}.qwen-uninstall.XXXXXX") || { + log_warning "Could not create temp file for ${rc_file}; leaving PATH entry unchanged." + continue + } + + awk -v marker="${marker}" ' + $0 == marker { skip_next = 1; next } + skip_next == 1 { skip_next = 0; next } + { print } + ' "${rc_file}" > "${temp_file}" && mv "${temp_file}" "${rc_file}" || { + rm -f "${temp_file}" + log_warning "Could not remove Qwen Code PATH entry from ${rc_file}." + continue + } + + log_success "Removed Qwen Code PATH entry from ${rc_file}" + done +} + +remove_empty_dir() { + local dir="$1" + + [[ -d "${dir}" ]] || return 0 + rmdir "${dir}" 2>/dev/null || true +} + +remove_source_marker() { + local source_json="${HOME:-}/.qwen/source.json" + + if [[ "${PURGE}" != "1" ]]; then + log_info "Preserving ${HOME:-~}/.qwen (set QWEN_UNINSTALL_PURGE=1 to remove source.json)." + return 0 + fi + + [[ -n "${HOME:-}" ]] || return 0 + if [[ -f "${source_json}" ]]; then + rm -f "${source_json}" + log_success "Removed ${source_json}" + fi + remove_empty_dir "${HOME}/.qwen" +} + +validate_options + +echo "Qwen Code Standalone Uninstaller" +echo "" + +install_was_managed=0 +if is_qwen_standalone_install_dir "${INSTALL_LIB_DIR}"; then + install_was_managed=1 + rm -rf "${INSTALL_LIB_DIR}" + log_success "Removed ${INSTALL_LIB_DIR}" +elif [[ -e "${INSTALL_LIB_DIR}" ]]; then + log_warning "${INSTALL_LIB_DIR} exists but is not a Qwen Code standalone install; skipping." +else + log_info "No standalone runtime found at ${INSTALL_LIB_DIR}." +fi + +if [[ "${install_was_managed}" == "1" ]]; then + remove_install_wrapper +else + log_info "Leaving ${INSTALL_BIN_DIR}/qwen unchanged because no managed standalone runtime was removed." +fi + +remove_shell_path_entry +remove_source_marker + +log_success "Qwen Code standalone install removed." diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index df2b4605e30..e10f6a28f24 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -647,6 +647,8 @@ describe('standalone release packaging', () => { const installSh = path.join(tmpDir, 'install-qwen-standalone.sh'); const installBat = path.join(tmpDir, 'install-qwen-standalone.bat'); const installPs1 = path.join(tmpDir, 'install-qwen-standalone.ps1'); + const uninstallSh = path.join(tmpDir, 'uninstall-qwen-standalone.sh'); + const uninstallPs1 = path.join(tmpDir, 'uninstall-qwen-standalone.ps1'); const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); const checksumLines = checksums.trim().split('\n'); @@ -654,6 +656,8 @@ describe('standalone release packaging', () => { 'install-qwen-standalone.sh', 'install-qwen-standalone.bat', 'install-qwen-standalone.ps1', + 'uninstall-qwen-standalone.sh', + 'uninstall-qwen-standalone.ps1', ]); expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( HOSTED_INSTALLATION_ASSET_NAMES, @@ -670,11 +674,19 @@ describe('standalone release packaging', () => { expect(readScript(installPs1)).toBe( readScript('scripts/installation/install-qwen-standalone.ps1'), ); + expect(readScript(uninstallSh)).toBe( + readScript('scripts/installation/uninstall-qwen-standalone.sh'), + ); + expect(readScript(uninstallPs1)).toBe( + readScript('scripts/installation/uninstall-qwen-standalone.ps1'), + ); expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ 'install-qwen-standalone.bat', 'install-qwen-standalone.ps1', 'install-qwen-standalone.sh', + 'uninstall-qwen-standalone.ps1', + 'uninstall-qwen-standalone.sh', ]); expect(checksums).toMatch( /^[0-9a-f]{64} {2}install-qwen-standalone\.sh$/m, @@ -685,8 +697,15 @@ describe('standalone release packaging', () => { expect(checksums).toMatch( /^[0-9a-f]{64} {2}install-qwen-standalone\.ps1$/m, ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}uninstall-qwen-standalone\.sh$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}uninstall-qwen-standalone\.ps1$/m, + ); if (process.platform !== 'win32') { expect(lstatSync(installSh).mode & 0o111).not.toBe(0); + expect(lstatSync(uninstallSh).mode & 0o111).not.toBe(0); } writeFileSync(installSh, 'tampered'); @@ -724,6 +743,14 @@ describe('standalone release packaging', () => { path.join(sourceDir, 'install-qwen-standalone.ps1'), "# --version vX.Y.Z\n$env:QWEN_INSTALL_VERSION = 'latest'\n", ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\nis_qwen_standalone_install_dir() { return 0; }\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n', + ); await expect( buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), @@ -760,6 +787,14 @@ describe('standalone release packaging', () => { path.join(sourceDir, 'install-qwen-standalone.ps1'), '& $qwenInstallerPath @args\n# QWEN_INSTALL_VERSION\n', ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\nis_qwen_standalone_install_dir() { return 0; }\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n', + ); await expect( buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), @@ -1252,6 +1287,8 @@ describe('standalone release packaging', () => { expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); expect(workflow).toContain('installation/install-qwen-standalone.ps1'); + expect(workflow).toContain('installation/uninstall-qwen-standalone.sh'); + expect(workflow).toContain('installation/uninstall-qwen-standalone.ps1'); expect(workflow).toContain('--acl public-read'); expect(workflow).toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', @@ -1273,6 +1310,8 @@ describe('standalone release packaging', () => { expect(guide).toContain('installation/install-qwen-standalone.sh'); expect(guide).toContain('installation/install-qwen-standalone.bat'); expect(guide).toContain('installation/install-qwen-standalone.ps1'); + expect(guide).toContain('installation/uninstall-qwen-standalone.sh'); + expect(guide).toContain('installation/uninstall-qwen-standalone.ps1'); expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_ID'); expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(guide).toContain('ALIYUN_OSS_BUCKET'); @@ -1281,6 +1320,32 @@ describe('standalone release packaging', () => { expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); + + it('provides standalone uninstall scripts that clean install-owned files only', () => { + const uninstallShellSource = readScript( + 'scripts/installation/uninstall-qwen-standalone.sh', + ); + const uninstallPowerShellSource = readScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ); + + expect(uninstallShellSource).toContain('is_qwen_standalone_install_dir'); + expect(uninstallShellSource).toContain('remove_shell_path_entry'); + expect(uninstallShellSource).toContain('QWEN_UNINSTALL_PURGE'); + expect(uninstallShellSource).toContain('Preserving'); + expect(uninstallShellSource).toContain('source.json'); + + expect(uninstallPowerShellSource).toContain( + 'Test-QwenStandaloneInstallDir', + ); + expect(uninstallPowerShellSource).toContain('Remove-UserPathEntry'); + expect(uninstallPowerShellSource).toContain('Remove-CurrentCmdPathShim'); + expect(uninstallPowerShellSource).toContain( + 'Qwen Code current-session shim', + ); + expect(uninstallPowerShellSource).toContain('QWEN_UNINSTALL_PURGE'); + expect(uninstallPowerShellSource).toContain('Preserving'); + }); }); describe('Linux/macOS installer end-to-end', () => { @@ -1321,6 +1386,53 @@ describe('Linux/macOS installer end-to-end', () => { }, ); + itOnUnix('uninstalls standalone files while preserving user config', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Added by qwen-code installer (multi-qwen shadow fix)', + `export PATH='${installRoot}/bin':$PATH`, + 'after', + ].join('\n') + '\n', + ); + const qwenDir = path.join(home, '.qwen'); + const sourceJson = path.join(qwenDir, 'source.json'); + const settingsJson = path.join(qwenDir, 'settings.json'); + writeFileSync(settingsJson, '{"theme":"dark"}\n'); + + runUnixUninstaller(installRoot, home); + + expect(existsSync(path.join(installRoot, 'lib', 'qwen-code'))).toBe( + false, + ); + expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(false); + expect(readScript(rcFile)).toBe('before\nafter\n'); + expect(existsSync(sourceJson)).toBe(true); + expect(existsSync(settingsJson)).toBe(true); + + runUnixUninstaller(installRoot, home, { QWEN_UNINSTALL_PURGE: '1' }); + + expect(existsSync(sourceJson)).toBe(false); + expect(existsSync(settingsJson)).toBe(true); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }); + itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -2116,6 +2228,34 @@ function runUnixInstaller( } } +function runUnixUninstaller(installRoot, home, extraEnv = {}) { + mkdirSync(home, { recursive: true }); + try { + return execFileSync( + 'bash', + ['scripts/installation/uninstall-qwen-standalone.sh'], + { + env: { + ...process.env, + HOME: home, + QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + function runWindowsInstaller( archive, installRoot, From d2ebed9ac176273d5070976d0420f3f5d3c03f94 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 17:44:17 +0800 Subject: [PATCH 086/129] fix(uninstall): match shell-quoted paths when removing the wrapper The installer's write_unix_wrapper shell-quotes the binary path, so paths containing single quotes (or other shell metacharacters) appear as shell-quoted strings in the generated wrapper file. The uninstall script's literal grep -qF missed these, leaving the wrapper orphaned. Add shell_quote to the uninstall script and match against both the raw and shell-quoted forms before removing the wrapper. --- scripts/installation/uninstall-qwen-standalone.sh | 12 +++++++++++- scripts/tests/install-script.test.js | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh index e615c2a295b..114838e48bb 100755 --- a/scripts/installation/uninstall-qwen-standalone.sh +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -138,6 +138,10 @@ is_qwen_standalone_install_dir() { [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 } +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + remove_install_wrapper() { local wrapper_path="${INSTALL_BIN_DIR}/qwen" local qwen_bin="${INSTALL_LIB_DIR}/bin/qwen" @@ -151,7 +155,13 @@ remove_install_wrapper() { return 0 fi - if ! grep -qF "${qwen_bin}" "${wrapper_path}" 2>/dev/null; then + # The installer writes the path through shell_quote, so the wrapper may + # contain the raw path (no special chars) or the single-quoted form + # (paths with spaces, quotes, or other shell metacharacters). + local quoted_qwen_bin + quoted_qwen_bin=$(shell_quote "${qwen_bin}") + if ! grep -qF "${qwen_bin}" "${wrapper_path}" 2>/dev/null && + ! grep -qF "${quoted_qwen_bin}" "${wrapper_path}" 2>/dev/null; then log_warning "${wrapper_path} does not point at this standalone install; skipping." return 0 fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index e10f6a28f24..33a172a2124 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1331,6 +1331,8 @@ describe('standalone release packaging', () => { expect(uninstallShellSource).toContain('is_qwen_standalone_install_dir'); expect(uninstallShellSource).toContain('remove_shell_path_entry'); + expect(uninstallShellSource).toContain('shell_quote'); + expect(uninstallShellSource).toContain('quoted_qwen_bin'); expect(uninstallShellSource).toContain('QWEN_UNINSTALL_PURGE'); expect(uninstallShellSource).toContain('Preserving'); expect(uninstallShellSource).toContain('source.json'); From db0a44d620a20378acf2c97f9cfe4dd0abe64e20 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 20:22:27 +0800 Subject: [PATCH 087/129] fix(installer): update download commands to use progress indicators for curl and wget --- scripts/installation/install-qwen-standalone.sh | 8 ++++++-- scripts/tests/install-script.test.js | 13 ++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index baf2197a329..0e1a5ea6832 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -619,12 +619,16 @@ download_file() { local destination="$2" if command_exists curl; then - curl -fsSL --retry 2 "${url}" -o "${destination}" + curl -fL --retry 2 --progress-bar "${url}" -o "${destination}" return $? fi if command_exists wget; then - wget -q --tries=3 "${url}" -O "${destination}" || return 1 + if wget --help 2>&1 | grep -q -- '--progress'; then + wget --progress=bar:force:noscroll --tries=3 "${url}" -O "${destination}" || return 1 + else + wget --tries=3 "${url}" -O "${destination}" || return 1 + fi return $? fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 33a172a2124..3cbbd04a380 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -121,7 +121,18 @@ describe('installation scripts', () => { expect(script).toContain( 'tar -xzf "${archive_path}" -C "${destination}" || return 1', ); - expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"'); + expect(script).toContain( + 'curl -fL --retry 2 --progress-bar "${url}" -o "${destination}"', + ); + expect(script).toContain( + 'wget --progress=bar:force:noscroll --tries=3 "${url}" -O "${destination}"', + ); + expect(script).not.toContain( + 'curl -fsSL --retry 2 "${url}" -o "${destination}"', + ); + expect(script).not.toContain( + 'wget -q --tries=3 "${url}" -O "${destination}"', + ); expect(script).toContain('TEMP_DIRS+='); expect(script).toContain('validate_github_repo()'); expect(script).toContain( From 8fb2bc1254ce5b6edd1057772e90418e9aac6090 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 22:49:07 +0800 Subject: [PATCH 088/129] fix(installer): resolve Aliyun latest via version pointer --- .github/workflows/release.yml | 25 +- scripts/installation/INSTALLATION_GUIDE.md | 17 +- .../installation/install-qwen-standalone.bat | 92 +++- .../installation/install-qwen-standalone.ps1 | 39 +- .../installation/install-qwen-standalone.sh | 119 ++++- .../uninstall-qwen-standalone.ps1 | 54 ++- scripts/release-script-utils.js | 8 +- scripts/tests/install-script.test.js | 445 +++++++++++++++++- scripts/verify-installation-release.js | 2 + 9 files changed, 752 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 583230026ce..9c255b5b23c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -506,10 +506,6 @@ jobs: upload_release_assets "releases/qwen-code/${RELEASE_TAG}" - if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then - upload_release_assets "releases/qwen-code/latest" - fi - upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" @@ -517,6 +513,11 @@ jobs: upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then + printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" + upload_asset "${RUNNER_TEMP}/qwen-code-latest-version" "releases/qwen-code/latest/VERSION" + fi + - name: 'Verify Aliyun OSS Release Assets' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} @@ -531,13 +532,25 @@ jobs: npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then - npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest" + latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" + if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then + echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" + exit 1 + fi fi + hosted_tmp_dir="$(mktemp -d)" + trap 'rm -rf "${hosted_tmp_dir}"' EXIT for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" - curl -fsSIL "${url}" >/dev/null || curl -fsSL --range 0-0 "${url}" >/dev/null + curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" done + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true + exit 1 + } + (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) - name: 'Create PR to merge release branch into main' if: |- diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index f7b20ced1b7..c1f40c6fcb8 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -10,7 +10,8 @@ The installers are intentionally lightweight: - They try a standalone archive first by default. - They do not install Node.js, NVM, or any other Node version manager. -- They do not edit npm config or shell profiles. +- They do not edit npm config. Standalone installs may update the shell profile + or user PATH so the generated `qwen` shim is discoverable. - They do not start `qwen` automatically after installation. - They store source information in `~/.qwen/source.json` or `%USERPROFILE%\.qwen\source.json` when `--source` is provided. @@ -76,8 +77,10 @@ contents byte-for-byte to `installation/uninstall-qwen-standalone.sh`, and `installation/uninstall-qwen-standalone.ps1`; the staging command also writes `SHA256SUMS` for upload verification. The hosted installers intentionally -default to `latest`; use `--version` or `QWEN_INSTALL_VERSION` to pin a -standalone release. +default to `latest`; on Aliyun OSS this means reading +`releases/qwen-code/latest/VERSION` first, then downloading the matching +versioned release directory. Use `--version` or `QWEN_INSTALL_VERSION` to pin a +standalone release directly. Configure the `production-release` GitHub environment with these required secrets before enabling OSS sync: @@ -247,9 +250,11 @@ Use `--base-url` for private mirrors. The URL must contain base URLs must use `https://`. For Aliyun OSS/CDN, release publishing uploads byte-identical artifacts to the -versioned directory, for example `vX.Y.Z/`. Stable releases also update the -`latest/` directory used by the default installer path; nightly and preview -releases do not overwrite `latest/`. +versioned directory, for example `releases/qwen-code/vX.Y.Z/`. Stable releases +also update the small `releases/qwen-code/latest/VERSION` pointer used by the +default installer path. The installer reads that pointer and then downloads the +versioned archive plus the versioned `SHA256SUMS`; nightly and preview releases +do not update the pointer. ## Supported Source Values diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index fdb810c88bb..297c4aac52a 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -481,6 +481,10 @@ rem args: %~1=version_path → sets QWEN_OSS_BASE_URL set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" exit /b 0 +:AliyunLatestVersionUrl +set "QWEN_OSS_LATEST_VERSION_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" +exit /b 0 + :RaceMirrorHead rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url rem Sets QWEN_RACE_RESULT to "aliyun", "github", or "timeout". Sequential @@ -508,8 +512,14 @@ call :ReleaseVersionPath if /i "!MIRROR!"=="auto" ( call :GithubBaseUrlForVersion "!VERSION_PATH!" - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_BASE_URL!/SHA256SUMS" + if /i "!VERSION_PATH!"=="latest" ( + call :AliyunLatestVersionUrl + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_LATEST_VERSION_URL!" + ) else ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_BASE_URL!/SHA256SUMS" + ) + 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. set "MIRROR=github" @@ -519,13 +529,18 @@ if /i "!MIRROR!"=="auto" ( ) set "QWEN_GH_BASE_URL=" set "QWEN_OSS_BASE_URL=" + set "QWEN_OSS_LATEST_VERSION_URL=" + set "QWEN_OSS_PROBE_URL=" set "QWEN_RACE_RESULT=" ) if /i "!MIRROR!"=="aliyun" ( - call :AliyunBaseUrlForVersion "!VERSION_PATH!" + call :ResolveAliyunVersionPath "!VERSION_PATH!" + if !ERRORLEVEL! NEQ 0 exit /b 1 + call :AliyunBaseUrlForVersion "!RESOLVED_VERSION_PATH!" set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" set "QWEN_OSS_BASE_URL=" + set "RESOLVED_VERSION_PATH=" exit /b 0 ) @@ -567,6 +582,51 @@ set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" exit /b %PS_STATUS% +:DownloadFileQuiet +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; if (Get-Command curl.exe -ErrorAction SilentlyContinue) { curl.exe -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:ResolveAliyunVersionPath +set "RESOLVED_VERSION_PATH=" +if /i not "%~1"=="latest" ( + set "RESOLVED_VERSION_PATH=%~1" + exit /b 0 +) + +call :AliyunLatestVersionUrl +call :CreateTempFile "qwen-code-latest-version" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "TEMP_VERSION_FILE=!TEMP_FILE!" + +call :DownloadFileQuiet "!QWEN_OSS_LATEST_VERSION_URL!" "!TEMP_VERSION_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 + set "TEMP_VERSION_FILE=" + set "QWEN_OSS_LATEST_VERSION_URL=" + echo WARNING: Failed to resolve Aliyun latest VERSION pointer. + exit /b 1 +) + +set "QWEN_VERSION_POINTER_FILE=!TEMP_VERSION_FILE!" +for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = (Get-Content -LiteralPath $env:QWEN_VERSION_POINTER_FILE -Raw).Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" +set "QWEN_VERSION_POINTER_FILE=" +if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 +set "TEMP_VERSION_FILE=" +set "QWEN_OSS_LATEST_VERSION_URL=" + +if "!RESOLVED_VERSION_PATH!"=="" ( + echo ERROR: Aliyun latest VERSION pointer is not a valid semver value. + exit /b 1 +) + +echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. +exit /b 0 + :VerifyChecksum set "ARCHIVE_FILE=%~1" set "CHECKSUM_SOURCE=%~2" @@ -666,6 +726,10 @@ if not "!ARCHIVE_PATH!"=="" ( set "ARCHIVE_NAME=qwen-code-win-x64.zip" call :StandaloneBaseUrl + if !ERRORLEVEL! NEQ 0 ( + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" @@ -1008,6 +1072,14 @@ for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" echo SUCCESS: npm %NPM_VERSION% detected. exit /b 0 +:NpmPackageSpec +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@latest" +if /i "!VERSION!"=="latest" exit /b 0 +set "NPM_VERSION_SPEC=!VERSION!" +if /i "!NPM_VERSION_SPEC:~0,1!"=="v" set "NPM_VERSION_SPEC=!NPM_VERSION_SPEC:~1!" +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@!NPM_VERSION_SPEC!" +exit /b 0 + :InstallNpm call :RequireNode if %ERRORLEVEL% NEQ 0 exit /b 1 @@ -1015,21 +1087,27 @@ if %ERRORLEVEL% NEQ 0 exit /b 1 call :RequireNpm 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! - echo INFO: Upgrading to the latest 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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! -call npm install -g @qwen-code/qwen-code@latest --registry "!NPM_REGISTRY!" +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 @qwen-code/qwen-code@latest --registry !NPM_REGISTRY! + echo npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! exit /b 1 ) diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index b75d4df350a..80074378fe2 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -42,20 +42,52 @@ function Download-File { } } +function Get-QwenInstallBase { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + return $env:QWEN_INSTALL_ROOT + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + return Join-Path $env:LOCALAPPDATA 'qwen-code' + } + + return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local') 'qwen-code' +} + function Get-QwenInstallBinDir { if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { return $env:QWEN_INSTALL_BIN_DIR } if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { - return Join-Path $env:QWEN_INSTALL_ROOT 'bin' + return Join-Path (Get-QwenInstallBase) 'bin' } if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { - return Join-Path (Join-Path $env:LOCALAPPDATA 'qwen-code') 'bin' + return Join-Path (Get-QwenInstallBase) 'bin' } - return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local\qwen-code') 'bin' + return Join-Path (Get-QwenInstallBase) 'bin' +} + +function Get-CurrentCmdShimStatePath { + return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt' +} + +function Save-CurrentCmdPathShim { + param([string]$ShimPath) + + if ([string]::IsNullOrEmpty($ShimPath)) { + return + } + + try { + $statePath = Get-CurrentCmdShimStatePath + New-Item -ItemType Directory -Path (Split-Path -Parent $statePath) -Force | Out-Null + [IO.File]::WriteAllText($statePath, $ShimPath, [Text.UTF8Encoding]::new($false)) + } catch { + # Best-effort cleanup hint only. The installer still works if this fails. + } } function Update-CurrentSessionPath { @@ -225,6 +257,7 @@ function Install-CurrentCmdPathShim { $shim = "@echo off`r`nREM Qwen Code current-session shim. Generated by install-qwen-standalone.ps1.`r`ncall `"$QwenCommand`" %*`r`n" [IO.File]::WriteAllText($shimPath, $shim, [Text.UTF8Encoding]::new($false)) + Save-CurrentCmdPathShim -ShimPath $shimPath return $shimPath } diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 0e1a5ea6832..99c79a99cc7 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -514,8 +514,8 @@ maybe_update_shell_path() { export_line="export PATH=${quoted_install_bin_dir}:\$PATH" fi - if [[ -f "${rc_file}" ]] && grep -qF "${marker}" "${rc_file}" 2>/dev/null; then - log_info "PATH update already present in ${rc_file} (skipping)." + 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 fi @@ -548,6 +548,80 @@ aliyun_base_url_for_version() { echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" } +aliyun_latest_version_url() { + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" +} + +normalize_version_path_value() { + local raw_version="$1" + local version_path + + raw_version=$(printf '%s' "${raw_version}" | tr -d '\r' | awk 'NF { print $1; exit }') + if [[ -z "${raw_version}" ]]; then + return 1 + fi + + case "${raw_version}" in + v*) + version_path="${raw_version}" + ;; + *) + version_path="v${raw_version}" + ;; + esac + + if [[ "${version_path}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$ ]]; then + echo "${version_path}" + return 0 + fi + + return 1 +} + +download_text() { + local url="$1" + + if command_exists curl; then + curl -fsSL --retry 2 "${url}" + return $? + fi + + if command_exists wget; then + wget -q --tries=3 -O - "${url}" + return $? + fi + + log_error "curl or wget is required to resolve the standalone release version." + return 1 +} + +resolve_aliyun_version_path() { + local version_path="$1" + + if [[ "${version_path}" != "latest" ]]; then + echo "${version_path}" + return 0 + fi + + local latest_url + latest_url=$(aliyun_latest_version_url) + + local latest_version + if ! latest_version=$(download_text "${latest_url}"); then + log_warning "Failed to resolve Aliyun latest VERSION pointer." >&2 + return 1 + fi + + local resolved_version_path + if ! resolved_version_path=$(normalize_version_path_value "${latest_version}"); then + log_error "Aliyun latest VERSION pointer is not a valid semver value." + return 1 + fi + + log_info "Resolved Aliyun latest to ${resolved_version_path}." >&2 + echo "${resolved_version_path}" +} + # Race two HEAD probes; print "aliyun" or "github" based on which mirror's # SHA256SUMS responds first, or "timeout" if neither responds before the # deadline. Caller decides what to do with "timeout" (currently: log it and @@ -595,7 +669,11 @@ standalone_base_url() { if [[ "${MIRROR}" == "auto" ]]; then local gh_head oss_head selected gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" - oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" + if [[ "${version_path}" == "latest" ]]; then + oss_head="$(aliyun_latest_version_url)" + else + oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" + 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 @@ -607,6 +685,9 @@ standalone_base_url() { fi if [[ "${MIRROR}" == "aliyun" ]]; then + if ! version_path=$(resolve_aliyun_version_path "${version_path}"); then + return 1 + fi aliyun_base_url_for_version "${version_path}" return 0 fi @@ -896,7 +977,12 @@ install_standalone() { archive_name="qwen-code-${target}.${archive_extension}" local base_url - base_url=$(standalone_base_url) + if ! base_url=$(standalone_base_url); then + if [[ "${METHOD}" == "detect" ]]; then + return 2 + fi + return 1 + fi local archive_url="${base_url}/${archive_name}" checksum_source="${base_url}/SHA256SUMS" @@ -1004,27 +1090,44 @@ install_standalone() { log_info "Installed to ${INSTALL_LIB_DIR}" } +npm_package_spec() { + if [[ "${VERSION}" == "latest" ]]; then + echo "@qwen-code/qwen-code@latest" + return 0 + fi + + local npm_version="${VERSION#v}" + echo "@qwen-code/qwen-code@${npm_version}" +} + install_npm() { require_node || return 1 require_npm || return 1 + 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}" - log_info "Upgrading to the latest 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 -g - @qwen-code/qwen-code@latest + "${package_spec}" --registry "${NPM_REGISTRY}" ) - log_info "Running: npm install -g @qwen-code/qwen-code@latest --registry ${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 @@ -1036,7 +1139,7 @@ install_npm() { 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 @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}" + echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}" return 1 } diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 index 215c367bbde..4576ec461e7 100644 --- a/scripts/installation/uninstall-qwen-standalone.ps1 +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -54,6 +54,10 @@ function Get-QwenInstallBinDir { return Join-Path (Get-QwenInstallBase) 'bin' } +function Get-CurrentCmdShimStatePath { + return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt' +} + function Get-NormalizedPath { param([string]$PathValue) @@ -188,7 +192,42 @@ function Add-PathCandidate { [void]$Candidates.Add($Directory.Trim().Trim('"')) } +function Remove-CurrentCmdPathShimFile { + param([string]$ShimPath) + + if ([string]::IsNullOrEmpty($ShimPath)) { + return + } + + if (-not (Test-Path -LiteralPath $ShimPath -PathType Leaf)) { + return + } + + $existingShim = Get-Content -LiteralPath $ShimPath -Raw -ErrorAction SilentlyContinue + if ($existingShim -notmatch 'Qwen Code current-session shim') { + return + } + + Remove-Item -LiteralPath $ShimPath -Force -ErrorAction SilentlyContinue + Write-Success "Removed current cmd.exe qwen shim: $ShimPath" +} + +function Remove-RecordedCurrentCmdPathShim { + $statePath = Get-CurrentCmdShimStatePath + if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { + return + } + + foreach ($shimPath in Get-Content -LiteralPath $statePath -ErrorAction SilentlyContinue) { + Remove-CurrentCmdPathShimFile -ShimPath $shimPath + } + + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue +} + function Remove-CurrentCmdPathShim { + Remove-RecordedCurrentCmdPathShim + $candidates = [System.Collections.Generic.List[string]]::new() foreach ($entry in @($env:Path -split ';')) { if (-not [string]::IsNullOrEmpty($entry)) { @@ -208,17 +247,7 @@ function Remove-CurrentCmdPathShim { foreach ($candidate in $candidates) { $shimPath = Join-Path $candidate 'qwen.cmd' - if (-not (Test-Path -LiteralPath $shimPath -PathType Leaf)) { - continue - } - - $existingShim = Get-Content -LiteralPath $shimPath -Raw -ErrorAction SilentlyContinue - if ($existingShim -notmatch 'Qwen Code current-session shim') { - continue - } - - Remove-Item -LiteralPath $shimPath -Force -ErrorAction SilentlyContinue - Write-Success "Removed current cmd.exe qwen shim: $shimPath" + Remove-CurrentCmdPathShimFile -ShimPath $shimPath } } @@ -291,6 +320,8 @@ $installDir = Get-QwenInstallDir $installBinDir = Get-QwenInstallBinDir $installWasManaged = Test-QwenStandaloneInstallDir -InstallDir $installDir +Remove-CurrentCmdPathShim + if ($installWasManaged) { Remove-Item -LiteralPath $installDir -Recurse -Force Write-Success "Removed $installDir" @@ -307,7 +338,6 @@ if ($installWasManaged) { } Remove-UserPathEntry -BinDir $installBinDir -Remove-CurrentCmdPathShim Remove-SourceMarker if ([string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { Remove-EmptyDirectory -Directory $installBinDir diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index 3e09751a6be..9580ab3f0ff 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -42,6 +42,9 @@ export function parseSha256Sums(content) { if (!match) { fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); } + if (checksums.has(match[2])) { + fail(`Duplicate SHA256SUMS entry for: ${match[2]}`); + } checksums.set(match[2], match[1].toLowerCase()); } return checksums; @@ -86,6 +89,9 @@ export function parseArgs(argv, definitions) { if (eqIndex >= 0) { const key = raw.slice(0, eqIndex); const value = raw.slice(eqIndex + 1); + if (key === '--help' || key === '-h') { + fail(`${key} does not accept a value`); + } const def = definitions[key]; if (!def) { fail(`Unknown option: ${key}`); @@ -93,7 +99,7 @@ export function parseArgs(argv, definitions) { if (def.type === 'flag') { fail(`${key} does not accept a value`); } - if (!value) { + if (!value || value.startsWith('-')) { fail(`${key} requires a value`); } args[def.key] = value; diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 3cbbd04a380..833c22d397e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -66,9 +66,8 @@ describe('installation scripts', () => { '--source may only contain letters, numbers, dot, underscore, or dash', ); expect(script).toContain('Node.js 22 or newer is required'); - expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', - ); + expect(script).toContain('npm_package_spec()'); + expect(script).toContain('@qwen-code/qwen-code@latest'); expect(script).toContain('Run: qwen'); }); @@ -141,6 +140,8 @@ describe('installation scripts', () => { expect(script).toContain('set -gx PATH ${quoted_install_bin_dir} \\$PATH'); expect(script).toContain('export PATH=${quoted_install_bin_dir}:\\$PATH'); expect(script).toContain('curl -fsIL -m "${timeout}"'); + expect(script).toContain('/latest/VERSION'); + expect(script).toContain('resolve_aliyun_version_path()'); expect(script).not.toContain('-print -quit'); }); @@ -165,9 +166,8 @@ describe('installation scripts', () => { ); expect(script).toContain('Node.js 22 or newer is required'); expect(script).toContain('Please install Node.js'); - expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', - ); + expect(script).toContain(':NpmPackageSpec'); + expect(script).toContain('@qwen-code/qwen-code@latest'); expect(script).toContain('Run: qwen'); }); @@ -247,6 +247,8 @@ describe('installation scripts', () => { expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); expect(script).toContain(':CreateTempFile'); + expect(script).toContain('/latest/VERSION'); + expect(script).toContain(':ResolveAliyunVersionPath'); expect(script).not.toContain('%RANDOM%'); }); @@ -302,6 +304,18 @@ describe('release-script-utils', () => { ).toThrow(/Malformed SHA256SUMS line 1/); }); + it('rejects duplicate SHA256SUMS entries', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + const first = 'a'.repeat(64); + const second = 'b'.repeat(64); + + expect(() => + parseSha256Sums( + `${first} install-qwen-standalone.sh\n${second} install-qwen-standalone.sh\n`, + ), + ).toThrow(/Duplicate SHA256SUMS entry for: install-qwen-standalone\.sh/); + }); + it('supports --key=value form in parseArgs', async () => { const { parseArgs } = await import(releaseScriptUtilsUrl); const defs = { @@ -339,6 +353,9 @@ describe('release-script-utils', () => { expect(() => parseArgs(['--out-dir', '--help'], defs)).toThrow( /--out-dir requires a value/, ); + expect(() => parseArgs(['--out-dir=-tmp'], defs)).toThrow( + /--out-dir requires a value/, + ); }); it('rejects --key=value for flag-type options', async () => { @@ -355,6 +372,9 @@ describe('release-script-utils', () => { expect(parseArgs(['--help'], {}).help).toBe(true); expect(parseArgs(['-h'], {}).help).toBe(true); + expect(() => parseArgs(['--help=anything'], {})).toThrow( + /--help does not accept a value/, + ); }); it('fail() wraps messages with ERROR: prefix', async () => { @@ -624,6 +644,8 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('Update-CurrentSessionPath'); expect(installPowerShellSource).toContain('Install-CurrentCmdPathShim'); + expect(installPowerShellSource).toContain('Save-CurrentCmdPathShim'); + expect(installPowerShellSource).toContain('current-cmd-shim.txt'); expect(installPowerShellSource).toContain('Test-WritableDirectory'); expect(installPowerShellSource).toContain('Qwen Code current-session shim'); expect(installPowerShellSource).not.toContain('doskey.exe'); @@ -1295,6 +1317,12 @@ describe('standalone release packaging', () => { expect(workflow).toContain('vars.ALIYUN_OSS_ENDPOINT'); expect(workflow).toContain('releases/qwen-code/${RELEASE_TAG}'); expect(workflow).toContain('releases/qwen-code/latest'); + expect(workflow).not.toContain( + 'upload_release_assets "releases/qwen-code/latest"', + ); + expect(workflow).toContain( + 'upload_asset "${RUNNER_TEMP}/qwen-code-latest-version" "releases/qwen-code/latest/VERSION"', + ); expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); expect(workflow).toContain('installation/install-qwen-standalone.ps1'); @@ -1304,6 +1332,19 @@ describe('standalone release packaging', () => { expect(workflow).toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', ); + expect(workflow).toContain( + 'latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d', + ); + expect(workflow).not.toContain( + 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', + ); + expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); + expect(workflow).toContain( + 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS"', + ); + expect(workflow).toContain( + '(cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS)', + ); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -1353,6 +1394,10 @@ describe('standalone release packaging', () => { ); expect(uninstallPowerShellSource).toContain('Remove-UserPathEntry'); expect(uninstallPowerShellSource).toContain('Remove-CurrentCmdPathShim'); + expect(uninstallPowerShellSource).toContain( + 'Remove-RecordedCurrentCmdPathShim', + ); + expect(uninstallPowerShellSource).toContain('current-cmd-shim.txt'); expect(uninstallPowerShellSource).toContain( 'Qwen Code current-session shim', ); @@ -1399,6 +1444,117 @@ describe('Linux/macOS installer end-to-end', () => { }, ); + itOnUnix( + 'resolves Aliyun latest through a single VERSION pointer before downloading archives', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s\\n" "$url" >> "$QWEN_FAKE_CURL_LOG"', + 'case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' if [ -n "$dest" ]; then', + ' printf "v0.0.0-smoke\\n" > "$dest"', + ' else', + ' printf "v0.0.0-smoke\\n"', + ' fi ;;', + ' */releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest" ;;', + ' */releases/qwen-code/v0.0.0-smoke/SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest" ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(path.join(fakeBin, 'uname'), 0o755); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'standalone', + '--mirror', + 'aliyun', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/SHA256SUMS', + ); + expect(curlUrls).not.toContain( + '/releases/qwen-code/latest/qwen-code-linux-x64.tar.gz', + ); + expect(output).toContain( + 'Downloading https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + 15000, + ); + itOnUnix('uninstalls standalone files while preserving user config', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); @@ -1529,6 +1685,41 @@ describe('Linux/macOS installer end-to-end', () => { 15000, ); + itOnUnix( + 'adds a new shell rc PATH entry when reinstalling with a different bin dir', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const firstBinDir = path.join(installRoot, 'bin-one'); + const secondBinDir = path.join(installRoot, 'bin-two'); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: firstBinDir, + }); + runUnixInstaller(archive, installRoot, home, 'standalone', { + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: secondBinDir, + }); + + const bashrc = readScript(path.join(home, '.bashrc')); + expect(bashrc).toContain(`export PATH='${firstBinDir}':$PATH`); + expect(bashrc).toContain(`export PATH='${secondBinDir}':$PATH`); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + 15000, + ); + itOnUnix('rejects a tampered local archive', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1769,6 +1960,80 @@ describe('Linux/macOS installer end-to-end', () => { }, ); + itOnUnix('passes pinned versions through to npm fallback', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const home = path.join(tmpDir, 'home'); + const npmLog = path.join(tmpDir, 'npm-args.txt'); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + + writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n'); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then', + ' case "$2" in', + ' *split*) echo 22 ;;', + ' *) echo 22.0.0 ;;', + ' esac', + ' exit 0', + 'fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -v) echo 10.0.0 ;;', + ' prefix) echo "$QWEN_FAKE_NPM_PREFIX" ;;', + ' install) printf "%s\\n" "$*" > "$QWEN_FAKE_NPM_LOG" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + for (const command of ['curl', 'node', 'npm']) { + chmodSync(path.join(fakeBin, command), 0o755); + } + + execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--version', + 'v0.15.10', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + }, + stdio: 'pipe', + }, + ); + + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@0.15.10 --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnUnix('preserves context when npm fallback also fails', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -1896,6 +2161,66 @@ describe('Windows installer end-to-end', () => { } }); + itOnWindows( + 'resolves Aliyun latest through a single VERSION pointer before downloading archives', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + createFakeWindowsCurlExe(fakeBin); + + const output = runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'standalone', + '--mirror', + 'aliyun', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'AMD64', + PROCESSOR_ARCHITEW6432: '', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip', + ); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/SHA256SUMS', + ); + expect(curlUrls).not.toContain( + '/releases/qwen-code/latest/qwen-code-win-x64.zip', + ); + expect(output).toContain( + 'Downloading https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip', + ); + expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe( + true, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + itOnWindows( 'falls back to npm in detect mode when archive is unavailable', () => { @@ -1935,6 +2260,43 @@ describe('Windows installer end-to-end', () => { }, ); + itOnWindows('passes pinned versions through to npm fallback', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const npmLog = path.join(tmpDir, 'npm-install.log'); + createFakeWindowsNpmTools(fakeBin); + + runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + '--version', + 'v0.15.10', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ); + + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@0.15.10 --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnWindows('preserves context when npm fallback also fails', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -2112,6 +2474,77 @@ function createFakeWindowsNpmTools(fakeBin) { ); } +function createFakeWindowsCurlExe(fakeBin) { + mkdirSync(fakeBin, { recursive: true }); + const sourcePath = path.join(fakeBin, 'FakeCurl.cs'); + const outputPath = path.join(fakeBin, 'curl.exe'); + writeFileSync( + sourcePath, + String.raw` +using System; +using System.IO; + +public static class FakeCurl { + public static int Main(string[] args) { + string destination = null; + string url = null; + + for (int index = 0; index < args.Length; index++) { + string arg = args[index]; + if (arg.StartsWith("-") && arg.IndexOf('o') >= 0 && index + 1 < args.Length) { + destination = args[++index]; + continue; + } + if (arg.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { + url = arg; + } + } + + File.AppendAllText(Environment.GetEnvironmentVariable("QWEN_FAKE_CURL_LOG"), url + Environment.NewLine); + if (url == null || destination == null) { + Console.Error.WriteLine("missing url or destination"); + return 2; + } + + if (url.EndsWith("/releases/qwen-code/latest/VERSION", StringComparison.OrdinalIgnoreCase)) { + File.WriteAllText(destination, "v0.0.0-smoke" + Environment.NewLine); + return 0; + } + if (url.EndsWith("/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip", StringComparison.OrdinalIgnoreCase)) { + File.Copy(Environment.GetEnvironmentVariable("QWEN_FAKE_ARCHIVE"), destination, true); + return 0; + } + if (url.EndsWith("/releases/qwen-code/v0.0.0-smoke/SHA256SUMS", StringComparison.OrdinalIgnoreCase)) { + File.Copy(Environment.GetEnvironmentVariable("QWEN_FAKE_SHA256SUMS"), destination, true); + return 0; + } + + Console.Error.WriteLine("unexpected url: " + url); + return 22; + } +} +`, + ); + execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + '$source = Get-Content -LiteralPath $env:QWEN_FAKE_CURL_SOURCE -Raw; Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $env:QWEN_FAKE_CURL_OUTPUT -OutputType ConsoleApplication', + ], + { + env: { + ...process.env, + QWEN_FAKE_CURL_SOURCE: sourcePath, + QWEN_FAKE_CURL_OUTPUT: outputPath, + }, + stdio: 'pipe', + }, + ); +} + function prependWindowsPath(directory) { const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 6aa351449ad..e4303d1ffd7 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -202,6 +202,8 @@ async function assertRemoteAssetAvailable(url, fetchImpl) { } await response.body?.cancel?.(); + // Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET + // so the verifier can confirm reachability without downloading the archive. response = await fetchWithTimeout(fetchImpl, url, { headers: { Range: 'bytes=0-0', From db8677f01ba78abf0dbbeaaf20497bd1cf88ffd4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 14 May 2026 23:09:15 +0800 Subject: [PATCH 089/129] fix(installer): cleanup mirror probe temp dirs --- scripts/installation/install-qwen-standalone.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 99c79a99cc7..bf84e45d468 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -633,6 +633,7 @@ race_mirror_head() { local tmpdir tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" mkdir -p "${tmpdir}" 2>/dev/null || true + register_temp_dir "${tmpdir}" (curl -fsIL -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & local oss_pid=$! From 40ac124927afd3bd10e3c60cd8517e0feebc30b4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 01:54:27 +0800 Subject: [PATCH 090/129] fix(installer): harden standalone release fallback --- .github/workflows/release.yml | 36 +++-- .../installation/install-qwen-standalone.bat | 73 ++++++++- .../installation/install-qwen-standalone.sh | 80 +++++++++- scripts/tests/install-script.test.js | 150 +++++++++++++++++- 4 files changed, 306 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c255b5b23c..6cdeec4ca54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -485,8 +485,6 @@ jobs: ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" - IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' - IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail @@ -513,32 +511,17 @@ jobs: upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" - if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then - printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" - upload_asset "${RUNNER_TEMP}/qwen-code-latest-version" "releases/qwen-code/latest/VERSION" - fi - - name: 'Verify Aliyun OSS Release Assets' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" - IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' - IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" - if [[ "${IS_NIGHTLY}" == "false" && "${IS_PREVIEW}" == "false" ]]; then - latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" - if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then - echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" - exit 1 - fi - fi - hosted_tmp_dir="$(mktemp -d)" trap 'rm -rf "${hosted_tmp_dir}"' EXIT for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do @@ -552,6 +535,25 @@ jobs: } (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + - name: 'Publish Aliyun OSS Latest VERSION' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" + ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read + + latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" + if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then + echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" + exit 1 + fi + - name: 'Create PR to merge release branch into main' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 297c4aac52a..385be3bf055 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -503,12 +503,14 @@ set "QWEN_RACE_OSS_URL=" exit /b 0 :StandaloneBaseUrl +set "STANDALONE_VERSION_PATH=" if not "!BASE_URL!"=="" ( set "STANDALONE_BASE_URL=!BASE_URL!" exit /b 0 ) call :ReleaseVersionPath +set "STANDALONE_VERSION_PATH=!VERSION_PATH!" if /i "!MIRROR!"=="auto" ( call :GithubBaseUrlForVersion "!VERSION_PATH!" @@ -537,6 +539,7 @@ if /i "!MIRROR!"=="auto" ( if /i "!MIRROR!"=="aliyun" ( call :ResolveAliyunVersionPath "!VERSION_PATH!" if !ERRORLEVEL! NEQ 0 exit /b 1 + set "STANDALONE_VERSION_PATH=!RESOLVED_VERSION_PATH!" call :AliyunBaseUrlForVersion "!RESOLVED_VERSION_PATH!" set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" set "QWEN_OSS_BASE_URL=" @@ -549,6 +552,14 @@ set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" set "QWEN_GH_BASE_URL=" exit /b 0 +:UseGithubFallbackBaseUrl +set "STANDALONE_BASE_URL=!GITHUB_FALLBACK_BASE_URL!" +set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" +set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" +set "GITHUB_FALLBACK_BASE_URL=" +set "MIRROR=github" +exit /b 0 + :MaybeUpdateUserPath rem args: %~1=install_bin_dir rem Prepend the install dir to the user-level PATH (HKCU\Environment) via @@ -725,19 +736,59 @@ if not "!ARCHIVE_PATH!"=="" ( if !ERRORLEVEL! NEQ 0 exit /b 2 set "ARCHIVE_NAME=qwen-code-win-x64.zip" + set "REQUESTED_MIRROR=!MIRROR!" + set "REQUESTED_VERSION_PATH=" + set "GITHUB_FALLBACK_BASE_URL=" + if "!BASE_URL!"=="" if /i "!REQUESTED_MIRROR!"=="auto" ( + call :ReleaseVersionPath + set "REQUESTED_VERSION_PATH=!VERSION_PATH!" + call :GithubBaseUrlForVersion "!VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" + ) + call :StandaloneBaseUrl if !ERRORLEVEL! NEQ 0 ( - if /i "!METHOD!"=="detect" exit /b 2 - exit /b 1 + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!MIRROR!"=="aliyun" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + echo WARNING: Aliyun standalone release metadata unavailable; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) + ) + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!REQUESTED_VERSION_PATH!"=="latest" if /i "!MIRROR!"=="aliyun" if not "!STANDALONE_VERSION_PATH!"=="" ( + call :GithubBaseUrlForVersion "!STANDALONE_VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" ) + if /i "!STANDALONE_BASE_URL!"=="!GITHUB_FALLBACK_BASE_URL!" set "GITHUB_FALLBACK_BASE_URL=" set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" if /i "!METHOD!"=="detect" ( call :UrlExists "!ARCHIVE_URL!" if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive not found: !ARCHIVE_NAME! - exit /b 2 + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + set "GITHUB_ARCHIVE_URL=!GITHUB_FALLBACK_BASE_URL!/!ARCHIVE_NAME!" + call :UrlExists "!GITHUB_ARCHIVE_URL!" + if !ERRORLEVEL! EQU 0 ( + echo WARNING: Aliyun standalone archive not found; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + set "GITHUB_ARCHIVE_URL=" + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + set "GITHUB_ARCHIVE_URL=" + ) else ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) ) ) @@ -747,10 +798,20 @@ if not "!ARCHIVE_PATH!"=="" ( echo INFO: Downloading !ARCHIVE_URL! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" - if !ERRORLEVEL! NEQ 0 ( + set "DOWNLOAD_STATUS=!ERRORLEVEL!" + if not "!DOWNLOAD_STATUS!"=="0" if not "!GITHUB_FALLBACK_BASE_URL!"=="" ( + 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 INFO: Downloading !ARCHIVE_URL! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + set "DOWNLOAD_STATUS=!ERRORLEVEL!" + ) + if not "!DOWNLOAD_STATUS!"=="0" ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 echo WARNING: Failed to download standalone archive. - exit /b 2 + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 ) ) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index bf84e45d468..7c9f188c915 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -977,19 +977,61 @@ install_standalone() { archive_extension=$(archive_extension_for_target "${target}") archive_name="qwen-code-${target}.${archive_extension}" + local requested_mirror="${MIRROR}" + local requested_version_path="" + local github_fallback_base_url="" + if [[ -z "${BASE_URL}" && "${requested_mirror}" == "auto" ]]; then + requested_version_path=$(release_version_path) + github_fallback_base_url="$(github_base_url_for_version "${requested_version_path}")" + fi + local base_url if ! base_url=$(standalone_base_url); then - if [[ "${METHOD}" == "detect" ]]; then - return 2 + if [[ -n "${github_fallback_base_url}" ]]; then + log_warning "Aliyun standalone release metadata unavailable; retrying GitHub mirror." + base_url="${github_fallback_base_url}" + MIRROR="github" + github_fallback_base_url="" + else + if [[ "${METHOD}" == "detect" ]]; then + return 2 + fi + return 1 fi - return 1 fi + if [[ -n "${github_fallback_base_url}" && "${requested_version_path}" == "latest" ]]; then + local aliyun_release_base="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/" + if [[ "${base_url}" == "${aliyun_release_base}"* ]]; then + local resolved_version_path="${base_url#"${aliyun_release_base}"}" + if [[ -n "${resolved_version_path}" && "${resolved_version_path}" != "latest" && "${resolved_version_path}" != */* ]]; then + github_fallback_base_url="$(github_base_url_for_version "${resolved_version_path}")" + fi + fi + fi + if [[ "${base_url}" == "${github_fallback_base_url}" ]]; then + github_fallback_base_url="" + fi + local archive_url="${base_url}/${archive_name}" checksum_source="${base_url}/SHA256SUMS" if [[ "${METHOD}" == "detect" ]] && ! url_exists "${archive_url}"; then - log_warning "Standalone archive not found: ${archive_name}" - return 2 + if [[ -n "${github_fallback_base_url}" ]]; then + local github_archive_url="${github_fallback_base_url}/${archive_name}" + if url_exists "${github_archive_url}"; then + log_warning "Aliyun standalone archive not found; retrying GitHub mirror." + base_url="${github_fallback_base_url}" + archive_url="${github_archive_url}" + checksum_source="${base_url}/SHA256SUMS" + github_fallback_base_url="" + else + log_warning "Standalone archive not found: ${archive_name}" + return 2 + fi + else + log_warning "Standalone archive not found: ${archive_name}" + return 2 + fi fi temp_dir=$(mktemp -d) @@ -998,9 +1040,31 @@ install_standalone() { log_info "Downloading ${archive_url}" if ! download_file "${archive_url}" "${archive_path}"; then - rm -rf "${temp_dir}" - log_warning "Failed to download standalone archive." - return 2 + if [[ -n "${github_fallback_base_url}" ]]; then + rm -f "${archive_path}" + archive_url="${github_fallback_base_url}/${archive_name}" + checksum_source="${github_fallback_base_url}/SHA256SUMS" + github_fallback_base_url="" + log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." + log_info "Downloading ${archive_url}" + if download_file "${archive_url}" "${archive_path}"; then + : + else + rm -rf "${temp_dir}" + log_warning "Failed to download standalone archive." + if [[ "${METHOD}" == "detect" ]]; then + return 2 + fi + return 1 + fi + else + rm -rf "${temp_dir}" + log_warning "Failed to download standalone archive." + if [[ "${METHOD}" == "detect" ]]; then + return 2 + fi + return 1 + fi fi fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 833c22d397e..18d21005f67 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -142,6 +142,7 @@ describe('installation scripts', () => { expect(script).toContain('curl -fsIL -m "${timeout}"'); expect(script).toContain('/latest/VERSION'); expect(script).toContain('resolve_aliyun_version_path()'); + expect(script).toContain('retrying GitHub mirror'); expect(script).not.toContain('-print -quit'); }); @@ -249,6 +250,8 @@ describe('installation scripts', () => { expect(script).toContain(':CreateTempFile'); expect(script).toContain('/latest/VERSION'); expect(script).toContain(':ResolveAliyunVersionPath'); + expect(script).toContain(':UseGithubFallbackBaseUrl'); + expect(script).toContain('retrying GitHub mirror'); expect(script).not.toContain('%RANDOM%'); }); @@ -1320,8 +1323,23 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain( 'upload_release_assets "releases/qwen-code/latest"', ); - expect(workflow).toContain( - 'upload_asset "${RUNNER_TEMP}/qwen-code-latest-version" "releases/qwen-code/latest/VERSION"', + const syncStepIndex = workflow.indexOf( + "name: 'Sync Release Assets to Aliyun OSS'", + ); + const verifyStepIndex = workflow.indexOf( + "name: 'Verify Aliyun OSS Release Assets'", + ); + const publishLatestStepIndex = workflow.indexOf( + "name: 'Publish Aliyun OSS Latest VERSION'", + ); + expect(syncStepIndex).toBeGreaterThanOrEqual(0); + expect(verifyStepIndex).toBeGreaterThan(syncStepIndex); + expect(publishLatestStepIndex).toBeGreaterThan(verifyStepIndex); + expect(workflow.slice(syncStepIndex, verifyStepIndex)).not.toContain( + 'releases/qwen-code/latest/VERSION', + ); + expect(workflow.slice(publishLatestStepIndex)).toContain( + 'releases/qwen-code/latest/VERSION', ); expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); @@ -1555,6 +1573,134 @@ describe('Linux/macOS installer end-to-end', () => { 15000, ); + itOnUnix( + 'tries GitHub before npm when auto-selected Aliyun archive is unavailable', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'is_head=0', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' -*) case "$1" in *I*) is_head=1 ;; esac ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s\\n" "$url" >> "$QWEN_FAKE_CURL_LOG"', + 'if [ "$is_head" = "1" ]; then', + ' case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' exit 0 ;;', + ' */releases/latest/download/SHA256SUMS)', + ' exit 22 ;;', + ' */releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' exit 22 ;;', + ' */releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' exit 0 ;;', + ' *)', + ' echo "unexpected HEAD url: $url" >&2', + ' exit 22 ;;', + ' esac', + 'fi', + 'case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' printf "v0.0.0-smoke\\n" ;;', + ' */releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest" ;;', + ' */releases/download/v0.0.0-smoke/SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest" ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(path.join(fakeBin, 'uname'), 0o755); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--mirror', + 'auto', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/download/v0.0.0-smoke/SHA256SUMS', + ); + expect(output).toContain( + 'Aliyun standalone archive not found; retrying GitHub mirror.', + ); + expect(output).toContain( + 'Downloading https://github.com/QwenLM/qwen-code/releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(output).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + 15000, + ); + itOnUnix('uninstalls standalone files while preserving user config', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); From bae933332ef6820e68e5f0c66276fa7b2603124e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 15:14:34 +0800 Subject: [PATCH 091/129] fix(installer): address standalone review feedback --- .github/workflows/release.yml | 46 ++++++----- .../installation/install-qwen-standalone.bat | 24 +++++- .../installation/install-qwen-standalone.sh | 34 +++++++- .../uninstall-qwen-standalone.ps1 | 3 +- .../installation/uninstall-qwen-standalone.sh | 10 ++- scripts/tests/install-script.test.js | 79 +++++++++++++++++++ 6 files changed, 169 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cdeec4ca54..11cba2a80f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -485,6 +485,7 @@ jobs: ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail @@ -504,36 +505,45 @@ jobs: upload_release_assets "releases/qwen-code/${RELEASE_TAG}" - upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" - upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" - upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" - upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" - upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" - upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then + upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" + upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" + upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" + upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" + upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" + upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + else + echo "Skipping hosted installation asset upload for prerelease ${RELEASE_TAG}." + fi - name: 'Verify Aliyun OSS Release Assets' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" - hosted_tmp_dir="$(mktemp -d)" - trap 'rm -rf "${hosted_tmp_dir}"' EXIT - for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do - url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" - curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" - done - cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { - echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" - diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true - exit 1 - } - (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then + hosted_tmp_dir="$(mktemp -d)" + trap 'rm -rf "${hosted_tmp_dir}"' EXIT + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do + url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" + curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" + done + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true + exit 1 + } + (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + else + echo "Skipping hosted installation asset verification for prerelease ${RELEASE_TAG}." + fi - name: 'Publish Aliyun OSS Latest VERSION' if: |- diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 385be3bf055..f0b537f00db 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -241,7 +241,7 @@ if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -257,7 +257,7 @@ call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -906,6 +906,12 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) +call :RestoreStaleInstallBackup +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + if exist "!NEW_INSTALL_DIR!" ( rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 if !ERRORLEVEL! NEQ 0 ( @@ -918,7 +924,7 @@ if exist "!OLD_INSTALL_DIR!" ( rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + echo ERROR: Failed to remove stale install backup: !OLD_INSTALL_DIR!. exit /b 1 ) ) @@ -1057,6 +1063,18 @@ if !ERRORLEVEL! NEQ 0 ( ) exit /b 0 +:RestoreStaleInstallBackup +if exist "!INSTALL_DIR!" exit /b 0 +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +echo WARNING: Found previous install backup without an active install: !OLD_INSTALL_DIR! +echo WARNING: Restoring backup to !INSTALL_DIR! before continuing. +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo ERROR: Failed to restore previous install from !OLD_INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 7c9f188c915..918fb92650f 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -811,6 +811,7 @@ verify_checksum() { validate_archive_entry_path() { local entry="$1" + entry="${entry//\\//}" while [[ "${entry}" == ./* ]]; do entry="${entry#./}" @@ -826,7 +827,7 @@ validate_archive_entry_path() { esac case "${entry}" in - ""|/*|..|../*|*/..|*/../*|*\\*) + ""|/*|..|../*|*/..|*/../*) log_error "Archive contains unsafe path: ${entry:-}" return 1 ;; @@ -923,6 +924,24 @@ ensure_managed_install_dir() { return 1 } +restore_stale_install_backup() { + local old_install_dir="$1" + local current_install_dir="$2" + + if [[ -e "${current_install_dir}" || ! -e "${old_install_dir}" ]]; then + return 0 + fi + + log_warning "Found previous install backup without an active install: ${old_install_dir}" + log_warning "Restoring backup to ${current_install_dir} before continuing." + if mv "${old_install_dir}" "${current_install_dir}"; then + return 0 + fi + + log_error "Failed to restore previous install from ${old_install_dir}." + return 1 +} + is_qwen_standalone_install_dir() { local install_dir="$1" local manifest_path="${install_dir}/manifest.json" @@ -1113,7 +1132,18 @@ install_standalone() { rm -rf "${temp_dir}" return 1 fi - rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}" + if ! restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"; then + rm -rf "${temp_dir}" + return 1 + fi + if [[ -e "${old_install_dir}" ]]; then + rm -rf "${old_install_dir}" || { + rm -rf "${temp_dir}" + log_error "Failed to remove stale install backup: ${old_install_dir}" + return 1 + } + fi + rm -rf "${new_install_dir}" "${wrapper_tmp}" mv "${extract_dir}/qwen-code" "${new_install_dir}" if ! write_unix_wrapper "${wrapper_tmp}" "${INSTALL_LIB_DIR}/bin/qwen"; then diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 index 4576ec461e7..a454c840771 100644 --- a/scripts/installation/uninstall-qwen-standalone.ps1 +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -320,9 +320,8 @@ $installDir = Get-QwenInstallDir $installBinDir = Get-QwenInstallBinDir $installWasManaged = Test-QwenStandaloneInstallDir -InstallDir $installDir -Remove-CurrentCmdPathShim - if ($installWasManaged) { + Remove-CurrentCmdPathShim Remove-Item -LiteralPath $installDir -Recurse -Force Write-Success "Removed $installDir" } elseif (Test-Path -LiteralPath $installDir) { diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh index 114838e48bb..0f4d211f50b 100755 --- a/scripts/installation/uninstall-qwen-standalone.sh +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -193,8 +193,14 @@ remove_shell_path_entry() { } awk -v marker="${marker}" ' - $0 == marker { skip_next = 1; next } - skip_next == 1 { skip_next = 0; next } + index($0, marker) { check_next = 1; next } + check_next == 1 { + check_next = 0 + if ($0 ~ /^[[:space:]]*export PATH=/ || + $0 ~ /^[[:space:]]*set -gx PATH /) { + next + } + } { print } ' "${rc_file}" > "${temp_file}" && mv "${temp_file}" "${rc_file}" || { rm -f "${temp_file}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 18d21005f67..47f9e32a2bf 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -143,6 +143,14 @@ describe('installation scripts', () => { expect(script).toContain('/latest/VERSION'); expect(script).toContain('resolve_aliyun_version_path()'); expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('entry="${entry//\\\\//}"'); + expect(script).toContain('restore_stale_install_backup()'); + expect(script).toContain( + 'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"', + ); + expect(script).not.toContain( + 'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"', + ); expect(script).not.toContain('-print -quit'); }); @@ -252,6 +260,15 @@ describe('installation scripts', () => { expect(script).toContain(':ResolveAliyunVersionPath'); expect(script).toContain(':UseGithubFallbackBaseUrl'); expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%"'); + expect(script).not.toContain( + 'endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%"', + ); + expect(script).toContain(':RestoreStaleInstallBackup'); + expect(script).toContain('call :RestoreStaleInstallBackup'); + expect(script).not.toContain( + 'ERROR: Failed to remove stale backup directory', + ); expect(script).not.toContain('%RANDOM%'); }); @@ -1341,6 +1358,14 @@ describe('standalone release packaging', () => { expect(workflow.slice(publishLatestStepIndex)).toContain( 'releases/qwen-code/latest/VERSION', ); + const syncStep = workflow.slice(syncStepIndex, verifyStepIndex); + expect(syncStep).toContain('IS_STABLE_RELEASE'); + expect(syncStep).toContain( + 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', + ); + expect(syncStep).toContain( + 'Skipping hosted installation asset upload for prerelease', + ); expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); expect(workflow).toContain('installation/install-qwen-standalone.ps1'); @@ -1356,6 +1381,14 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', ); + const verifyStep = workflow.slice(verifyStepIndex, publishLatestStepIndex); + expect(verifyStep).toContain('IS_STABLE_RELEASE'); + expect(verifyStep).toContain( + 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', + ); + expect(verifyStep).toContain( + 'Skipping hosted installation asset verification for prerelease', + ); expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); expect(workflow).toContain( 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS"', @@ -1421,6 +1454,12 @@ describe('standalone release packaging', () => { ); expect(uninstallPowerShellSource).toContain('QWEN_UNINSTALL_PURGE'); expect(uninstallPowerShellSource).toContain('Preserving'); + expect(uninstallPowerShellSource).toMatch( + /if \(\$installWasManaged\) \{\n\s+Remove-CurrentCmdPathShim\n\s+Remove-Item/, + ); + expect(uninstallPowerShellSource).not.toMatch( + /\$installWasManaged = Test-QwenStandaloneInstallDir[^\n]*\n\nRemove-CurrentCmdPathShim\n\nif \(\$installWasManaged\)/, + ); }); }); @@ -1748,6 +1787,46 @@ describe('Linux/macOS installer end-to-end', () => { } }); + itOnUnix( + 'removes only installer-owned shell rc PATH lines during uninstall', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Added by qwen-code installer (multi-qwen shadow fix) ', + `export PATH='${installRoot}/bin':$PATH`, + 'middle', + '# Added by qwen-code installer (multi-qwen shadow fix)', + 'echo keep-me', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe( + ['before', 'middle', 'echo keep-me', 'after'].join('\n') + '\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + ); + itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); From a79745b4d8d73f7968ddc015424d1dce733eb1a4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 17:13:11 +0800 Subject: [PATCH 092/129] style(installer): align standalone install output --- .../installation/install-qwen-standalone.bat | 51 ++++++++++++---- .../installation/install-qwen-standalone.sh | 58 +++++++++++++++---- scripts/tests/install-script.test.js | 47 +++++++++++---- 3 files changed, 121 insertions(+), 35 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index f0b537f00db..73a570be233 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -203,8 +203,7 @@ goto usage_error call :ValidateOptions if %ERRORLEVEL% NEQ 0 exit /b 1 -echo Qwen Code Installer -echo. +call :PrintHeader REM Discover all qwen executables on disk BEFORE we install. We can't REM reliably simulate the user's PATH ordering, so enumerate well-known @@ -240,7 +239,7 @@ REM Dispatch after validation; detect falls back to npm only when unavailable. if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -248,7 +247,7 @@ if /i "!METHOD!"=="standalone" ( if /i "!METHOD!"=="npm" ( call :InstallNpm if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" + call :PrintFinalInstructions "" "" "npm" endlocal exit /b 0 ) @@ -256,7 +255,7 @@ if /i "!METHOD!"=="npm" ( call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -269,7 +268,7 @@ if !STANDALONE_STATUS! EQU 2 ( echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. exit /b !ERRORLEVEL! ) - call :PrintFinalInstructions "" + call :PrintFinalInstructions "" "" "npm" endlocal exit /b 0 ) @@ -307,6 +306,14 @@ echo when a shadowing 'qwen' is detected. echo -h, --help Show this help message. exit /b 0 +:PrintHeader +set "DISPLAY_VERSION=!VERSION!" +if /i not "!DISPLAY_VERSION!"=="latest" ( + if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" +) +echo Installing Qwen Code version: !DISPLAY_VERSION! +exit /b 0 + :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" @@ -796,14 +803,14 @@ if not "!ARCHIVE_PATH!"=="" ( if !ERRORLEVEL! NEQ 0 exit /b 1 set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - echo INFO: Downloading !ARCHIVE_URL! + echo Downloading !ARCHIVE_NAME! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" set "DOWNLOAD_STATUS=!ERRORLEVEL!" if not "!DOWNLOAD_STATUS!"=="0" if not "!GITHUB_FALLBACK_BASE_URL!"=="" ( 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 INFO: Downloading !ARCHIVE_URL! + echo Downloading !ARCHIVE_NAME! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" set "DOWNLOAD_STATUS=!ERRORLEVEL!" ) @@ -1212,6 +1219,9 @@ exit /b 0 :PrintFinalInstructions set "EXTRA_BIN=%~1" +set "SUMMARY_INSTALL_DIR=%~2" +set "SUMMARY_INSTALL_METHOD=%~3" +if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" set "INSTALLED_BIN=" if not "!EXTRA_BIN!"=="" ( @@ -1226,8 +1236,28 @@ 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 !INSTALLED_VERSION! installed. -if not "!INSTALLED_BIN!"=="" echo Location: !INSTALLED_BIN! +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!"=="" echo rmdir /S /Q "!SUMMARY_INSTALL_DIR!" + if not "!INSTALLED_BIN!"=="" echo del /F /Q "!INSTALLED_BIN!" +) rem Build OTHER_QWENS = PRE_INSTALL_QWENS_LIST minus the install we just made. set "OTHER_QWENS=" @@ -1271,6 +1301,5 @@ if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. exit /b 0 ) -echo Run: qwen echo qwen is ready to use in this terminal. exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 918fb92650f..d35293f4959 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -71,6 +71,15 @@ shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } +display_install_version() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + echo "${VERSION#v}" +} + trap cleanup_temp_dirs EXIT trap 'cleanup_temp_dirs; exit 130' INT trap 'cleanup_temp_dirs; exit 143' TERM @@ -309,8 +318,7 @@ done validate_options print_header() { - echo "Qwen Code Installer" - echo "" + echo "Installing Qwen Code version: $(display_install_version)" } print_node_help() { @@ -395,6 +403,10 @@ get_npm_global_bin() { esac } +get_npm_global_root() { + npm root -g 2>/dev/null || true +} + create_source_json() { if [[ "${SOURCE}" == "unknown" ]]; then return 0 @@ -1057,7 +1069,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - log_info "Downloading ${archive_url}" + echo "Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then if [[ -n "${github_fallback_base_url}" ]]; then rm -f "${archive_path}" @@ -1065,7 +1077,7 @@ install_standalone() { checksum_source="${github_fallback_base_url}/SHA256SUMS" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." - log_info "Downloading ${archive_url}" + echo "Downloading ${archive_name}" if download_file "${archive_url}" "${archive_path}"; then : else @@ -1240,6 +1252,8 @@ install_npm() { 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="" if [[ -n "${install_bin_dir}" ]]; then @@ -1280,9 +1294,30 @@ print_final_instructions() { installed_version=$(qwen --version 2>/dev/null || echo "unknown") fi - echo "Qwen Code ${installed_version} installed." - if [[ -n "${installed_bin}" ]]; then - echo "Location: ${installed_bin}" + 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}" + fi + + echo "" + echo "Uninstall:" + if [[ "${install_method}" == "npm" ]]; then + echo " npm uninstall -g @qwen-code/qwen-code" + elif [[ -n "${install_dir}" && -n "${installed_bin}" ]]; then + echo " rm -rf $(shell_quote "${install_dir}") $(shell_quote "${installed_bin}")" + elif [[ -n "${install_dir}" ]]; then + echo " rm -rf $(shell_quote "${install_dir}")" + else + echo " npm uninstall -g @qwen-code/qwen-code" fi if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then @@ -1307,7 +1342,6 @@ print_final_instructions() { return 0 fi - echo "Run: qwen" echo "(Open a new terminal for the PATH change to take effect.)" } @@ -1358,22 +1392,22 @@ main() { case "${METHOD}" in standalone) install_standalone - print_final_instructions "${INSTALL_BIN_DIR}" + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" ;; npm) install_npm - print_final_instructions "$(get_npm_global_bin)" + 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}" + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" else standalone_status=$? if [[ "${standalone_status}" -eq 2 ]]; then log_warning "Falling back to npm installation." if install_npm; then - print_final_instructions "$(get_npm_global_bin)" + 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." diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 47f9e32a2bf..f60d623b46e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -68,7 +68,14 @@ describe('installation scripts', () => { expect(script).toContain('Node.js 22 or newer is required'); expect(script).toContain('npm_package_spec()'); expect(script).toContain('@qwen-code/qwen-code@latest'); - expect(script).toContain('Run: qwen'); + 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:'); }); it('supports code-server-style standalone install on Linux/macOS', () => { @@ -126,6 +133,7 @@ describe('installation scripts', () => { expect(script).toContain( 'wget --progress=bar:force:noscroll --tries=3 "${url}" -O "${destination}"', ); + expect(script).toContain('echo "Downloading ${archive_name}"'); expect(script).not.toContain( 'curl -fsSL --retry 2 "${url}" -o "${destination}"', ); @@ -177,7 +185,14 @@ describe('installation scripts', () => { expect(script).toContain('Please install Node.js'); expect(script).toContain(':NpmPackageSpec'); expect(script).toContain('@qwen-code/qwen-code@latest'); - expect(script).toContain('Run: qwen'); + 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:'); }); it('supports code-server-style standalone install on Windows', () => { @@ -255,6 +270,7 @@ describe('installation scripts', () => { expect(script).toContain('Failed to update user PATH'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); + expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); expect(script).toContain(':CreateTempFile'); expect(script).toContain('/latest/VERSION'); expect(script).toContain(':ResolveAliyunVersionPath'); @@ -1474,7 +1490,7 @@ describe('Linux/macOS installer end-to-end', () => { const archive = packageFakeStandalone(tmpDir); const installRoot = path.join(tmpDir, 'install'); const home = path.join(tmpDir, 'home'); - runUnixInstaller(archive, installRoot, home); + const output = runUnixInstaller(archive, installRoot, home).toString(); expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); expect( @@ -1492,6 +1508,19 @@ describe('Linux/macOS installer end-to-end', () => { .toString() .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( + `rm -rf '${path.join(installRoot, 'lib', 'qwen-code')}'`, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); if (createdDist) { @@ -1599,9 +1628,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(curlUrls).not.toContain( '/releases/qwen-code/latest/qwen-code-linux-x64.tar.gz', ); - expect(output).toContain( - 'Downloading https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', - ); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); } finally { rmSync(tmpDir, { recursive: true, force: true }); if (createdDist) { @@ -1726,9 +1753,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(output).toContain( 'Aliyun standalone archive not found; retrying GitHub mirror.', ); - expect(output).toContain( - 'Downloading https://github.com/QwenLM/qwen-code/releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', - ); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); expect(output).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); @@ -2434,9 +2459,7 @@ describe('Windows installer end-to-end', () => { expect(curlUrls).not.toContain( '/releases/qwen-code/latest/qwen-code-win-x64.zip', ); - expect(output).toContain( - 'Downloading https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip', - ); + expect(output).toContain('Downloading qwen-code-win-x64.zip'); expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe( true, ); From 7fd314a358582603d9a140fb9ef07ad0f043bd2c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 18:26:49 +0800 Subject: [PATCH 093/129] fix(installer): print standalone uninstall commands --- .../installation/install-qwen-standalone.bat | 12 ++++++++-- .../installation/install-qwen-standalone.sh | 9 ++++---- scripts/tests/install-script.test.js | 22 ++++++++++++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 73a570be233..19467095169 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -1221,6 +1221,7 @@ exit /b 0 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" if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" set "INSTALLED_BIN=" @@ -1255,8 +1256,15 @@ echo Uninstall: if /i "!SUMMARY_INSTALL_METHOD!"=="npm" ( echo npm uninstall -g @qwen-code/qwen-code ) else ( - if not "!SUMMARY_INSTALL_DIR!"=="" echo rmdir /S /Q "!SUMMARY_INSTALL_DIR!" - if not "!INSTALLED_BIN!"=="" echo del /F /Q "!INSTALLED_BIN!" + 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. diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index d35293f4959..15325350a89 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -1256,6 +1256,7 @@ print_final_instructions() { 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}") @@ -1312,12 +1313,10 @@ print_final_instructions() { echo "Uninstall:" if [[ "${install_method}" == "npm" ]]; then echo " npm uninstall -g @qwen-code/qwen-code" - elif [[ -n "${install_dir}" && -n "${installed_bin}" ]]; then - echo " rm -rf $(shell_quote "${install_dir}") $(shell_quote "${installed_bin}")" - elif [[ -n "${install_dir}" ]]; then - echo " rm -rf $(shell_quote "${install_dir}")" + 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 " npm uninstall -g @qwen-code/qwen-code" + echo " curl -fsSL ${standalone_uninstall_url} | bash" fi if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index f60d623b46e..d62f547897f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -76,6 +76,8 @@ describe('installation scripts', () => { expect(script).toContain('To start:'); expect(script).toContain('Installed to:'); expect(script).toContain('Uninstall:'); + expect(script).toContain('uninstall-qwen-standalone.sh'); + expect(script).not.toContain('rm -rf $(shell_quote "${install_dir}")'); }); it('supports code-server-style standalone install on Linux/macOS', () => { @@ -193,6 +195,9 @@ describe('installation scripts', () => { expect(script).toContain('To start:'); expect(script).toContain('Installed to:'); expect(script).toContain('Uninstall:'); + expect(script).toContain('uninstall-qwen-standalone.ps1'); + expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); + expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); it('supports code-server-style standalone install on Windows', () => { @@ -303,6 +308,14 @@ describe('installation scripts', () => { expect(attrs).toContain( 'scripts/installation/install-qwen-standalone.bat: eol: crlf', ); + + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + const bareLfLines = script + .split(/(?<=\n)/) + .filter((line) => line.endsWith('\n') && !line.endsWith('\r\n')); + expect(bareLfLines).toHaveLength(0); }); it('creates PowerShell validation scripts with a ps1 extension', () => { @@ -1519,8 +1532,15 @@ describe('Linux/macOS installer end-to-end', () => { ); expect(output).toContain('Uninstall:'); expect(output).toContain( - `rm -rf '${path.join(installRoot, 'lib', 'qwen-code')}'`, + '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).not.toContain('rm -rf'); } finally { rmSync(tmpDir, { recursive: true, force: true }); if (createdDist) { From 7d665f39dfa13c6e513e7f1bf00c1168bf54e8b5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 21:00:13 +0800 Subject: [PATCH 094/129] fix(installer): address release review follow-ups --- scripts/installation/install-qwen-standalone.sh | 2 ++ scripts/tests/install-script.test.js | 14 ++++++++++++++ scripts/verify-installation-release.js | 17 ++++++++++------- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 15325350a89..fefbf4357f4 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -1054,6 +1054,7 @@ install_standalone() { base_url="${github_fallback_base_url}" archive_url="${github_archive_url}" checksum_source="${base_url}/SHA256SUMS" + MIRROR="github" github_fallback_base_url="" else log_warning "Standalone archive not found: ${archive_name}" @@ -1075,6 +1076,7 @@ install_standalone() { rm -f "${archive_path}" archive_url="${github_fallback_base_url}/${archive_name}" checksum_source="${github_fallback_base_url}/SHA256SUMS" + MIRROR="github" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." echo "Downloading ${archive_name}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index d62f547897f..9dcafc0a512 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -107,6 +107,12 @@ describe('installation scripts', () => { expect(script).toContain('Falling back to npm installation'); expect(script).toContain('standalone_status=$?'); expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]'); + expect(script).toMatch( + /Aliyun standalone archive not found; retrying GitHub mirror\.[\s\S]*checksum_source="\$\{base_url\}\/SHA256SUMS"[\s\S]*MIRROR="github"/, + ); + expect(script).toMatch( + /archive_url="\$\{github_fallback_base_url\}\/\$\{archive_name\}"[\s\S]*checksum_source="\$\{github_fallback_base_url\}\/SHA256SUMS"[\s\S]*MIRROR="github"[\s\S]*Aliyun standalone archive download failed; retrying GitHub mirror\./, + ); expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); @@ -285,6 +291,9 @@ describe('installation scripts', () => { expect(script).not.toContain( 'endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%"', ); + expect(script).toContain( + 'if /i "!METHOD!"=="detect" exit /b 2\r\n exit /b 1', + ); expect(script).toContain(':RestoreStaleInstallBackup'); expect(script).toContain('call :RestoreStaleInstallBackup'); expect(script).not.toContain( @@ -517,6 +526,11 @@ describe('standalone release packaging', () => { expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES'); + expect(releaseVerifyScript).toContain('import { RELEASE_TARGETS }'); + expect(releaseVerifyScript).toContain( + 'standaloneArchiveNamesFromReleaseTargets', + ); + expect(releaseVerifyScript).not.toContain("'qwen-code-win-x64.zip'"); expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES'); expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches'); }); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index e4303d1ffd7..b2eb1c6c672 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -9,6 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { RELEASE_TARGETS } from './build-standalone-release.js'; import { fail, isMainModule, @@ -21,13 +22,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); -const EXPECTED_STANDALONE_ARCHIVE_NAMES = [ - 'qwen-code-darwin-arm64.tar.gz', - 'qwen-code-darwin-x64.tar.gz', - 'qwen-code-linux-arm64.tar.gz', - 'qwen-code-linux-x64.tar.gz', - 'qwen-code-win-x64.zip', -]; +const EXPECTED_STANDALONE_ARCHIVE_NAMES = + standaloneArchiveNamesFromReleaseTargets(RELEASE_TARGETS); // Release artifacts that the installer chain expects in a GitHub Release. // Hosted installer scripts are served from a separate endpoint and are // intentionally not part of this set; they have their own staging path in @@ -38,6 +34,13 @@ const EXPECTED_RELEASE_ASSET_NAMES = [ ]; const REMOTE_FETCH_TIMEOUT_MS = 30_000; +function standaloneArchiveNamesFromReleaseTargets(releaseTargets) { + return releaseTargets.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget.startsWith('win-') ? 'zip' : 'tar.gz'}`, + ); +} + const ARG_DEFS = { '--dir': { key: 'dir', type: 'value' }, '--base-url': { key: 'baseUrl', type: 'value' }, From c959913dfc35ebd624e8f369ecbbefb14670e0f4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 21:49:41 +0800 Subject: [PATCH 095/129] fix(installer): harden Windows target detection --- scripts/installation/install-qwen-standalone.bat | 6 ++++-- scripts/tests/install-script.test.js | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 19467095169..11f84d46750 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -453,8 +453,10 @@ exit /b 1 :DetectTarget set "TARGET=" -if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "TARGET=win-x64" -if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" if "!TARGET!"=="" ( echo WARNING: Standalone archive is not available for this Windows architecture. exit /b 1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 9dcafc0a512..63f350806a3 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -226,6 +226,10 @@ describe('installation scripts', () => { expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain( + 'if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64"', + ); + expect(script).not.toContain('if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64"'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); From b2b43b51dad6aaf644140dd25b1fe2abc896027f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 22:22:28 +0800 Subject: [PATCH 096/129] test(installer): stabilize Windows fake tool path --- scripts/tests/install-script.test.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 63f350806a3..8e9ee8dca29 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -331,6 +331,15 @@ describe('installation scripts', () => { expect(bareLfLines).toHaveLength(0); }); + it('prepends fake Windows tools to both PATH casings', () => { + const fakeBin = 'C:\\qwen-test-bin'; + + const env = prependWindowsPath(fakeBin); + + expect(env.PATH).toMatch(/^C:\\qwen-test-bin;/); + expect(env.Path).toMatch(/^C:\\qwen-test-bin;/); + }); + it('creates PowerShell validation scripts with a ps1 extension', () => { const script = readScript( 'scripts/installation/install-qwen-standalone.bat', @@ -2835,8 +2844,11 @@ function prependWindowsPath(directory) { const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || 'Path'; + const value = `${directory};${process.env[pathKey] || ''}`; return { - [pathKey]: `${directory};${process.env[pathKey] || ''}`, + PATH: value, + Path: value, + [pathKey]: value, }; } From 308fe4da93c906c2942a368f22856cf6c174597e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 22:45:58 +0800 Subject: [PATCH 097/129] fix(installer): allow explicit Windows curl path --- scripts/installation/install-qwen-standalone.bat | 4 ++-- scripts/tests/install-script.test.js | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 11f84d46750..3e00d500f75 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -596,7 +596,7 @@ 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'; if (Get-Command curl.exe -ErrorAction SilentlyContinue) { curl.exe -#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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +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 -#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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -605,7 +605,7 @@ exit /b %PS_STATUS% :DownloadFileQuiet set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; if (Get-Command curl.exe -ErrorAction SilentlyContinue) { curl.exe -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 8e9ee8dca29..74c642cd9e4 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -179,7 +179,7 @@ describe('installation scripts', () => { expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); expect(script).toContain('Invoke-WebRequest'); - expect(script).toContain('curl.exe -#fSLo'); + expect(script).toContain('& $curl -#fSLo'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -254,9 +254,9 @@ describe('installation scripts', () => { expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); - expect(script).toContain( - "$ErrorActionPreference = 'Stop'; if (Get-Command curl.exe", - ); + expect(script).toContain('$curl = $env:QWEN_INSTALL_CURL_EXE'); + expect(script).toContain('QWEN_INSTALL_CURL_EXE'); + expect(script).toContain('Get-Command curl.exe -CommandType Application'); expect(script).toContain( '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', ); @@ -2489,6 +2489,7 @@ describe('Windows installer end-to-end', () => { QWEN_FAKE_ARCHIVE: archive, QWEN_FAKE_SHA256SUMS: checksumFile, QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_CURL_EXE: path.join(fakeBin, 'curl.exe'), ...prependWindowsPath(fakeBin), PROCESSOR_ARCHITECTURE: 'AMD64', PROCESSOR_ARCHITEW6432: '', From 1d272f60243f654c7121ef1fc39a01892866fbcf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 23:06:33 +0800 Subject: [PATCH 098/129] test(installer): use cmd fake curl on Windows --- scripts/tests/install-script.test.js | 112 +++++++++++---------------- 1 file changed, 45 insertions(+), 67 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 74c642cd9e4..d8fd1106386 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -2471,7 +2471,7 @@ describe('Windows installer end-to-end', () => { const installRoot = path.join(tmpDir, 'install'); const home = path.join(tmpDir, 'home'); - createFakeWindowsCurlExe(fakeBin); + const fakeCurl = createFakeWindowsCurlCommand(fakeBin); const output = runWindowsCommand( [ @@ -2489,7 +2489,7 @@ describe('Windows installer end-to-end', () => { QWEN_FAKE_ARCHIVE: archive, QWEN_FAKE_SHA256SUMS: checksumFile, QWEN_FAKE_CURL_LOG: curlLog, - QWEN_INSTALL_CURL_EXE: path.join(fakeBin, 'curl.exe'), + QWEN_INSTALL_CURL_EXE: fakeCurl, ...prependWindowsPath(fakeBin), PROCESSOR_ARCHITECTURE: 'AMD64', PROCESSOR_ARCHITEW6432: '', @@ -2770,75 +2770,53 @@ function createFakeWindowsNpmTools(fakeBin) { ); } -function createFakeWindowsCurlExe(fakeBin) { +function createFakeWindowsCurlCommand(fakeBin) { mkdirSync(fakeBin, { recursive: true }); - const sourcePath = path.join(fakeBin, 'FakeCurl.cs'); - const outputPath = path.join(fakeBin, 'curl.exe'); + const outputPath = path.join(fakeBin, 'curl.cmd'); writeFileSync( - sourcePath, - String.raw` -using System; -using System.IO; - -public static class FakeCurl { - public static int Main(string[] args) { - string destination = null; - string url = null; - - for (int index = 0; index < args.Length; index++) { - string arg = args[index]; - if (arg.StartsWith("-") && arg.IndexOf('o') >= 0 && index + 1 < args.Length) { - destination = args[++index]; - continue; - } - if (arg.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - url = arg; - } - } - - File.AppendAllText(Environment.GetEnvironmentVariable("QWEN_FAKE_CURL_LOG"), url + Environment.NewLine); - if (url == null || destination == null) { - Console.Error.WriteLine("missing url or destination"); - return 2; - } - - if (url.EndsWith("/releases/qwen-code/latest/VERSION", StringComparison.OrdinalIgnoreCase)) { - File.WriteAllText(destination, "v0.0.0-smoke" + Environment.NewLine); - return 0; - } - if (url.EndsWith("/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip", StringComparison.OrdinalIgnoreCase)) { - File.Copy(Environment.GetEnvironmentVariable("QWEN_FAKE_ARCHIVE"), destination, true); - return 0; - } - if (url.EndsWith("/releases/qwen-code/v0.0.0-smoke/SHA256SUMS", StringComparison.OrdinalIgnoreCase)) { - File.Copy(Environment.GetEnvironmentVariable("QWEN_FAKE_SHA256SUMS"), destination, true); - return 0; - } - - Console.Error.WriteLine("unexpected url: " + url); - return 22; - } -} -`, - ); - execFileSync( - 'powershell', [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - '$source = Get-Content -LiteralPath $env:QWEN_FAKE_CURL_SOURCE -Raw; Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $env:QWEN_FAKE_CURL_OUTPUT -OutputType ConsoleApplication', - ], - { - env: { - ...process.env, - QWEN_FAKE_CURL_SOURCE: sourcePath, - QWEN_FAKE_CURL_OUTPUT: outputPath, - }, - stdio: 'pipe', - }, + '@echo off', + 'setlocal EnableExtensions EnableDelayedExpansion', + 'set "destination="', + 'set "url="', + ':parse_args', + 'if "%~1"=="" goto done_parse', + 'set "arg=%~1"', + 'if "!arg:~0,1!"=="-" (', + ' echo(!arg! | findstr /C:"o" >nul && (', + ' shift', + ' set "destination=%~1"', + ' shift', + ' goto parse_args', + ' )', + ' shift', + ' goto parse_args', + ')', + 'if /i "!arg:~0,4!"=="http" set "url=!arg!"', + 'shift', + 'goto parse_args', + ':done_parse', + '>>"%QWEN_FAKE_CURL_LOG%" echo(!url!', + 'if "!url!"=="" echo missing url or destination 1>&2 & exit /b 2', + 'if "!destination!"=="" echo missing url or destination 1>&2 & exit /b 2', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/latest/VERSION" >nul && (', + ' > "!destination!" echo v0.0.0-smoke', + ' exit /b 0', + ')', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip" >nul && (', + ' copy /Y "%QWEN_FAKE_ARCHIVE%" "!destination!" >nul', + ' exit /b 0', + ')', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0-smoke/SHA256SUMS" >nul && (', + ' copy /Y "%QWEN_FAKE_SHA256SUMS%" "!destination!" >nul', + ' exit /b 0', + ')', + 'echo unexpected url: !url! 1>&2', + 'exit /b 22', + '', + ].join('\r\n'), ); + return outputPath; } function prependWindowsPath(directory) { From 778919f616e686cd56b9aa012a615f5e4abb0f66 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 23:32:18 +0800 Subject: [PATCH 099/129] test(installer): cover Windows fake curl helper --- scripts/tests/install-script.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index d8fd1106386..01c5b0face3 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -340,6 +340,22 @@ describe('installation scripts', () => { expect(env.Path).toMatch(/^C:\\qwen-test-bin;/); }); + it('creates a fake Windows curl command script', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-curl-helper-')); + + try { + const fakeCurl = createFakeWindowsCurlCommand(tmpDir); + + expect(fakeCurl).toBe(path.join(tmpDir, 'curl.cmd')); + expect(readScript(fakeCurl)).toContain('QWEN_FAKE_CURL_LOG'); + expect(readScript(fakeCurl)).toContain( + '/releases/qwen-code/latest/VERSION', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('creates PowerShell validation scripts with a ps1 extension', () => { const script = readScript( 'scripts/installation/install-qwen-standalone.bat', @@ -2774,6 +2790,7 @@ function createFakeWindowsCurlCommand(fakeBin) { mkdirSync(fakeBin, { recursive: true }); const outputPath = path.join(fakeBin, 'curl.cmd'); writeFileSync( + outputPath, [ '@echo off', 'setlocal EnableExtensions EnableDelayedExpansion', From ecfad9995f77bb7b72ef94b9685357da79357e6b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 23:58:34 +0800 Subject: [PATCH 100/129] test(installer): inject Windows arch overrides in cmd --- scripts/tests/install-script.test.js | 69 ++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 01c5b0face3..25fb4f71485 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -356,6 +356,27 @@ describe('installation scripts', () => { } }); + it('injects Windows processor overrides directly into cmd commands', () => { + const prepared = prepareWindowsCommand( + 'call "C:\\tools\\install-qwen-standalone.bat"', + { + Path: 'C:\\fake-bin', + PROCESSOR_ARCHITECTURE: 'AMD64', + PROCESSOR_ARCHITEW6432: '', + }, + { + Path: 'C:\\Windows\\System32', + processor_architecture: 'ARM64', + PROCESSOR_ARCHITEW6432: 'ARM64', + }, + ); + + expect(prepared.command).toBe( + 'set "PROCESSOR_ARCHITECTURE=AMD64" && set "PROCESSOR_ARCHITEW6432=" && call "C:\\tools\\install-qwen-standalone.bat"', + ); + expect(prepared.env).toEqual({ Path: 'C:\\fake-bin' }); + }); + it('creates PowerShell validation scripts with a ps1 extension', () => { const script = readScript( 'scripts/installation/install-qwen-standalone.bat', @@ -3034,15 +3055,47 @@ function runWindowsInstaller( } function runWindowsCommand(command, env = {}) { - return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], { - env: { - ...process.env, - ...env, + const prepared = prepareWindowsCommand(command, env); + return execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/c', prepared.command], + { + env: { + ...prepared.env, + }, + stdio: 'pipe', + // cmd.exe parses the command string itself; preserve quoted paths. + windowsVerbatimArguments: true, }, - stdio: 'pipe', - // cmd.exe parses the command string itself; preserve quoted paths. - windowsVerbatimArguments: true, - }); + ); +} + +const WINDOWS_COMMAND_ENV_OVERRIDES = [ + 'PROCESSOR_ARCHITECTURE', + 'PROCESSOR_ARCHITEW6432', +]; + +function prepareWindowsCommand(command, env = {}, baseEnv = process.env) { + const commandEnv = { ...baseEnv, ...env }; + const commandPrefix = []; + + for (const key of WINDOWS_COMMAND_ENV_OVERRIDES) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + continue; + } + + for (const existingKey of Object.keys(commandEnv)) { + if (existingKey.toLowerCase() === key.toLowerCase()) { + delete commandEnv[existingKey]; + } + } + commandPrefix.push(`set "${key}=${env[key] ?? ''}"`); + } + + return { + command: [...commandPrefix, command].join(' && '), + env: commandEnv, + }; } function createSymlinkStandaloneArchive(tmpDir) { From ce45abd0f0a20b38676f16739d30ce65ce2aea45 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 00:20:48 +0800 Subject: [PATCH 101/129] test(cli): wait for prompt suggestion render --- .../src/ui/components/InputPrompt.test.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index e3a7d17173e..50fe7335581 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -252,26 +252,29 @@ describe('InputPrompt', () => { const wait = (ms = 150) => new Promise((resolve) => setTimeout(resolve, ms)); describe('prompt suggestions', () => { - // createFollowupController.setSuggestion debounces the visibility - // transition by SUGGESTION_DELAY_MS (300ms) before flipping - // followup.state.isVisible to true. The Enter handler reads that flag - // synchronously, so we must wait for the timer to fire before pressing - // Enter — otherwise the suggestion path is skipped and onSubmit never - // runs. 350ms left only ~50ms margin and was eaten by ink 7 / React 19.2 - // mount overhead on slow Windows CI runners. Keep this wait > 300ms + - // generous buffer (renderWithProviders cold start can be 100-200ms). - const SUGGESTION_VISIBLE_WAIT_MS = 700; + const waitForPromptSuggestion = async ( + lastFrame: () => string | undefined, + suggestion: string, + ) => { + await waitFor( + () => { + expect(stripAnsi(lastFrame() ?? '')).toContain(suggestion); + }, + { timeout: 3000 }, + ); + }; it('accepts and submits the prompt suggestion on Enter when the buffer is empty', async () => { - const { stdin, unmount } = renderWithProviders( + const { stdin, unmount, lastFrame } = renderWithProviders( , ); - await wait(SUGGESTION_VISIBLE_WAIT_MS); + await waitForPromptSuggestion(lastFrame, 'commit this'); stdin.write('\r'); - await wait(); - expect(props.onSubmit).toHaveBeenCalledWith('commit this'); + await waitFor(() => { + expect(props.onSubmit).toHaveBeenCalledWith('commit this'); + }); // Enter path must NOT call buffer.insert — it passes text directly to // handleSubmitAndClear. Calling insert would re-fill the buffer after // it was already cleared (the microtask race bug). @@ -280,10 +283,10 @@ describe('InputPrompt', () => { }); it('does not accept the prompt suggestion on shift+tab', async () => { - const { stdin, unmount } = renderWithProviders( + const { stdin, unmount, lastFrame } = renderWithProviders( , ); - await wait(SUGGESTION_VISIBLE_WAIT_MS); + await waitForPromptSuggestion(lastFrame, 'commit this'); stdin.write('\x1b[Z'); // shift+tab await wait(); @@ -302,10 +305,10 @@ describe('InputPrompt', () => { }, ] as UseCommandCompletionReturn['suggestions']; - const { stdin, unmount } = renderWithProviders( + const { stdin, unmount, lastFrame } = renderWithProviders( , ); - await wait(SUGGESTION_VISIBLE_WAIT_MS); + await waitForPromptSuggestion(lastFrame, 'commit this'); stdin.write('\t'); await wait(); From 78d0c8617751bda8ed055feeb59ba3741189e0d6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 00:32:58 +0800 Subject: [PATCH 102/129] test(cli): revert prompt suggestion wait tweak --- .../src/ui/components/InputPrompt.test.tsx | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 50fe7335581..e3a7d17173e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -252,29 +252,26 @@ describe('InputPrompt', () => { const wait = (ms = 150) => new Promise((resolve) => setTimeout(resolve, ms)); describe('prompt suggestions', () => { - const waitForPromptSuggestion = async ( - lastFrame: () => string | undefined, - suggestion: string, - ) => { - await waitFor( - () => { - expect(stripAnsi(lastFrame() ?? '')).toContain(suggestion); - }, - { timeout: 3000 }, - ); - }; + // createFollowupController.setSuggestion debounces the visibility + // transition by SUGGESTION_DELAY_MS (300ms) before flipping + // followup.state.isVisible to true. The Enter handler reads that flag + // synchronously, so we must wait for the timer to fire before pressing + // Enter — otherwise the suggestion path is skipped and onSubmit never + // runs. 350ms left only ~50ms margin and was eaten by ink 7 / React 19.2 + // mount overhead on slow Windows CI runners. Keep this wait > 300ms + + // generous buffer (renderWithProviders cold start can be 100-200ms). + const SUGGESTION_VISIBLE_WAIT_MS = 700; it('accepts and submits the prompt suggestion on Enter when the buffer is empty', async () => { - const { stdin, unmount, lastFrame } = renderWithProviders( + const { stdin, unmount } = renderWithProviders( , ); - await waitForPromptSuggestion(lastFrame, 'commit this'); + await wait(SUGGESTION_VISIBLE_WAIT_MS); stdin.write('\r'); + await wait(); - await waitFor(() => { - expect(props.onSubmit).toHaveBeenCalledWith('commit this'); - }); + expect(props.onSubmit).toHaveBeenCalledWith('commit this'); // Enter path must NOT call buffer.insert — it passes text directly to // handleSubmitAndClear. Calling insert would re-fill the buffer after // it was already cleared (the microtask race bug). @@ -283,10 +280,10 @@ describe('InputPrompt', () => { }); it('does not accept the prompt suggestion on shift+tab', async () => { - const { stdin, unmount, lastFrame } = renderWithProviders( + const { stdin, unmount } = renderWithProviders( , ); - await waitForPromptSuggestion(lastFrame, 'commit this'); + await wait(SUGGESTION_VISIBLE_WAIT_MS); stdin.write('\x1b[Z'); // shift+tab await wait(); @@ -305,10 +302,10 @@ describe('InputPrompt', () => { }, ] as UseCommandCompletionReturn['suggestions']; - const { stdin, unmount, lastFrame } = renderWithProviders( + const { stdin, unmount } = renderWithProviders( , ); - await waitForPromptSuggestion(lastFrame, 'commit this'); + await wait(SUGGESTION_VISIBLE_WAIT_MS); stdin.write('\t'); await wait(); From b3e21a56aea64f9d6e2fe425756424fad14adddf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 00:33:09 +0800 Subject: [PATCH 103/129] fix(installer): harden hosted release publishing --- .github/workflows/release.yml | 82 ++++++++++++------- scripts/installation/INSTALLATION_GUIDE.md | 21 +++-- .../installation/install-qwen-standalone.sh | 18 ++-- scripts/tests/install-script.test.js | 79 +++++++++++++----- 4 files changed, 136 insertions(+), 64 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11cba2a80f5..5136a4aa2d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -485,7 +485,6 @@ jobs: ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" - IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail @@ -505,46 +504,17 @@ jobs: upload_release_assets "releases/qwen-code/${RELEASE_TAG}" - if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then - upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" - upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" - upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" - upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" - upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" - upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" - else - echo "Skipping hosted installation asset upload for prerelease ${RELEASE_TAG}." - fi - - name: 'Verify Aliyun OSS Release Assets' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" - IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" - if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then - hosted_tmp_dir="$(mktemp -d)" - trap 'rm -rf "${hosted_tmp_dir}"' EXIT - for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do - url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" - curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" - done - cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { - echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" - diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true - exit 1 - } - (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) - else - echo "Skipping hosted installation asset verification for prerelease ${RELEASE_TAG}." - fi - - name: 'Publish Aliyun OSS Latest VERSION' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} @@ -564,6 +534,58 @@ jobs: exit 1 fi + - name: 'Sync Hosted Installation Assets to Aliyun OSS' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + upload_asset() { + local source="$1" + local key="$2" + ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read + } + + upload_hosted_installation_assets() { + local prefix="$1" + for asset in \ + dist/installation/install-qwen-standalone.sh \ + dist/installation/install-qwen-standalone.ps1 \ + dist/installation/install-qwen-standalone.bat \ + dist/installation/uninstall-qwen-standalone.sh \ + dist/installation/uninstall-qwen-standalone.ps1 \ + dist/installation/SHA256SUMS; do + upload_asset "${asset}" "${prefix}/$(basename "${asset}")" + done + } + + upload_hosted_installation_assets "installation/${RELEASE_TAG}" + + - name: 'Verify Aliyun OSS Hosted Installation Assets' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + hosted_tmp_dir="$(mktemp -d)" + trap 'rm -rf "${hosted_tmp_dir}"' EXIT + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do + url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}" + curl -fsSL --connect-timeout 15 --max-time 300 "${url}" -o "${hosted_tmp_dir}/${asset}" + done + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true + exit 1 + } + (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + - name: 'Create PR to merge release branch into main' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index c1f40c6fcb8..5a11a70a39f 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -60,6 +60,13 @@ Hosted installer assets are staged separately from GitHub Release archives: - `uninstall-qwen-standalone.sh` removes Linux/macOS standalone installs. - `uninstall-qwen-standalone.ps1` removes Windows standalone installs. +The global standalone-suffixed OSS entrypoints are maintained under +`installation/install-qwen-standalone.sh`, +`installation/install-qwen-standalone.ps1`, +`installation/install-qwen-standalone.bat`, +`installation/uninstall-qwen-standalone.sh`, and +`installation/uninstall-qwen-standalone.ps1`. + Build them with: ```bash @@ -69,15 +76,11 @@ npm run package:hosted-installation -- --out-dir dist/installation The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, `install-qwen-standalone.bat`, `uninstall-qwen-standalone.sh`, and `uninstall-qwen-standalone.ps1` files map to the standalone-suffixed hosted URLs -shown above. During a non-dry-run release, the publish workflow uploads their -contents byte-for-byte to -`installation/install-qwen-standalone.sh`, -`installation/install-qwen-standalone.ps1`, and -`installation/install-qwen-standalone.bat`, -`installation/uninstall-qwen-standalone.sh`, and -`installation/uninstall-qwen-standalone.ps1`; the staging command also writes -`SHA256SUMS` for upload verification. The hosted installers intentionally -default to `latest`; on Aliyun OSS this means reading +shown above. The staging command also writes `SHA256SUMS` for upload +verification. During a non-dry-run stable release, the publish workflow uploads +a byte-for-byte snapshot to `installation/vX.Y.Z/` for audit and rollback +instead of overwriting the global `installation/` entrypoint objects. The hosted +installers intentionally default to `latest`; on Aliyun OSS this means reading `releases/qwen-code/latest/VERSION` first, then downloading the matching versioned release directory. Use `--version` or `QWEN_INSTALL_VERSION` to pin a standalone release directly. diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index fefbf4357f4..e2258a7ce69 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -713,15 +713,19 @@ download_file() { local destination="$2" if command_exists curl; then - curl -fL --retry 2 --progress-bar "${url}" -o "${destination}" + curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}" return $? fi if command_exists wget; then + local wget_args=(--tries=3 --timeout=15) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout=300) + fi if wget --help 2>&1 | grep -q -- '--progress'; then - wget --progress=bar:force:noscroll --tries=3 "${url}" -O "${destination}" || return 1 + wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" || return 1 else - wget --tries=3 "${url}" -O "${destination}" || return 1 + wget "${wget_args[@]}" "${url}" -O "${destination}" || return 1 fi return $? fi @@ -734,12 +738,16 @@ url_exists() { local url="$1" if command_exists curl; then - curl -fsIL --retry 1 "${url}" >/dev/null 2>&1 + curl -fsIL --retry 1 --connect-timeout 10 --max-time 30 "${url}" >/dev/null 2>&1 return $? fi if command_exists wget; then - wget -q --spider "${url}" >/dev/null 2>&1 + local wget_args=(--tries=2 --timeout=10) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout=30) + fi + wget -q --spider "${wget_args[@]}" "${url}" >/dev/null 2>&1 return $? fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 25fb4f71485..6c3243c7897 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -136,11 +136,16 @@ describe('installation scripts', () => { 'tar -xzf "${archive_path}" -C "${destination}" || return 1', ); expect(script).toContain( - 'curl -fL --retry 2 --progress-bar "${url}" -o "${destination}"', + 'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"', ); expect(script).toContain( - 'wget --progress=bar:force:noscroll --tries=3 "${url}" -O "${destination}"', + 'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"', ); + expect(script).toContain('wget_args+=(--read-timeout=300)'); + expect(script).toContain( + 'curl -fsIL --retry 1 --connect-timeout 10 --max-time 30 "${url}"', + ); + expect(script).toContain('wget_args+=(--read-timeout=30)'); expect(script).toContain('echo "Downloading ${archive_name}"'); expect(script).not.toContain( 'curl -fsSL --retry 2 "${url}" -o "${destination}"', @@ -1442,9 +1447,17 @@ describe('standalone release packaging', () => { const publishLatestStepIndex = workflow.indexOf( "name: 'Publish Aliyun OSS Latest VERSION'", ); + const syncHostedStepIndex = workflow.indexOf( + "name: 'Sync Hosted Installation Assets to Aliyun OSS'", + ); + const verifyHostedStepIndex = workflow.indexOf( + "name: 'Verify Aliyun OSS Hosted Installation Assets'", + ); expect(syncStepIndex).toBeGreaterThanOrEqual(0); expect(verifyStepIndex).toBeGreaterThan(syncStepIndex); expect(publishLatestStepIndex).toBeGreaterThan(verifyStepIndex); + expect(syncHostedStepIndex).toBeGreaterThan(publishLatestStepIndex); + expect(verifyHostedStepIndex).toBeGreaterThan(syncHostedStepIndex); expect(workflow.slice(syncStepIndex, verifyStepIndex)).not.toContain( 'releases/qwen-code/latest/VERSION', ); @@ -1452,18 +1465,43 @@ describe('standalone release packaging', () => { 'releases/qwen-code/latest/VERSION', ); const syncStep = workflow.slice(syncStepIndex, verifyStepIndex); - expect(syncStep).toContain('IS_STABLE_RELEASE'); - expect(syncStep).toContain( - 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', - ); - expect(syncStep).toContain( - 'Skipping hosted installation asset upload for prerelease', - ); - expect(workflow).toContain('installation/install-qwen-standalone.sh'); - expect(workflow).toContain('installation/install-qwen-standalone.bat'); - expect(workflow).toContain('installation/install-qwen-standalone.ps1'); - expect(workflow).toContain('installation/uninstall-qwen-standalone.sh'); - expect(workflow).toContain('installation/uninstall-qwen-standalone.ps1'); + expect(syncStep).not.toContain('dist/installation/'); + expect(syncStep).not.toContain('installation/install-qwen-standalone.sh'); + const syncHostedStep = workflow.slice( + syncHostedStepIndex, + verifyHostedStepIndex, + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.sh', + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.bat', + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.ps1', + ); + expect(syncHostedStep).toContain( + 'dist/installation/uninstall-qwen-standalone.sh', + ); + expect(syncHostedStep).toContain( + 'dist/installation/uninstall-qwen-standalone.ps1', + ); + expect(syncHostedStep).toContain( + 'upload_hosted_installation_assets "installation/${RELEASE_TAG}"', + ); + expect(workflow).not.toContain('"installation/install-qwen-standalone.sh"'); + expect(workflow).not.toContain( + '"installation/install-qwen-standalone.bat"', + ); + expect(workflow).not.toContain( + '"installation/install-qwen-standalone.ps1"', + ); + expect(workflow).not.toContain( + '"installation/uninstall-qwen-standalone.sh"', + ); + expect(workflow).not.toContain( + '"installation/uninstall-qwen-standalone.ps1"', + ); expect(workflow).toContain('--acl public-read'); expect(workflow).toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', @@ -1475,14 +1513,15 @@ describe('standalone release packaging', () => { 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', ); const verifyStep = workflow.slice(verifyStepIndex, publishLatestStepIndex); - expect(verifyStep).toContain('IS_STABLE_RELEASE'); - expect(verifyStep).toContain( - 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', + expect(verifyStep).not.toContain('hosted_tmp_dir'); + const verifyHostedStep = workflow.slice(verifyHostedStepIndex); + expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); + expect(verifyHostedStep).toContain( + 'url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}"', ); - expect(verifyStep).toContain( - 'Skipping hosted installation asset verification for prerelease', + expect(verifyHostedStep).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${url}"', ); - expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); expect(workflow).toContain( 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS"', ); From 0994a809aad398382526b784923eebc92af50460 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 15:52:33 +0800 Subject: [PATCH 104/129] fix(installer): harden Windows latest pointer parsing --- scripts/installation/install-qwen-standalone.bat | 9 ++++++--- scripts/tests/install-script.test.js | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 3e00d500f75..7a5e9b5368f 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -632,9 +632,12 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -set "QWEN_VERSION_POINTER_FILE=!TEMP_VERSION_FILE!" -for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = (Get-Content -LiteralPath $env:QWEN_VERSION_POINTER_FILE -Raw).Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" -set "QWEN_VERSION_POINTER_FILE=" +set "VERSION_POINTER_VALUE=" +for /f "usebackq delims=" %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" +set "QWEN_VERSION_VALUE=!VERSION_POINTER_VALUE!" +for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" +set "QWEN_VERSION_VALUE=" +set "VERSION_POINTER_VALUE=" if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 set "TEMP_VERSION_FILE=" set "QWEN_OSS_LATEST_VERSION_URL=" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 6c3243c7897..5685cd8a4c4 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -207,6 +207,9 @@ describe('installation scripts', () => { expect(script).toContain('Installed to:'); expect(script).toContain('Uninstall:'); expect(script).toContain('uninstall-qwen-standalone.ps1'); + expect(script).toContain('VERSION_POINTER_VALUE'); + expect(script).toContain('IsNullOrWhiteSpace($value)'); + expect(script).not.toContain('QWEN_VERSION_POINTER_FILE'); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); From 63ec525f88106325eeec4b296fe50c354aab0736 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 16:00:42 +0800 Subject: [PATCH 105/129] fix(installer): bound Windows download timeouts --- .../installation/install-qwen-standalone.bat | 4 ++-- .../installation/install-qwen-standalone.ps1 | 4 ++-- scripts/tests/install-script.test.js | 17 +++++++++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 7a5e9b5368f..424579e4bbd 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -596,7 +596,7 @@ 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 -#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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +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 -#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 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -605,7 +605,7 @@ exit /b %PS_STATUS% :DownloadFileQuiet set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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=" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 80074378fe2..7eb4a37777d 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -30,13 +30,13 @@ function Download-File { $global:ProgressPreference = 'SilentlyContinue' try { if (Get-Command curl.exe -ErrorAction SilentlyContinue) { - curl.exe -sSfLo $OutFile $Url + curl.exe --connect-timeout 15 --max-time 300 -sSfLo $OutFile $Url if ($LASTEXITCODE -ne 0) { throw "curl.exe download failed (exit code $LASTEXITCODE)" } return } - Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300 } finally { $global:ProgressPreference = $prevProgressPreference } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 5685cd8a4c4..5d03cd60eb3 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -184,7 +184,13 @@ describe('installation scripts', () => { expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); expect(script).toContain('Invoke-WebRequest'); - expect(script).toContain('& $curl -#fSLo'); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 -#fSLo', + ); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 -fsSLo', + ); + expect(script).toContain('-TimeoutSec 300'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -265,6 +271,10 @@ describe('installation scripts', () => { expect(script).toContain('$curl = $env:QWEN_INSTALL_CURL_EXE'); expect(script).toContain('QWEN_INSTALL_CURL_EXE'); expect(script).toContain('Get-Command curl.exe -CommandType Application'); + expect(script).toContain('--connect-timeout 15 --max-time 300 -#fSLo'); + expect(script).toContain('--connect-timeout 15 --max-time 300 -fsSLo'); + expect(script).toContain('Invoke-WebRequest'); + expect(script).toContain('-TimeoutSec 300'); expect(script).toContain( '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', ); @@ -743,7 +753,10 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); expect(installPowerShellSource).toContain('Invoke-WebRequest'); expect(installPowerShellSource).toContain('Download-File'); - expect(installPowerShellSource).toContain('curl.exe -sSfLo'); + expect(installPowerShellSource).toContain( + 'curl.exe --connect-timeout 15 --max-time 300 -sSfLo', + ); + expect(installPowerShellSource).toContain('-TimeoutSec 300'); expect(installPowerShellSource).toContain( "$global:ProgressPreference = 'SilentlyContinue'", ); From 0155a4fd883b1e6dc1d4421dbea6a2d033c8dd2e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 16:59:30 +0800 Subject: [PATCH 106/129] fix(installer): bound hosted installer probes --- .github/workflows/release.yml | 4 ++-- scripts/installation/install-qwen-standalone.bat | 2 +- scripts/installation/install-qwen-standalone.ps1 | 6 ++++++ scripts/installation/install-qwen-standalone.sh | 8 ++++++-- scripts/tests/install-script.test.js | 14 +++++++++++++- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5136a4aa2d2..4c6d2ec06c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -402,7 +402,7 @@ jobs: set -euo pipefail tmp_dir="$(mktemp -d)" - curl -fsSL "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" + curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" echo "${OSSUTIL_SHA256} ${tmp_dir}/ossutil.zip" | sha256sum -c - unzip -q "${tmp_dir}/ossutil.zip" -d "${tmp_dir}" @@ -528,7 +528,7 @@ jobs: printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" + latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" exit 1 diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 424579e4bbd..9ecf16ebe2d 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -586,7 +586,7 @@ exit /b %PS_STATUS% set "QWEN_CHECK_URL=%~1" rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 set "PS_STATUS=%ERRORLEVEL%" set "QWEN_CHECK_URL=" exit /b %PS_STATUS% diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 7eb4a37777d..c1c4420edd1 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -271,6 +271,12 @@ function Update-CurrentShell { return } + 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 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index e2258a7ce69..b823d466ed2 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -594,12 +594,16 @@ download_text() { local url="$1" if command_exists curl; then - curl -fsSL --retry 2 "${url}" + curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}" return $? fi if command_exists wget; then - wget -q --tries=3 -O - "${url}" + local wget_args=(--tries=3 --timeout=10) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout=30) + fi + wget -q "${wget_args[@]}" -O - "${url}" return $? fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 5d03cd60eb3..c3f3223b433 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -138,6 +138,10 @@ describe('installation scripts', () => { expect(script).toContain( 'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"', ); + expect(script).toContain( + 'curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}"', + ); + expect(script).toContain('wget -q "${wget_args[@]}" -O - "${url}"'); expect(script).toContain( 'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"', ); @@ -191,6 +195,8 @@ describe('installation scripts', () => { '& $curl --connect-timeout 15 --max-time 300 -fsSLo', ); expect(script).toContain('-TimeoutSec 300'); + expect(script).toContain('$request.Timeout = 10000'); + expect(script).toContain('$request.ReadWriteTimeout = 30000'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -782,6 +788,9 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('current-cmd-shim.txt'); expect(installPowerShellSource).toContain('Test-WritableDirectory'); expect(installPowerShellSource).toContain('Qwen Code current-session shim'); + expect(installPowerShellSource).toContain( + 'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.', + ); expect(installPowerShellSource).not.toContain('doskey.exe'); expect(installPowerShellSource).toContain( 'qwen is ready to use in this PowerShell session.', @@ -1519,11 +1528,14 @@ describe('standalone release packaging', () => { '"installation/uninstall-qwen-standalone.ps1"', ); expect(workflow).toContain('--acl public-read'); + expect(workflow).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}"', + ); expect(workflow).toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', ); expect(workflow).toContain( - 'latest_version="$(curl -fsSL "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d', + 'latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d', ); expect(workflow).not.toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', From 308358d716b63a46ac4f645a62f605951b85de29 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 17:02:49 +0800 Subject: [PATCH 107/129] fix(release): make ossutil download configurable --- .github/workflows/release.yml | 4 ++-- scripts/tests/install-script.test.js | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c6d2ec06c6..55102d522e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -396,8 +396,8 @@ jobs: if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: - OSSUTIL_URL: 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' - OSSUTIL_SHA256: 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' + OSSUTIL_URL: "${{ vars.OSSUTIL_URL || 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' }}" + OSSUTIL_SHA256: "${{ vars.OSSUTIL_SHA256 || 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' }}" run: |- set -euo pipefail diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c3f3223b433..1ac94c0162e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1458,6 +1458,8 @@ describe('standalone release packaging', () => { expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); expect(workflow).toContain('vars.ALIYUN_OSS_ENDPOINT'); + expect(workflow).toContain('vars.OSSUTIL_URL'); + expect(workflow).toContain('vars.OSSUTIL_SHA256'); expect(workflow).toContain('releases/qwen-code/${RELEASE_TAG}'); expect(workflow).toContain('releases/qwen-code/latest'); expect(workflow).not.toContain( From 3eb6d6c7c3ea2fdc5049a5b165afcb569dcfbb22 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 13:44:34 +0800 Subject: [PATCH 108/129] fix(installer): address hosted release review feedback --- .github/workflows/release.yml | 89 +-- .../installation/install-qwen-standalone.bat | 16 +- .../installation/install-qwen-standalone.ps1 | 28 +- .../installation/install-qwen-standalone.sh | 72 ++- .../installation/uninstall-qwen-standalone.sh | 49 +- scripts/tests/install-script.test.js | 513 ++++++++++++++---- scripts/upload-aliyun-oss-assets.js | 126 +++++ scripts/verify-installation-release.js | 100 ++-- 8 files changed, 777 insertions(+), 216 deletions(-) create mode 100644 scripts/upload-aliyun-oss-assets.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23577102c15..4eea99b51ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -413,9 +413,11 @@ jobs: fi chmod +x "${ossutil_path}" - sudo install -m 0755 "${ossutil_path}" /usr/local/bin/ossutil + mkdir -p "${HOME}/.local/bin" + install -m 0755 "${ossutil_path}" "${HOME}/.local/bin/ossutil" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" rm -rf "${tmp_dir}" - ossutil >/dev/null + "${HOME}/.local/bin/ossutil" >/dev/null - name: 'Configure Aliyun OSS Credentials' if: |- @@ -464,16 +466,18 @@ jobs: IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' run: |- + set -euo pipefail + PRERELEASE_FLAG="" if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then PRERELEASE_FLAG="--prerelease" fi + mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) + gh release create "${RELEASE_TAG}" \ dist/cli.js \ - dist/standalone/qwen-code-*.tar.gz \ - dist/standalone/qwen-code-*.zip \ - dist/standalone/SHA256SUMS \ + "${release_assets[@]}" \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "${PREVIOUS_RELEASE_TAG}" \ @@ -489,20 +493,12 @@ jobs: run: |- set -euo pipefail - upload_asset() { - local source="$1" - local key="$2" - ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - } - - upload_release_assets() { - local prefix="$1" - for asset in dist/standalone/qwen-code-*.tar.gz dist/standalone/qwen-code-*.zip dist/standalone/SHA256SUMS; do - upload_asset "${asset}" "${prefix}/$(basename "${asset}")" - done - } - - upload_release_assets "releases/qwen-code/${RELEASE_TAG}" + mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "releases/qwen-code/${RELEASE_TAG}" \ + "${release_assets[@]}" - name: 'Verify Aliyun OSS Release Assets' if: |- @@ -543,26 +539,24 @@ jobs: run: |- set -euo pipefail - upload_asset() { - local source="$1" - local key="$2" - ossutil cp "${source}" "oss://${ALIYUN_OSS_BUCKET}/${key}" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - } - - upload_hosted_installation_assets() { - local prefix="$1" - for asset in \ - dist/installation/install-qwen-standalone.sh \ - dist/installation/install-qwen-standalone.ps1 \ - dist/installation/install-qwen-standalone.bat \ - dist/installation/uninstall-qwen-standalone.sh \ - dist/installation/uninstall-qwen-standalone.ps1 \ - dist/installation/SHA256SUMS; do - upload_asset "${asset}" "${prefix}/$(basename "${asset}")" - done - } - - upload_hosted_installation_assets "installation/${RELEASE_TAG}" + hosted_assets=( + dist/installation/install-qwen-standalone.sh + dist/installation/install-qwen-standalone.ps1 + dist/installation/install-qwen-standalone.bat + dist/installation/uninstall-qwen-standalone.sh + dist/installation/uninstall-qwen-standalone.ps1 + dist/installation/SHA256SUMS + ) + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "installation/${RELEASE_TAG}" \ + "${hosted_assets[@]}" + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "installation" \ + "${hosted_assets[@]}" - name: 'Verify Aliyun OSS Hosted Installation Assets' if: |- @@ -575,16 +569,25 @@ jobs: hosted_tmp_dir="$(mktemp -d)" trap 'rm -rf "${hosted_tmp_dir}"' EXIT + mkdir -p "${hosted_tmp_dir}/versioned" "${hosted_tmp_dir}/global" for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}" - curl -fsSL --connect-timeout 15 --max-time 300 "${url}" -o "${hosted_tmp_dir}/${asset}" + global_url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" + curl -fsSL --connect-timeout 15 --max-time 300 "${url}" -o "${hosted_tmp_dir}/versioned/${asset}" + curl -fsSL --connect-timeout 15 --max-time 300 "${global_url}" -o "${hosted_tmp_dir}/global/${asset}" done - cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS" || { echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" - diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS" || true + exit 1 + } + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS" || { + echo "::error::Global hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS" || true exit 1 } - (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + (cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS) + (cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS) - name: 'Create PR to merge release branch into main' if: |- diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 9ecf16ebe2d..c1270f3f78d 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -5,6 +5,9 @@ REM This script intentionally does not install Node.js or change npm config. setlocal enabledelayedexpansion +call :ValidateRawEnvironmentOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + set "SOURCE=unknown" set "METHOD=" if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" @@ -314,6 +317,12 @@ if /i not "!DISPLAY_VERSION!"=="latest" ( echo Installing Qwen Code version: !DISPLAY_VERSION! exit /b 0 +:ValidateRawEnvironmentOptions +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" +if %ERRORLEVEL% EQU 0 exit /b 0 +echo ERROR: installer options contain unsafe command characters. +exit /b 1 + :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" @@ -453,10 +462,13 @@ exit /b 1 :DetectTarget set "TARGET=" +rem Keep :DetectTarget in sync with RELEASE_TARGETS in scripts/build-standalone-release.js. if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITECTURE!"=="ARM64" set "TARGET=win-arm64" if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="ARM64" set "TARGET=win-arm64" if "!TARGET!"=="" ( echo WARNING: Standalone archive is not available for this Windows architecture. exit /b 1 @@ -586,7 +598,7 @@ exit /b %PS_STATUS% set "QWEN_CHECK_URL=%~1" rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = 'HEAD'; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; try { $response = $request.GetResponse(); $response.Close(); exit 0 } catch { exit 1 }" >nul 2>&1 +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Test-QwenUrl($method, $range) { try { $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = $method; if ($range) { $request.Headers.Add('Range', 'bytes=0-0') }; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; $response = $request.GetResponse(); $response.Close(); return $true } catch { return $false } }; if (Test-QwenUrl 'HEAD' $false) { exit 0 }; if (Test-QwenUrl 'GET' $true) { exit 0 }; exit 1" >nul 2>&1 set "PS_STATUS=%ERRORLEVEL%" set "QWEN_CHECK_URL=" exit /b %PS_STATUS% @@ -747,7 +759,7 @@ if not "!ARCHIVE_PATH!"=="" ( call :DetectTarget if !ERRORLEVEL! NEQ 0 exit /b 2 - set "ARCHIVE_NAME=qwen-code-win-x64.zip" + set "ARCHIVE_NAME=qwen-code-!TARGET!.zip" set "REQUESTED_MIRROR=!MIRROR!" set "REQUESTED_VERSION_PATH=" set "GITHUB_FALLBACK_BASE_URL=" diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index c1c4420edd1..df29c088171 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -59,14 +59,6 @@ function Get-QwenInstallBinDir { return $env:QWEN_INSTALL_BIN_DIR } - if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { - return Join-Path (Get-QwenInstallBase) 'bin' - } - - if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { - return Join-Path (Get-QwenInstallBase) 'bin' - } - return Join-Path (Get-QwenInstallBase) 'bin' } @@ -203,6 +195,16 @@ function Add-PathCandidate { [void]$Candidates.Add($Directory.Trim().Trim('"')) } +function Test-SystemManagedPathDirectory { + param([string]$Directory) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + return ( + -not [string]::IsNullOrEmpty($normalizedDirectory) -and + $normalizedDirectory -match '\\Microsoft\\WindowsApps$' + ) +} + function Install-CurrentCmdPathShim { param([string]$QwenCommand, [string]$PathValue) @@ -210,9 +212,6 @@ function Install-CurrentCmdPathShim { $candidates = [System.Collections.Generic.List[string]]::new() $preferredDirectories = @() - if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { - $preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' - } if (-not [string]::IsNullOrEmpty($env:APPDATA)) { $preferredDirectories += Join-Path $env:APPDATA 'npm' } @@ -232,6 +231,9 @@ function Install-CurrentCmdPathShim { $userRoot = Get-NormalizedPath -PathValue $env:USERPROFILE foreach ($entry in $pathEntries) { + if (Test-SystemManagedPathDirectory -Directory $entry) { + continue + } $entryNormalized = Get-NormalizedPath -PathValue $entry if ( -not [string]::IsNullOrEmpty($userRoot) -and @@ -335,6 +337,10 @@ $qwenInstallerName = [IO.Path]::GetFileName(([Uri]$qwenInstallerUrl).AbsolutePat if ([string]::IsNullOrEmpty($qwenInstallerName)) { $qwenInstallerName = 'install-qwen-standalone.bat' } +if ([string]::IsNullOrEmpty($env:TEMP)) { + Write-Error "TEMP environment variable is not set. Please set TEMP to a writable directory." + exit 1 +} $qwenInstallerPath = Join-Path $env:TEMP $qwenInstallerName $qwenChecksumsPath = Join-Path $env:TEMP 'qwen-installation-SHA256SUMS' diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index b823d466ed2..e4d881ffbfe 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -511,12 +511,16 @@ maybe_update_shell_path() { fi ;; */fish) rc_file="${HOME}/.config/fish/config.fish" ;; - *) rc_file="${HOME}/.profile" ;; + *) + log_warning "Unsupported shell for automatic PATH update: ${SHELL:-unknown}. Add ${install_bin_dir} to PATH manually." + return 0 + ;; esac [[ -z "${rc_file}" ]] && return 0 - local marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local begin_marker="# Qwen Code PATH block begin" + local end_marker="# Qwen Code PATH block end" local quoted_install_bin_dir quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") local export_line @@ -534,8 +538,9 @@ maybe_update_shell_path() { mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true { echo "" - echo "${marker}" + echo "${begin_marker}" echo "${export_line}" + echo "${end_marker}" } >> "${rc_file}" || { log_warning "Could not write PATH update to ${rc_file}." return 0 @@ -638,9 +643,39 @@ resolve_aliyun_version_path() { echo "${resolved_version_path}" } -# Race two HEAD probes; print "aliyun" or "github" based on which mirror's -# SHA256SUMS responds first, or "timeout" if neither responds before the -# deadline. Caller decides what to do with "timeout" (currently: log it and +# Probe a URL with a HEAD request first, then fall back to a 1-byte ranged GET +# for object stores or CDNs that reject HEAD while still serving the object. +probe_url_available() { + local url="$1" + local timeout="${2:-30}" + + if command_exists curl; then + if curl -fsIL --retry 1 --connect-timeout 10 --max-time "${timeout}" "${url}" >/dev/null 2>&1; then + return 0 + fi + curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}" \ + --range 0-0 -o /dev/null "${url}" >/dev/null 2>&1 + return $? + fi + + if command_exists wget; then + local wget_args=(--tries=2 --timeout=10) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout="${timeout}") + fi + if wget -q --spider "${wget_args[@]}" "${url}" >/dev/null 2>&1; then + return 0 + fi + wget -q "${wget_args[@]}" --header='Range: bytes=0-0' -O /dev/null "${url}" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +# Race two availability probes; print "aliyun" or "github" based on which +# mirror's SHA256SUMS responds first, or "timeout" if neither responds before +# the deadline. Caller decides what to do with "timeout" (currently: log it and # fall back to github). race_mirror_head() { local timeout="${1:-2}" @@ -651,9 +686,9 @@ race_mirror_head() { mkdir -p "${tmpdir}" 2>/dev/null || true register_temp_dir "${tmpdir}" - (curl -fsIL -m "${timeout}" -o /dev/null "${oss_url}" >/dev/null 2>&1 && : > "${tmpdir}/aliyun") & + (probe_url_available "${oss_url}" "${timeout}" && : > "${tmpdir}/aliyun") & local oss_pid=$! - (curl -fsIL -m "${timeout}" -o /dev/null "${gh_url}" >/dev/null 2>&1 && : > "${tmpdir}/github") & + (probe_url_available "${gh_url}" "${timeout}" && : > "${tmpdir}/github") & local gh_pid=$! local winner="" @@ -741,21 +776,7 @@ download_file() { url_exists() { local url="$1" - if command_exists curl; then - curl -fsIL --retry 1 --connect-timeout 10 --max-time 30 "${url}" >/dev/null 2>&1 - return $? - fi - - if command_exists wget; then - local wget_args=(--tries=2 --timeout=10) - if wget --help 2>&1 | grep -q -- '--read-timeout'; then - wget_args+=(--read-timeout=30) - fi - wget -q --spider "${wget_args[@]}" "${url}" >/dev/null 2>&1 - return $? - fi - - return 1 + probe_url_available "${url}" 30 } sha256_file() { @@ -985,10 +1006,13 @@ write_unix_wrapper() { local quoted_qwen_bin quoted_qwen_bin=$(shell_quote "${qwen_bin}") - cat > "${wrapper_path}" < "${wrapper_path}" </dev/null || continue + grep -qF "${begin_marker}" "${rc_file}" 2>/dev/null || + grep -qF "${legacy_marker}" "${rc_file}" 2>/dev/null || + continue local temp_file temp_file=$(mktemp "${rc_file}.qwen-uninstall.XXXXXX") || { @@ -192,8 +196,40 @@ remove_shell_path_entry() { continue } - awk -v marker="${marker}" ' - index($0, marker) { check_next = 1; next } + awk -v begin_marker="${begin_marker}" \ + -v end_marker="${end_marker}" \ + -v legacy_marker="${legacy_marker}" ' + function reset_block( i) { + for (i = 1; i <= block_count; i++) { + delete block[i] + } + block_count = 0 + in_block = 0 + } + function flush_block( i) { + for (i = 1; i <= block_count; i++) { + print block[i] + } + reset_block() + } + index($0, begin_marker) { + if (in_block) { + flush_block() + } + in_block = 1 + block_count = 1 + block[block_count] = $0 + next + } + in_block { + block_count++ + block[block_count] = $0 + if (index($0, end_marker)) { + reset_block() + } + next + } + index($0, legacy_marker) { check_next = 1; next } check_next == 1 { check_next = 0 if ($0 ~ /^[[:space:]]*export PATH=/ || @@ -202,6 +238,11 @@ remove_shell_path_entry() { } } { print } + END { + if (in_block) { + flush_block() + } + } ' "${rc_file}" > "${temp_file}" && mv "${temp_file}" "${rc_file}" || { rm -f "${temp_file}" log_warning "Could not remove Qwen Code PATH entry from ${rc_file}." diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 1ac94c0162e..fd22023d2c9 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -15,6 +15,7 @@ const { mkdtempSync, readdirSync, readFileSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -147,7 +148,7 @@ describe('installation scripts', () => { ); expect(script).toContain('wget_args+=(--read-timeout=300)'); expect(script).toContain( - 'curl -fsIL --retry 1 --connect-timeout 10 --max-time 30 "${url}"', + '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}"'); @@ -164,7 +165,10 @@ describe('installation scripts', () => { ); expect(script).toContain('set -gx PATH ${quoted_install_bin_dir} \\$PATH'); expect(script).toContain('export PATH=${quoted_install_bin_dir}:\\$PATH'); - expect(script).toContain('curl -fsIL -m "${timeout}"'); + expect(script).toContain('Unsupported shell for automatic PATH update'); + expect(script).toContain('# Qwen Code PATH block begin'); + expect(script).toContain('# Qwen Code PATH block end'); + expect(script).toContain('probe_url_available()'); expect(script).toContain('/latest/VERSION'); expect(script).toContain('resolve_aliyun_version_path()'); expect(script).toContain('retrying GitHub mirror'); @@ -245,7 +249,7 @@ describe('installation scripts', () => { expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"'); expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); - expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain('qwen-code-!TARGET!.zip'); expect(script).toContain( 'if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64"', ); @@ -287,6 +291,7 @@ describe('installation scripts', () => { expect(script).toContain( '$request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', ); + expect(script).toContain("Headers.Add('Range', 'bytes=0-0')"); expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); @@ -327,6 +332,17 @@ describe('installation scripts', () => { expect(script).not.toContain( 'ERROR: Failed to remove stale backup directory', ); + expect(script).toContain('call :ValidateRawEnvironmentOptions'); + expect(script).toContain('$rawNames = @('); + expect(script).toContain("'QWEN_INSTALL_VERSION'"); + expect(script.indexOf('$rawNames = @(')).toBeLessThan( + script.indexOf('set "QWEN_VALIDATE_VERSION=!VERSION!"'), + ); + expect(script).toContain('set "ARCHIVE_NAME=qwen-code-!TARGET!.zip"'); + expect(script).toContain('Keep :DetectTarget in sync with RELEASE_TARGETS'); + expect(script).toContain( + 'if /i "!PROCESSOR_ARCHITECTURE!"=="ARM64" set "TARGET=win-arm64"', + ); expect(script).not.toContain('%RANDOM%'); }); @@ -788,6 +804,18 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('current-cmd-shim.txt'); expect(installPowerShellSource).toContain('Test-WritableDirectory'); expect(installPowerShellSource).toContain('Qwen Code current-session shim'); + expect(installPowerShellSource).toContain( + 'TEMP environment variable is not set', + ); + expect(installPowerShellSource).toMatch( + /function Get-QwenInstallBinDir \{[\s\S]*QWEN_INSTALL_BIN_DIR[\s\S]*return Join-Path \(Get-QwenInstallBase\) 'bin'[\s\S]*\}/, + ); + expect(installPowerShellSource).toContain( + 'Test-SystemManagedPathDirectory', + ); + expect(installPowerShellSource).not.toContain( + "$preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\\WindowsApps'", + ); expect(installPowerShellSource).toContain( 'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.', ); @@ -1063,7 +1091,10 @@ describe('standalone release packaging', () => { if (url.endsWith('/SHA256SUMS')) { return new Response(checksumContent); } - return new Response(null, { status: 200 }); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); }, }), ).resolves.not.toThrow(); @@ -1079,16 +1110,42 @@ describe('standalone release packaging', () => { for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { expect(fetchedUrls).toContainEqual([ `https://example.com/qwen-code/v0.0.0/${assetName}`, - 'HEAD', + 'GET', true, ]); } + expect(warnSpy).not.toHaveBeenCalled(); for (const [url] of fetchedUrls) { expect(url).not.toMatch(/install-qwen\.(sh|bat|ps1)$/); expect(url).not.toMatch(/\/install$/); } }); + it('rejects remote release archives whose downloaded hash differs', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + if (assetName === EXPECTED_STANDALONE_ARCHIVE_NAMES[0]) { + return new Response('tampered\n'); + } + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow(/Checksum verification failed for qwen-code-/); + }); + it('rejects a release base URL that is not https', async () => { const { verifyReleaseBaseUrl } = await import( installationReleaseVerificationScriptUrl @@ -1102,7 +1159,7 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); - it('falls back to a ranged GET when a release asset HEAD fails', async () => { + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); const checksumContent = placeholderChecksumContent( @@ -1121,16 +1178,13 @@ describe('standalone release packaging', () => { if (url.endsWith('/SHA256SUMS')) { return new Response(checksumContent); } - // Simulate an object-storage host that disables HEAD: the verifier - // must retry with a 1-byte ranged GET before treating the asset as - // unavailable. if (method === 'HEAD') { return new Response(null, { status: 405 }); } - if (range === 'bytes=0-0') { - return new Response('a', { status: 206 }); - } - return new Response(null, { status: 500 }); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); }, }), ).resolves.not.toThrow(); @@ -1140,8 +1194,8 @@ describe('standalone release packaging', () => { for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { const assetUrl = `https://example.com/qwen-code/v0.0.0/${assetName}`; - expect(fetchedUrls).toContainEqual([assetUrl, 'HEAD', '']); - expect(fetchedUrls).toContainEqual([assetUrl, 'GET', 'bytes=0-0']); + expect(fetchedUrls).toContainEqual([assetUrl, 'GET', '']); + expect(fetchedUrls).not.toContainEqual([assetUrl, 'HEAD', '']); } }); @@ -1166,12 +1220,15 @@ describe('standalone release packaging', () => { if (url.endsWith(`/${unavailableAsset}`)) { return new Response(null, { status: 404 }); } - return new Response(null, { status: 200 }); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); }, }), ).rejects.toThrow( new RegExp( - `Unavailable release asset URL\\(s\\): ${escapeRegExp(unavailableAsset)} \\(.*\\)`, + `Unavailable or invalid release asset\\(s\\): ${escapeRegExp(unavailableAsset)} \\(.*\\)`, ), ); } finally { @@ -1221,7 +1278,10 @@ describe('standalone release packaging', () => { if (url.endsWith('/SHA256SUMS')) { return new Response(checksumContent); } - return new Response(null, { status: 200 }); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); }, }), ).resolves.not.toThrow(); @@ -1230,6 +1290,35 @@ describe('standalone release packaging', () => { } }); + it('prints explicit release asset paths for GitHub release upload', async () => { + const { EXPECTED_RELEASE_ASSET_NAMES, EXPECTED_STANDALONE_ARCHIVE_NAMES } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-list-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + + const output = execFileSync( + process.execPath, + [ + 'scripts/verify-installation-release.js', + '--dir', + tmpDir, + '--list-release-asset-paths', + ], + { encoding: 'utf8' }, + ); + + expect(output.trim().split('\n')).toEqual( + EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => + path.join(tmpDir, assetName), + ), + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -1260,9 +1349,7 @@ describe('standalone release packaging', () => { ).toThrow(/Node\.js runtime for .* must contain/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1305,9 +1392,7 @@ describe('standalone release packaging', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 30_000); @@ -1334,9 +1419,7 @@ describe('standalone release packaging', () => { expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1366,9 +1449,7 @@ describe('standalone release packaging', () => { ).toThrow(/symlink escapes the archive/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1398,9 +1479,7 @@ describe('standalone release packaging', () => { ).toThrow(/symlink cycle/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1430,11 +1509,7 @@ describe('standalone release packaging', () => { ).toThrow(/Unexpected dist asset/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } else { - rmSync('dist/debug-cache.tmp', { force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1448,9 +1523,9 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain('package:installation-assets'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); - expect(workflow).toContain('dist/standalone/qwen-code-*.tar.gz'); - expect(workflow).toContain('dist/standalone/qwen-code-*.zip'); - expect(workflow).toContain('dist/standalone/SHA256SUMS'); + expect(workflow).not.toContain('dist/standalone/qwen-code-*.tar.gz'); + expect(workflow).not.toContain('dist/standalone/qwen-code-*.zip'); + expect(workflow).toContain('--list-release-asset-paths'); expect(workflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); @@ -1460,11 +1535,29 @@ describe('standalone release packaging', () => { expect(workflow).toContain('vars.ALIYUN_OSS_ENDPOINT'); expect(workflow).toContain('vars.OSSUTIL_URL'); expect(workflow).toContain('vars.OSSUTIL_SHA256'); + expect(workflow).not.toContain('sudo install'); + expect(workflow).toContain('${HOME}/.local/bin/ossutil'); + expect(workflow).toContain('${GITHUB_PATH}'); + expect(existsSync('scripts/upload-aliyun-oss-assets.js')).toBe(true); + expect(workflow).toContain('node scripts/upload-aliyun-oss-assets.js'); + expect(workflow.match(/upload_asset\(\)/g) || []).toHaveLength(0); expect(workflow).toContain('releases/qwen-code/${RELEASE_TAG}'); expect(workflow).toContain('releases/qwen-code/latest'); expect(workflow).not.toContain( 'upload_release_assets "releases/qwen-code/latest"', ); + const createReleaseStepIndex = workflow.indexOf( + "name: 'Create GitHub Release and Tag'", + ); + expect(createReleaseStepIndex).toBeGreaterThanOrEqual(0); + const createReleaseStep = workflow.slice(createReleaseStepIndex); + expect(createReleaseStep).toContain('mapfile -t release_assets'); + expect(createReleaseStep).toContain('"${release_assets[@]}"'); + expect(createReleaseStep).not.toContain( + 'dist/standalone/qwen-code-*.tar.gz', + ); + expect(createReleaseStep).not.toContain('dist/standalone/qwen-code-*.zip'); + const syncStepIndex = workflow.indexOf( "name: 'Sync Release Assets to Aliyun OSS'", ); @@ -1513,23 +1606,14 @@ describe('standalone release packaging', () => { expect(syncHostedStep).toContain( 'dist/installation/uninstall-qwen-standalone.ps1', ); + expect(syncHostedStep).toContain('--prefix "installation/${RELEASE_TAG}"'); + expect(syncHostedStep).toContain('--prefix "installation"'); expect(syncHostedStep).toContain( - 'upload_hosted_installation_assets "installation/${RELEASE_TAG}"', - ); - expect(workflow).not.toContain('"installation/install-qwen-standalone.sh"'); - expect(workflow).not.toContain( - '"installation/install-qwen-standalone.bat"', - ); - expect(workflow).not.toContain( - '"installation/install-qwen-standalone.ps1"', - ); - expect(workflow).not.toContain( - '"installation/uninstall-qwen-standalone.sh"', - ); - expect(workflow).not.toContain( - '"installation/uninstall-qwen-standalone.ps1"', + 'dist/installation/install-qwen-standalone.sh', ); - expect(workflow).toContain('--acl public-read'); + const uploadScript = readScript('scripts/upload-aliyun-oss-assets.js'); + expect(uploadScript).toContain("'--acl'"); + expect(uploadScript).toContain("'public-read'"); expect(workflow).toContain( 'curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}"', ); @@ -1549,14 +1633,26 @@ describe('standalone release packaging', () => { expect(verifyHostedStep).toContain( 'url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}"', ); + expect(verifyHostedStep).toContain( + 'global_url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}"', + ); expect(verifyHostedStep).toContain( 'curl -fsSL --connect-timeout 15 --max-time 300 "${url}"', ); + expect(verifyHostedStep).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${global_url}"', + ); + expect(workflow).toContain( + 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS"', + ); + expect(workflow).toContain( + 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS"', + ); expect(workflow).toContain( - 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS"', + '(cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS)', ); expect(workflow).toContain( - '(cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS)', + '(cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS)', ); }); @@ -1676,9 +1772,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(output).not.toContain('rm -rf'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, ); @@ -1784,9 +1878,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 15000, @@ -1910,9 +2002,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(output).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 15000, @@ -1933,8 +2023,9 @@ describe('Linux/macOS installer end-to-end', () => { rcFile, [ 'before', - '# Added by qwen-code installer (multi-qwen shadow fix)', + '# Qwen Code PATH block begin', `export PATH='${installRoot}/bin':$PATH`, + '# Qwen Code PATH block end', 'after', ].join('\n') + '\n', ); @@ -1959,9 +2050,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(settingsJson)).toBe(true); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -1998,9 +2087,84 @@ describe('Linux/macOS installer end-to-end', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'removes installer-owned shell rc PATH blocks even when extra lines are inserted', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Qwen Code PATH block begin', + '# inserted by another tool', + `export PATH='${installRoot}/bin':$PATH`, + '# Qwen Code PATH block end', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe('before\nafter\n'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'preserves malformed shell rc PATH blocks without an end marker', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + 'user content that must stay', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe( + [ + 'before', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + 'user content that must stay', + 'after', + ].join('\n') + '\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); } }, ); @@ -2036,9 +2200,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -2080,9 +2242,159 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(marker)).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'skips shell rc PATH updates for unsupported shells', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'shadow-bin'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'qwen'), '#!/usr/bin/env sh\n'); + chmodSync(path.join(fakeBin, 'qwen'), 0o755); + + const output = runUnixInstaller( + archive, + installRoot, + home, + 'standalone', + { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/tcsh', + }, + ).toString(); + + expect(output).toContain('Unsupported shell for automatic PATH update'); + expect(existsSync(path.join(home, '.profile'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'uses ranged GET fallback when archive HEAD probes fail', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'is_head=0', + 'is_range=0', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' --range|-r) is_range=1; shift ;;', + ' -H) shift; case "$1" in Range:*) is_range=1 ;; esac ;;', + ' -*) case "$1" in *I*) is_head=1 ;; esac ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s %s %s\\n" "$url" "$is_head" "$is_range" >> "$QWEN_FAKE_CURL_LOG"', + 'case "$url" in', + ' */qwen-code-linux-x64.tar.gz)', + ' if [ "$is_head" = "1" ]; then exit 22; fi', + ' if [ "$is_range" = "1" ]; then : > "${dest:-/dev/null}"; exit 0; fi', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest"; exit 0 ;;', + ' */SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest"; exit 0 ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then echo 22.0.0; exit 0; fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + '#!/usr/bin/env sh\necho npm fallback should not run >&2\nexit 1\n', + ); + for (const command of ['uname', 'curl', 'node', 'npm']) { + chmodSync(path.join(fakeBin, command), 0o755); } + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--base-url', + 'https://example.com/qwen-code', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('qwen-code-linux-x64.tar.gz 1 0'); + expect(curlUrls).toContain('qwen-code-linux-x64.tar.gz 0 1'); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); + expect(output).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); } }, 15000, @@ -2115,9 +2427,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(bashrc).toContain(`export PATH='${secondBinDir}':$PATH`); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 15000, @@ -2140,9 +2450,7 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/Checksum verification failed/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -2163,9 +2471,7 @@ describe('Linux/macOS installer end-to-end', () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -2241,9 +2547,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(existsSync(path.join(installDir, 'manifest.json'))).toBe(true); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -2272,9 +2576,7 @@ describe('Linux/macOS installer end-to-end', () => { expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -2746,18 +3048,35 @@ describe('Windows installer end-to-end', () => { }); function ensureMinimalDist() { - if (existsSync('dist')) { - return false; + const backupPath = existsSync('dist') + ? path.join( + tmpdir(), + `qwen-dist-backup-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ) + : null; + if (backupPath) { + renameSync('dist', backupPath); } + mkdirSync('dist/chunks', { recursive: true }); mkdirSync('dist/vendor', { recursive: true }); mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); writeFileSync('dist/cli.js', 'console.log("qwen");\n'); + writeFileSync('dist/chunks/index.js', 'export {};\n'); writeFileSync( 'dist/package.json', JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - return true; + return { backupPath }; +} + +function restoreMinimalDist(state) { + rmSync('dist', { recursive: true, force: true }); + if (state?.backupPath) { + renameSync(state.backupPath, 'dist'); + } } function createFakeNodeArchive(tmpDir, options = {}) { diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js new file mode 100644 index 00000000000..23e45856ed6 --- /dev/null +++ b/scripts/upload-aliyun-oss-assets.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fail, isMainModule, readOptionValue } from './release-script-utils.js'; + +if (isMainModule(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +function main(argv) { + const args = parseUploadArgs(argv); + if (args.help) { + printUsage(); + return; + } + uploadAssets(args); +} + +function printUsage() { + console.log(`Usage: node scripts/upload-aliyun-oss-assets.js [options] ASSET... + +Uploads local release assets to a public Aliyun OSS prefix via ossutil. + +Options: + --bucket NAME OSS bucket name. + --config PATH ossutil config path. + --prefix PREFIX Destination object prefix. + -h, --help Show this help message. +`); +} + +function parseUploadArgs(argv) { + const args = { + assets: [], + bucket: '', + config: '', + help: false, + prefix: '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + if (arg === '--bucket') { + args.bucket = readOptionValue(argv, index, arg); + index += 1; + continue; + } + if (arg === '--config') { + args.config = readOptionValue(argv, index, arg); + index += 1; + continue; + } + if (arg === '--prefix') { + args.prefix = readOptionValue(argv, index, arg).replace(/\/+$/, ''); + index += 1; + continue; + } + if (arg.startsWith('-')) { + fail(`Unknown option: ${arg}`); + } + args.assets.push(arg); + } + + if (args.help) { + return args; + } + if (!args.bucket) { + fail('--bucket requires a value'); + } + if (!args.config) { + fail('--config requires a value'); + } + if (!args.prefix) { + fail('--prefix requires a value'); + } + if (args.assets.length === 0) { + fail('At least one ASSET path is required'); + } + + return args; +} + +function uploadAssets({ assets, bucket, config, prefix }) { + for (const asset of assets) { + const key = `${prefix}/${path.basename(asset)}`; + const result = spawnSync( + 'ossutil', + [ + 'cp', + asset, + `oss://${bucket}/${key}`, + '-c', + config, + '-f', + '--acl', + 'public-read', + ], + { stdio: 'inherit' }, + ); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + fail(`ossutil failed while uploading ${asset}`); + } + } +} + +export { parseUploadArgs, uploadAssets }; diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index b2eb1c6c672..1650e734319 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -6,8 +6,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +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 { @@ -44,6 +47,10 @@ function standaloneArchiveNamesFromReleaseTargets(releaseTargets) { const ARG_DEFS = { '--dir': { key: 'dir', type: 'value' }, '--base-url': { key: 'baseUrl', type: 'value' }, + '--list-release-asset-paths': { + key: 'listReleaseAssetPaths', + type: 'flag', + }, }; if (isMainModule(import.meta.url)) { @@ -64,6 +71,19 @@ async function main() { if (args.dir && args.baseUrl) { fail('Pass --dir or --base-url, not both.'); } + if (args.listReleaseAssetPaths && args.baseUrl) { + fail('Pass --list-release-asset-paths with --dir, not --base-url.'); + } + if (args.listReleaseAssetPaths) { + const dir = path.resolve( + args.dir || path.join(rootDir, 'dist', 'standalone'), + ); + await verifyReleaseDirectory(dir, { silent: true }); + for (const assetPath of releaseAssetPaths(dir)) { + console.log(assetPath); + } + return; + } if (args.baseUrl) { await verifyReleaseBaseUrl(args.baseUrl); return; @@ -77,19 +97,21 @@ function printUsage() { console.log(`Usage: npm run verify:installation-release -- [options] Verifies that an installation release directory contains the expected standalone -archives with matching SHA256SUMS entries. For a release URL, verifies that -SHA256SUMS is reachable, lists the expected archives, and that each archive URL -is reachable without downloading the full archive. +archives with matching SHA256SUMS entries. For a release URL, downloads +SHA256SUMS and the expected archives, then verifies each archive hash. Options: --dir PATH Verify a local release directory. Defaults to dist/standalone. --base-url URL Verify a remote release URL (e.g. a GitHub release download prefix). Cannot be combined with --dir. + --list-release-asset-paths + Verify --dir, then print explicit asset paths for upload. -h, --help Show this help message. `); } -async function verifyReleaseDirectory(dir) { +async function verifyReleaseDirectory(dir, options = {}) { + const { silent = false } = options; const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); assertExpectedArchiveFiles(dir); @@ -106,9 +128,11 @@ async function verifyReleaseDirectory(dir) { } } - console.log( - `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`, - ); + if (!silent) { + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`, + ); + } } async function verifyReleaseBaseUrl(baseUrl, options = {}) { @@ -117,14 +141,11 @@ async function verifyReleaseBaseUrl(baseUrl, options = {}) { const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); assertExpectedChecksumEntries(checksums); - console.warn( - 'WARNING: Remote release verification checks URL reachability only; it does not download archive bodies or verify archive hashes. Run --dir against downloaded assets for checksum verification.', - ); - await assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl); + await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl); console.log( - `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release asset URLs at ${baseUrl}`, + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`, ); } @@ -166,14 +187,26 @@ function assertExpectedArchiveFiles(dir) { } } -async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { +function releaseAssetPaths(dir) { + return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => + path.join(dir, assetName), + ); +} + +async function assertRemoteAssetChecksums( + normalizedBaseUrl, + checksums, + fetchImpl, +) { const failures = []; for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { try { - await assertRemoteAssetAvailable( - new URL(assetName, normalizedBaseUrl).toString(), - fetchImpl, - ); + const assetUrl = new URL(assetName, normalizedBaseUrl).toString(); + const actual = await fetchSha256(assetUrl, fetchImpl); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail(`Checksum verification failed for ${assetName}`); + } } catch (reason) { failures.push({ assetName, @@ -191,31 +224,26 @@ async function assertRemoteAssetsAvailable(normalizedBaseUrl, fetchImpl) { ); } fail( - `Unavailable release asset URL(s): ${failures + `Unavailable or invalid release asset(s): ${failures .map(({ assetName, reason }) => `${assetName} (${reason})`) .join('; ')}`, ); } -async function assertRemoteAssetAvailable(url, fetchImpl) { - let response = await fetchWithTimeout(fetchImpl, url, { method: 'HEAD' }); - if (response.ok) { - await response.body?.cancel?.(); - return; - } - await response.body?.cancel?.(); - - // Some object-storage hosts disable HEAD; fall back to a 1-byte ranged GET - // so the verifier can confirm reachability without downloading the archive. - response = await fetchWithTimeout(fetchImpl, url, { - headers: { - Range: 'bytes=0-0', - }, - }); +async function fetchSha256(url, fetchImpl) { + const response = await fetchWithTimeout(fetchImpl, url); if (!response.ok) { - fail(`Release asset URL is not available: ${url}`); + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); } - await response.body?.cancel?.(); + if (!response.body) { + fail(`Downloaded response has no body: ${url}`); + } + + const hash = crypto.createHash('sha256'); + await pipeline(Readable.fromWeb(response.body), hash); + return hash.digest('hex'); } function formatErrorReason(reason) { @@ -260,6 +288,8 @@ function normalizeHttpsBaseUrl(baseUrl) { export { EXPECTED_STANDALONE_ARCHIVE_NAMES, + EXPECTED_RELEASE_ASSET_NAMES, + releaseAssetPaths, verifyReleaseBaseUrl, verifyReleaseDirectory, }; From 625dfbfa3e763f481f54b24aae222624a9e559e2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 18:18:29 +0800 Subject: [PATCH 109/129] test(installer): keep dist backup on same filesystem --- scripts/tests/install-script.test.js | 30 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index fd22023d2c9..b04452c1f00 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -3048,34 +3048,40 @@ describe('Windows installer end-to-end', () => { }); function ensureMinimalDist() { - const backupPath = existsSync('dist') + const distPath = path.resolve('dist'); + const backupPath = existsSync(distPath) ? path.join( - tmpdir(), + path.dirname(distPath), `qwen-dist-backup-${process.pid}-${Date.now()}-${Math.random() .toString(16) .slice(2)}`, ) : null; if (backupPath) { - renameSync('dist', backupPath); + renameSync(distPath, backupPath); } - mkdirSync('dist/chunks', { recursive: true }); - mkdirSync('dist/vendor', { recursive: true }); - mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); - writeFileSync('dist/cli.js', 'console.log("qwen");\n'); - writeFileSync('dist/chunks/index.js', 'export {};\n'); + mkdirSync(path.join(distPath, 'chunks'), { recursive: true }); + mkdirSync(path.join(distPath, 'vendor'), { recursive: true }); + mkdirSync(path.join(distPath, 'bundled/qc-helper/docs'), { + recursive: true, + }); + writeFileSync(path.join(distPath, 'cli.js'), 'console.log("qwen");\n'); + writeFileSync(path.join(distPath, 'chunks/index.js'), 'export {};\n'); writeFileSync( - 'dist/package.json', + path.join(distPath, 'package.json'), JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - return { backupPath }; + return { backupPath, distPath }; } function restoreMinimalDist(state) { - rmSync('dist', { recursive: true, force: true }); + rmSync(state?.distPath || path.resolve('dist'), { + recursive: true, + force: true, + }); if (state?.backupPath) { - renameSync(state.backupPath, 'dist'); + renameSync(state.backupPath, state.distPath); } } From 3f60c2c0e84acb0978939f5aac94aa68102ae1bc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 19:16:42 +0800 Subject: [PATCH 110/129] fix(installer): address remaining review feedback on PR #3828 - Remove REQUIRE_CHECKSUM dead code, always hard-fail on checksum issues - Add JSDoc to HOSTED_INSTALLER_BEHAVIOR_PATTERNS explaining its purpose - Add credential cleanup trap for ossutilconfig in release workflow - Add 3-attempt retry with exponential backoff for OSS uploads - Tighten findstr SOURCE regex to require leading letter --- .github/workflows/release.yml | 1 + scripts/build-hosted-installation-assets.js | 2 ++ .../installation/install-qwen-standalone.bat | 29 +++++-------------- scripts/upload-aliyun-oss-assets.js | 23 +++++++++++++-- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4eea99b51ba..cb6ea801311 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -434,6 +434,7 @@ jobs: exit 1 fi + trap 'rm -f "${RUNNER_TEMP}/.ossutilconfig"' EXIT ossutil config \ -e "${ALIYUN_OSS_ENDPOINT}" \ -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index ee3d7be6c24..b171e9b7cb4 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -49,6 +49,8 @@ const HOSTED_INSTALLATION_ASSETS = [ const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( ({ output }) => output, ); +/** Regex guards that verify each installer script contains required behaviors. + * Build fails if a pattern is missing, preventing broken entrypoints from shipping. */ const HOSTED_INSTALLER_BEHAVIOR_PATTERNS = { 'install-qwen-standalone.sh': [ { diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index c1270f3f78d..beb58f1baea 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -454,7 +454,7 @@ exit /b 1 :ValidateSource if "!SOURCE!"=="unknown" exit /b 0 -echo(!SOURCE!| findstr /R /C:"^[A-Za-z0-9._-][A-Za-z0-9._-]*$" >nul +echo(!SOURCE!| findstr /R /C:"^[A-Za-z][A-Za-z0-9._-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. @@ -668,13 +668,10 @@ set "CHECKSUM_SOURCE=%~2" set "ARCHIVE_NAME=%~3" set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" set "TEMP_CHECKSUM=" -set "REQUIRE_CHECKSUM=1" - if "!CHECKSUM_FILE!"=="" ( for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" ) else ( if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( - set "REQUIRE_CHECKSUM=1" call :CreateTempFile "qwen-code-checksums" if !ERRORLEVEL! NEQ 0 exit /b 1 set "TEMP_CHECKSUM=!TEMP_FILE!" @@ -689,12 +686,8 @@ if "!CHECKSUM_FILE!"=="" ( ) if not exist "!CHECKSUM_FILE!" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: SHA256SUMS not found; cannot verify archive. - exit /b 1 - ) - echo WARNING: SHA256SUMS not found; skipping checksum verification. - exit /b 0 + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 ) set "EXPECTED_HASH=" @@ -709,12 +702,8 @@ for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( if "!EXPECTED_HASH!"=="" ( if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. - exit /b 1 - ) - echo WARNING: Checksum entry for !ARCHIVE_NAME! not found; skipping checksum verification. - exit /b 0 + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 ) set "ACTUAL_HASH=" @@ -727,12 +716,8 @@ set "QWEN_HASH_FILE=" if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 if "!ACTUAL_HASH!"=="" ( - if "!REQUIRE_CHECKSUM!"=="1" ( - echo ERROR: Could not calculate SHA-256 checksum for archive. - exit /b 1 - ) - echo WARNING: Could not calculate SHA-256 checksum; skipping checksum verification. - exit /b 0 + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 ) if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index 23e45856ed6..ca7b4b5c951 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -96,9 +96,18 @@ function parseUploadArgs(argv) { return args; } +const MAX_UPLOAD_ATTEMPTS = 3; +const INITIAL_BACKOFF_MS = 2000; + function uploadAssets({ assets, bucket, config, prefix }) { for (const asset of assets) { const key = `${prefix}/${path.basename(asset)}`; + uploadWithRetry(asset, bucket, key, config); + } +} + +function uploadWithRetry(asset, bucket, key, config) { + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { const result = spawnSync( 'ossutil', [ @@ -117,10 +126,20 @@ function uploadAssets({ assets, bucket, config, prefix }) { if (result.error) { throw result.error; } - if (result.status !== 0) { - fail(`ossutil failed while uploading ${asset}`); + if (result.status === 0) { + return; + } + if (attempt < MAX_UPLOAD_ATTEMPTS) { + const delayMs = INITIAL_BACKOFF_MS * 2 ** (attempt - 1); + console.warn( + `Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`, + ); + spawnSync('sleep', [String(delayMs / 1000)]); } } + fail( + `ossutil failed after ${MAX_UPLOAD_ATTEMPTS} attempts while uploading ${asset}`, + ); } export { parseUploadArgs, uploadAssets }; From 273a01252f4697e149e366473ccbb6a31d68d38b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 23:18:13 +0800 Subject: [PATCH 111/129] fix(release): correct OSS credentials lifetime and mirror probe fallback - release.yml: remove `trap EXIT` inside the Configure step; it deleted ${RUNNER_TEMP}/.ossutilconfig as soon as the configure shell exited, so every subsequent step (publish/sync/verify) lost the credentials. Move credential cleanup to a final `if: always()` step at the job tail. - install-qwen-standalone.sh: drop the predictable PID-based mktemp -d fallback in race_mirror_head; if mktemp fails, return "github" instead of using /tmp/qwen-mirror.$$ which a local attacker could pre-create to bias mirror selection. --- .github/workflows/release.yml | 7 ++++++- scripts/installation/install-qwen-standalone.sh | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb6ea801311..07f67b0cd7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -434,7 +434,6 @@ jobs: exit 1 fi - trap 'rm -f "${RUNNER_TEMP}/.ossutilconfig"' EXIT ossutil config \ -e "${ALIYUN_OSS_ENDPOINT}" \ -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ @@ -590,6 +589,12 @@ jobs: (cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS) (cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS) + - name: 'Cleanup Aliyun OSS Credentials' + if: |- + ${{ always() && needs.prepare.outputs.is_dry_run == 'false' }} + run: |- + rm -f "${RUNNER_TEMP}/.ossutilconfig" + - name: 'Create PR to merge release branch into main' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index e4d881ffbfe..4a5c810c9b0 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -682,8 +682,13 @@ race_mirror_head() { local gh_url="$2" local oss_url="$3" local tmpdir - tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null) || tmpdir="/tmp/qwen-mirror.$$" - mkdir -p "${tmpdir}" 2>/dev/null || true + if ! tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null); then + # Refuse to fall back to a predictable PID-based path; a local attacker + # could pre-create it to influence mirror selection. + echo "mirror probe: mktemp failed" >&2 + echo "github" + return 0 + fi register_temp_dir "${tmpdir}" (probe_url_available "${oss_url}" "${timeout}" && : > "${tmpdir}/aliyun") & From a1ef8697b73c706529f4cfcc2ba12794b38082e1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 23:34:25 +0800 Subject: [PATCH 112/129] fix(installer): address review feedback round 2 Workflow: - Move 'Publish Aliyun OSS Latest VERSION' to run after the hosted installer assets are uploaded and verified, so the latest/VERSION pointer only flips once every release artifact is in place. Previously a hosted-sync failure could leave the pointer ahead of the actual installer scripts. upload-aliyun-oss-assets.js: - Replace `spawnSync('sleep', ...)` retry backoff with an Atomics.wait-based cross-platform sleep so retries also work on Windows runners. install-qwen-standalone.bat: - :DetectTarget no longer emits TARGET=win-arm64 because RELEASE_TARGETS has no win-arm64 archive; ARM64 hosts now fall through to the unsupported-arch branch and (in detect mode) get the npm fallback instead of a 404. - Add QWEN_INSTALL_CURL_EXE to :ValidateRawEnvironmentOptions so this curl override is checked for shell metacharacters like every other knob. - Replace `call echo %%i>>...` with plain `echo %%i>>...` when capturing pre-install qwen.cmd paths; `call` triggered an extra parse pass that could interpret &/|/<,>/etc. inside a directory name as command separators. - Add `--retry 2` to curl.exe downloads (`:DownloadFile` / `:DownloadFileQuiet`) to match the shell installer. - Include expected vs actual hash in the checksum-mismatch error message. install-qwen-standalone.ps1: - Stage the downloaded installer at a cryptographically random temp path (`qwen-installer-.bat`) so a same-user attacker cannot pre-stage a malicious .bat at a predictable path and race the verify/execute window. - Atomically install the current-session cmd shim by writing to a sibling `.new` temp file then renaming, so a partial write cannot leave a half-written shim on PATH. - Add `--retry 2` to the curl.exe download path. - Include expected vs actual hash in the checksum-mismatch error message. install-qwen-standalone.sh: - Include expected vs actual hash in the checksum-mismatch error message. uninstall-qwen-standalone.ps1: - Accept `-Purge` and `-Help` parameters; previously every CLI flag was silently dropped, so users running with `-Purge` got no purge and no error. `-Purge` maps to `QWEN_UNINSTALL_PURGE=1`. uninstall-qwen-standalone.sh: - `remove_install_wrapper` additionally requires the wrapper file to start with a `#!` shebang before it deletes it; a user-authored script that just happens to mention the install path now stays untouched. verify-installation-release.js, build-hosted-installation-assets.js: - Include expected vs actual hash in the checksum-mismatch error messages. scripts/tests/install-script.test.js: - Update assertions for the new error wording, the curl `--retry 2` flag, the dropped ARM64 detection, and the new release-step ordering. --- .github/workflows/release.yml | 42 ++++++++++-------- scripts/build-hosted-installation-assets.js | 4 +- .../installation/install-qwen-standalone.bat | 21 +++++---- .../installation/install-qwen-standalone.ps1 | 19 +++++--- .../installation/install-qwen-standalone.sh | 2 +- .../uninstall-qwen-standalone.ps1 | 30 +++++++++++-- .../installation/uninstall-qwen-standalone.sh | 8 ++++ scripts/tests/install-script.test.js | 43 +++++++++++-------- scripts/upload-aliyun-oss-assets.js | 8 +++- scripts/verify-installation-release.js | 11 +++-- 10 files changed, 128 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07f67b0cd7d..d8f2765dd8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -511,25 +511,6 @@ jobs: npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" - - name: 'Publish Aliyun OSS Latest VERSION' - if: |- - ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} - env: - ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" - ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" - RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' - run: |- - set -euo pipefail - - printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" - ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read - - latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" - if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then - echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" - exit 1 - fi - - name: 'Sync Hosted Installation Assets to Aliyun OSS' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} @@ -589,6 +570,29 @@ jobs: (cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS) (cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS) + - name: 'Publish Aliyun OSS Latest VERSION' + # Run last so the `latest/VERSION` pointer only flips after every + # release asset and hosted installer object has been uploaded and + # verified. If any earlier step fails, the pointer keeps referring + # to the previously-good release. + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" + ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read + + latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" + if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then + echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" + exit 1 + fi + - name: 'Cleanup Aliyun OSS Credentials' if: |- ${{ always() && needs.prepare.outputs.is_dry_run == 'false' }} diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index b171e9b7cb4..42c88c14a7b 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -263,7 +263,9 @@ async function assertHostedInstallationAssetChecksums(outDir) { const actual = await sha256File(path.join(outDir, output)); if (actual !== expected) { - fail(`Checksum verification failed for ${output}`); + fail( + `Checksum mismatch for ${output}: expected ${expected}, got ${actual}`, + ); } } } diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index beb58f1baea..ecd0e683922 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -214,16 +214,19 @@ REM per-tool bin directories plus everything `where qwen` returns. call :CreateTempFile "qwen-pre-install" if !ERRORLEVEL! NEQ 0 exit /b 1 set "PRE_INSTALL_QWENS_FILE=!TEMP_FILE!" -for /f "delims=" %%i in ('where qwen 2^>nul') do call echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +rem Avoid `call echo` here: `call` triggers an extra parse pass on the +rem expanded path, so a directory containing &/|/<,>/etc. would be re-evaluated +rem as command separators. Plain `echo` writes the literal value. +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" for %%c in ( "!USERPROFILE!\.opencode\bin\qwen.cmd" "!APPDATA!\npm\qwen.cmd" "!USERPROFILE!\.bun\bin\qwen.cmd" "!LOCALAPPDATA!\bun\bin\qwen.cmd" "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" -) do if exist %%c call echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( - if exist "%%i\qwen.cmd" call echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" ) set "PRE_INSTALL_QWENS_LIST=" if exist "!PRE_INSTALL_QWENS_FILE!" ( @@ -318,7 +321,7 @@ echo Installing Qwen Code version: !DISPLAY_VERSION! exit /b 0 :ValidateRawEnvironmentOptions -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO','QWEN_INSTALL_CURL_EXE'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: installer options contain unsafe command characters. exit /b 1 @@ -463,12 +466,12 @@ exit /b 1 :DetectTarget set "TARGET=" rem Keep :DetectTarget in sync with RELEASE_TARGETS in scripts/build-standalone-release.js. +rem RELEASE_TARGETS currently has no win-arm64 entry, so ARM64 falls through +rem to the unsupported-architecture branch and the caller can fall back to npm. if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" -if /i "!PROCESSOR_ARCHITECTURE!"=="ARM64" set "TARGET=win-arm64" if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" -if /i "!PROCESSOR_ARCHITEW6432!"=="ARM64" set "TARGET=win-arm64" if "!TARGET!"=="" ( echo WARNING: Standalone archive is not available for this Windows architecture. exit /b 1 @@ -608,7 +611,7 @@ 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 -#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 }" +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 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -617,7 +620,7 @@ exit /b %PS_STATUS% :DownloadFileQuiet set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" -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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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 }" +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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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=" @@ -721,7 +724,7 @@ if "!ACTUAL_HASH!"=="" ( ) if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( - echo ERROR: Checksum verification failed for !ARCHIVE_NAME!. + echo ERROR: Checksum mismatch for !ARCHIVE_NAME!: expected !EXPECTED_HASH!, got !ACTUAL_HASH!. exit /b 1 ) diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index df29c088171..430547a4444 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -30,7 +30,7 @@ function Download-File { $global:ProgressPreference = 'SilentlyContinue' try { if (Get-Command curl.exe -ErrorAction SilentlyContinue) { - curl.exe --connect-timeout 15 --max-time 300 -sSfLo $OutFile $Url + curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo $OutFile $Url if ($LASTEXITCODE -ne 0) { throw "curl.exe download failed (exit code $LASTEXITCODE)" } @@ -258,7 +258,12 @@ function Install-CurrentCmdPathShim { } $shim = "@echo off`r`nREM Qwen Code current-session shim. Generated by install-qwen-standalone.ps1.`r`ncall `"$QwenCommand`" %*`r`n" - [IO.File]::WriteAllText($shimPath, $shim, [Text.UTF8Encoding]::new($false)) + # Write to a sibling temp file first, then atomically rename so a partial + # write (process killed, disk full) cannot leave a half-written shim on + # PATH. + $shimTempPath = "$shimPath.new" + [IO.File]::WriteAllText($shimTempPath, $shim, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $shimTempPath -Destination $shimPath -Force Save-CurrentCmdPathShim -ShimPath $shimPath return $shimPath } @@ -341,8 +346,12 @@ if ([string]::IsNullOrEmpty($env:TEMP)) { Write-Error "TEMP environment variable is not set. Please set TEMP to a writable directory." exit 1 } -$qwenInstallerPath = Join-Path $env:TEMP $qwenInstallerName -$qwenChecksumsPath = Join-Path $env:TEMP 'qwen-installation-SHA256SUMS' +# Use a cryptographically random staging filename so a same-user attacker cannot +# pre-stage a malicious .bat at a predictable path and race the verify/execute +# window between Get-FileHash and `& $qwenInstallerPath`. +$qwenStagingSuffix = [IO.Path]::GetRandomFileName() +$qwenInstallerPath = Join-Path $env:TEMP "qwen-installer-$qwenStagingSuffix.bat" +$qwenChecksumsPath = Join-Path $env:TEMP "qwen-installation-SHA256SUMS-$qwenStagingSuffix" try { Download-File -Url $qwenInstallerUrl -OutFile $qwenInstallerPath @@ -379,7 +388,7 @@ $qwenActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $qwenInstallerPat if ($qwenActualHash -ne $qwenExpectedHash) { Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue - Write-Error "Checksum verification failed for ${qwenInstallerName}." + Write-Error "Checksum mismatch for ${qwenInstallerName}: expected ${qwenExpectedHash}, got ${qwenActualHash}." exit 1 } diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 4a5c810c9b0..ccc7f05edb9 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -852,7 +852,7 @@ verify_checksum() { rm -f "${temp_checksum}" if [[ "${expected}" != "${actual}" ]]; then - log_error "Checksum verification failed for ${archive_name}." + log_error "Checksum mismatch for ${archive_name}: expected ${expected}, got ${actual}." return 1 fi diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 index a454c840771..cee6854beff 100644 --- a/scripts/installation/uninstall-qwen-standalone.ps1 +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -5,12 +5,36 @@ # Usage: # powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex" # -# Set $env:QWEN_UNINSTALL_PURGE = '1' to also remove the installer source -# marker at %USERPROFILE%\.qwen\source.json. Other Qwen Code config and auth -# files are preserved. +# Set $env:QWEN_UNINSTALL_PURGE = '1' (or pass -Purge) to also remove the +# installer source marker at %USERPROFILE%\.qwen\source.json. Other Qwen Code +# config and auth files are preserved. + +param( + [switch]$Purge, + [switch]$Help +) $ErrorActionPreference = 'Stop' +if ($Help) { + Write-Output @" +Qwen Code standalone uninstaller. + +Usage: + uninstall-qwen-standalone.ps1 [-Purge] [-Help] + +Options: + -Purge Also remove %USERPROFILE%\.qwen\source.json (same as + QWEN_UNINSTALL_PURGE=1). + -Help Show this message and exit. +"@ + exit 0 +} + +if ($Purge) { + $env:QWEN_UNINSTALL_PURGE = '1' +} + function Write-Info { param([string]$Message) Write-Output "INFO: $Message" diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh index 3b2d618681c..b59230758e4 100755 --- a/scripts/installation/uninstall-qwen-standalone.sh +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -166,6 +166,14 @@ remove_install_wrapper() { return 0 fi + # Defense in depth: only delete files that look like the installer-generated + # wrapper (shebang on first line). A user-authored script that happens to + # mention the install path stays untouched. + if ! head -n 1 "${wrapper_path}" 2>/dev/null | grep -q '^#!'; then + log_warning "${wrapper_path} mentions this install but is not a shell wrapper; skipping." + return 0 + fi + rm -f "${wrapper_path}" log_success "Removed ${wrapper_path}" } diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index b04452c1f00..827c3986191 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -193,10 +193,10 @@ describe('installation scripts', () => { expect(script).not.toContain('msiexec'); expect(script).toContain('Invoke-WebRequest'); expect(script).toContain( - '& $curl --connect-timeout 15 --max-time 300 -#fSLo', + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo', ); expect(script).toContain( - '& $curl --connect-timeout 15 --max-time 300 -fsSLo', + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -fsSLo', ); expect(script).toContain('-TimeoutSec 300'); expect(script).toContain('$request.Timeout = 10000'); @@ -281,8 +281,12 @@ describe('installation scripts', () => { expect(script).toContain('$curl = $env:QWEN_INSTALL_CURL_EXE'); expect(script).toContain('QWEN_INSTALL_CURL_EXE'); expect(script).toContain('Get-Command curl.exe -CommandType Application'); - expect(script).toContain('--connect-timeout 15 --max-time 300 -#fSLo'); - expect(script).toContain('--connect-timeout 15 --max-time 300 -fsSLo'); + expect(script).toContain( + '--connect-timeout 15 --max-time 300 --retry 2 -#fSLo', + ); + expect(script).toContain( + '--connect-timeout 15 --max-time 300 --retry 2 -fsSLo', + ); expect(script).toContain('Invoke-WebRequest'); expect(script).toContain('-TimeoutSec 300'); expect(script).toContain( @@ -340,7 +344,10 @@ describe('installation scripts', () => { ); expect(script).toContain('set "ARCHIVE_NAME=qwen-code-!TARGET!.zip"'); expect(script).toContain('Keep :DetectTarget in sync with RELEASE_TARGETS'); - expect(script).toContain( + // ARM64 is intentionally not detected: RELEASE_TARGETS has no win-arm64 + // entry, so we want :DetectTarget to fall through to the unsupported-arch + // branch and let the caller fall back to npm. + expect(script).not.toContain( 'if /i "!PROCESSOR_ARCHITECTURE!"=="ARM64" set "TARGET=win-arm64"', ); expect(script).not.toContain('%RANDOM%'); @@ -776,7 +783,7 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('Invoke-WebRequest'); expect(installPowerShellSource).toContain('Download-File'); expect(installPowerShellSource).toContain( - 'curl.exe --connect-timeout 15 --max-time 300 -sSfLo', + 'curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo', ); expect(installPowerShellSource).toContain('-TimeoutSec 300'); expect(installPowerShellSource).toContain( @@ -786,7 +793,7 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).toContain('--version vX.Y.Z'); expect(installPowerShellSource).toContain('SHA256SUMS'); expect(installPowerShellSource).toContain('Get-FileHash'); - expect(installPowerShellSource).toContain('Checksum verification failed'); + expect(installPowerShellSource).toContain('Checksum mismatch'); expect(installPowerShellSource).toContain('@args'); }); @@ -915,9 +922,7 @@ describe('standalone release packaging', () => { writeFileSync(installSh, 'tampered'); await expect( assertHostedInstallationAssetChecksums(tmpDir), - ).rejects.toThrow( - /Checksum verification failed for install-qwen-standalone\.sh/, - ); + ).rejects.toThrow(/Checksum mismatch for install-qwen-standalone\.sh/); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -1041,7 +1046,7 @@ describe('standalone release packaging', () => { ); await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( new RegExp( - `Checksum verification failed for ${escapeRegExp(EXPECTED_STANDALONE_ARCHIVE_NAMES[0])}`, + `Checksum mismatch for ${escapeRegExp(EXPECTED_STANDALONE_ARCHIVE_NAMES[0])}`, ), ); } finally { @@ -1143,7 +1148,7 @@ describe('standalone release packaging', () => { return new Response(`${assetName}\n`); }, }), - ).rejects.toThrow(/Checksum verification failed for qwen-code-/); + ).rejects.toThrow(/Checksum mismatch for qwen-code-/); }); it('rejects a release base URL that is not https', async () => { @@ -1575,9 +1580,11 @@ describe('standalone release packaging', () => { ); expect(syncStepIndex).toBeGreaterThanOrEqual(0); expect(verifyStepIndex).toBeGreaterThan(syncStepIndex); - expect(publishLatestStepIndex).toBeGreaterThan(verifyStepIndex); - expect(syncHostedStepIndex).toBeGreaterThan(publishLatestStepIndex); + expect(syncHostedStepIndex).toBeGreaterThan(verifyStepIndex); expect(verifyHostedStepIndex).toBeGreaterThan(syncHostedStepIndex); + // Latest VERSION pointer must flip only after every release asset and + // hosted installer object is uploaded and verified. + expect(publishLatestStepIndex).toBeGreaterThan(verifyHostedStepIndex); expect(workflow.slice(syncStepIndex, verifyStepIndex)).not.toContain( 'releases/qwen-code/latest/VERSION', ); @@ -1626,7 +1633,7 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', ); - const verifyStep = workflow.slice(verifyStepIndex, publishLatestStepIndex); + const verifyStep = workflow.slice(verifyStepIndex, syncHostedStepIndex); expect(verifyStep).not.toContain('hosted_tmp_dir'); const verifyHostedStep = workflow.slice(verifyHostedStepIndex); expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); @@ -2447,7 +2454,7 @@ describe('Linux/macOS installer end-to-end', () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); restoreMinimalDist(createdDist); @@ -2571,7 +2578,7 @@ describe('Linux/macOS installer end-to-end', () => { failureMessage = error.message; } - expect(failureMessage).toContain('Checksum verification failed'); + expect(failureMessage).toContain('Checksum mismatch'); expect(failureMessage).toContain('Standalone install failed'); expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { @@ -2836,7 +2843,7 @@ describe('Windows installer end-to-end', () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index ca7b4b5c951..cb7244f8566 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -134,7 +134,7 @@ function uploadWithRetry(asset, bucket, key, config) { console.warn( `Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`, ); - spawnSync('sleep', [String(delayMs / 1000)]); + sleepSync(delayMs); } } fail( @@ -142,4 +142,10 @@ function uploadWithRetry(asset, bucket, key, config) { ); } +// Cross-platform synchronous sleep. `spawnSync('sleep', ...)` is unavailable +// on Windows runners; Atomics.wait blocks the current thread without spawning. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + export { parseUploadArgs, uploadAssets }; diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 1650e734319..6339ab5f59d 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -123,8 +123,11 @@ async function verifyReleaseDirectory(dir, options = {}) { } const actual = await sha256File(assetPath); - if (actual !== checksums.get(assetName)) { - fail(`Checksum verification failed for ${assetName}`); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); } } @@ -205,7 +208,9 @@ async function assertRemoteAssetChecksums( const actual = await fetchSha256(assetUrl, fetchImpl); const expected = checksums.get(assetName); if (actual !== expected) { - fail(`Checksum verification failed for ${assetName}`); + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); } } catch (reason) { failures.push({ From 0a5d308c9c7f47fd0def78d89cb30c3096b78a6c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 23:41:48 +0800 Subject: [PATCH 113/129] fix(installer): address review feedback round 3 Workflow: - Configure Aliyun OSS Credentials: write the ossutil config file directly with restricted umask instead of invoking `ossutil config -k `. Passing the access-key secret via argv made it visible in /proc//cmdline for the lifetime of that step; writing the INI file in-process keeps the secret out of the process table. upload-aliyun-oss-assets.js: - Upload assets in parallel with `Promise.all` + async `spawn` instead of a sequential `spawnSync` loop. Each asset keeps its own retry budget; failures are aggregated so one flaky upload does not mask a separate failure. - Replace the bespoke `Atomics.wait` retry sleep with `timers/promises#setTimeout` now that the loop is async. INSTALLATION_GUIDE.md: - Drop the misleading "instead of overwriting the global installation/ entrypoint objects" sentence; the workflow has always also refreshed the global versionless objects so curl|bash links keep resolving without a version segment. Document the rollback story instead. --- .github/workflows/release.yml | 18 +++-- scripts/installation/INSTALLATION_GUIDE.md | 7 +- scripts/upload-aliyun-oss-assets.js | 84 +++++++++++++--------- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f2765dd8c..aa4a8d9fa06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -434,12 +434,18 @@ jobs: exit 1 fi - ossutil config \ - -e "${ALIYUN_OSS_ENDPOINT}" \ - -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ - -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ - -L EN \ - -c "${RUNNER_TEMP}/.ossutilconfig" + # Write the ossutil config file directly instead of invoking + # `ossutil config -k `, so the access-key secret never lands + # in /proc//cmdline. Restrictive umask scopes the file to the + # current runner user. + umask 077 + { + printf '[Credentials]\n' + printf 'language=EN\n' + printf 'endpoint=%s\n' "${ALIYUN_OSS_ENDPOINT}" + printf 'accessKeyID=%s\n' "${ALIYUN_OSS_ACCESS_KEY_ID}" + printf 'accessKeySecret=%s\n' "${ALIYUN_OSS_ACCESS_KEY_SECRET}" + } > "${RUNNER_TEMP}/.ossutilconfig" - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 5a11a70a39f..e2d863e3283 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -78,8 +78,11 @@ The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, `uninstall-qwen-standalone.ps1` files map to the standalone-suffixed hosted URLs shown above. The staging command also writes `SHA256SUMS` for upload verification. During a non-dry-run stable release, the publish workflow uploads -a byte-for-byte snapshot to `installation/vX.Y.Z/` for audit and rollback -instead of overwriting the global `installation/` entrypoint objects. The hosted +a byte-for-byte snapshot to `installation/vX.Y.Z/` for audit and rollback, and +also refreshes the global `installation/` entrypoint objects so `curl | bash` +links keep resolving without a version segment. The versioned snapshot lets you +roll back by repointing the global objects to a previous tag if a regression is +caught after publish. The hosted installers intentionally default to `latest`; on Aliyun OSS this means reading `releases/qwen-code/latest/VERSION` first, then downloading the matching versioned release directory. Use `--version` or `QWEN_INSTALL_VERSION` to pin a diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index cb7244f8566..c5bf998c5e8 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -7,25 +7,26 @@ */ import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; import { fail, isMainModule, readOptionValue } from './release-script-utils.js'; if (isMainModule(import.meta.url)) { try { - main(process.argv.slice(2)); + await main(process.argv.slice(2)); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } } -function main(argv) { +async function main(argv) { const args = parseUploadArgs(argv); if (args.help) { printUsage(); return; } - uploadAssets(args); + await uploadAssets(args); } function printUsage() { @@ -99,34 +100,36 @@ function parseUploadArgs(argv) { const MAX_UPLOAD_ATTEMPTS = 3; const INITIAL_BACKOFF_MS = 2000; -function uploadAssets({ assets, bucket, config, prefix }) { - for (const asset of assets) { - const key = `${prefix}/${path.basename(asset)}`; - uploadWithRetry(asset, bucket, key, config); +async function uploadAssets({ assets, bucket, config, prefix }) { + // Upload assets in parallel; each asset has its own retry budget. Failures + // are collected and reported together so a flaky run is not masked by a + // later failure aborting earlier ones. + const failures = []; + await Promise.all( + assets.map(async (asset) => { + const key = `${prefix}/${path.basename(asset)}`; + try { + await uploadWithRetry(asset, bucket, key, config); + } catch (error) { + failures.push({ asset, error }); + } + }), + ); + if (failures.length > 0) { + for (const { asset, error } of failures) { + console.error( + `Upload failed for ${asset}: ${error instanceof Error ? error.message : error}`, + ); + } + fail(`${failures.length} of ${assets.length} asset uploads failed.`); } } -function uploadWithRetry(asset, bucket, key, config) { +async function uploadWithRetry(asset, bucket, key, config) { for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { - const result = spawnSync( - 'ossutil', - [ - 'cp', - asset, - `oss://${bucket}/${key}`, - '-c', - config, - '-f', - '--acl', - 'public-read', - ], - { stdio: 'inherit' }, - ); + const exitInfo = await runOssutilCp(asset, bucket, key, config); - if (result.error) { - throw result.error; - } - if (result.status === 0) { + if (exitInfo.status === 0) { return; } if (attempt < MAX_UPLOAD_ATTEMPTS) { @@ -134,18 +137,33 @@ function uploadWithRetry(asset, bucket, key, config) { console.warn( `Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`, ); - sleepSync(delayMs); + await delay(delayMs); } } - fail( + throw new Error( `ossutil failed after ${MAX_UPLOAD_ATTEMPTS} attempts while uploading ${asset}`, ); } -// Cross-platform synchronous sleep. `spawnSync('sleep', ...)` is unavailable -// on Windows runners; Atomics.wait blocks the current thread without spawning. -function sleepSync(ms) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +function runOssutilCp(asset, bucket, key, config) { + return new Promise((resolve, reject) => { + const child = spawn( + 'ossutil', + [ + 'cp', + asset, + `oss://${bucket}/${key}`, + '-c', + config, + '-f', + '--acl', + 'public-read', + ], + { stdio: 'inherit' }, + ); + child.once('error', reject); + child.once('close', (status) => resolve({ status: status ?? 1 })); + }); } export { parseUploadArgs, uploadAssets }; From 89b272dc156be53a1dfed0c3501e289004cbebb4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 23:44:20 +0800 Subject: [PATCH 114/129] test(installer): add parseUploadArgs unit tests and align verify derivation - scripts/tests/upload-aliyun-oss-assets.test.js: cover --help short-circuit, required-option validation (--bucket/--config/--prefix/empty assets), unknown options, missing option values, and trailing-slash prefix normalization. - scripts/verify-installation-release.js: switch the win-only zip branch from `startsWith('win-')` to the strict `=== 'win-x64'` check used by build-standalone-release.js, and add a comment recording that the two derivations must stay aligned. Without this the helpers would diverge the moment a non-x64 win target gets added. --- .../tests/upload-aliyun-oss-assets.test.js | 80 +++++++++++++++++++ scripts/verify-installation-release.js | 6 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/upload-aliyun-oss-assets.test.js diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js new file mode 100644 index 00000000000..74d42632ddf --- /dev/null +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { parseUploadArgs } from '../upload-aliyun-oss-assets.js'; + +describe('parseUploadArgs', () => { + it('returns help=true and skips later validation when --help is passed', () => { + const args = parseUploadArgs(['--help']); + expect(args.help).toBe(true); + // Other fields stay at their defaults; no fail() is thrown. + expect(args.assets).toEqual([]); + }); + + it('parses required options and asset list', () => { + const args = parseUploadArgs([ + '--bucket', + 'my-bucket', + '--config', + '/tmp/.ossutilconfig', + '--prefix', + 'releases/qwen-code/v1.2.3', + 'a.tar.gz', + 'b.zip', + ]); + expect(args).toMatchObject({ + bucket: 'my-bucket', + config: '/tmp/.ossutilconfig', + prefix: 'releases/qwen-code/v1.2.3', + assets: ['a.tar.gz', 'b.zip'], + help: false, + }); + }); + + it('strips a trailing slash from --prefix', () => { + const args = parseUploadArgs([ + '--bucket', + 'b', + '--config', + 'c', + '--prefix', + 'installation/', + 'one.txt', + ]); + expect(args.prefix).toBe('installation'); + }); + + it.each([ + [['--bucket', 'b', '--config', 'c', 'asset.txt'], '--prefix'], + [['--config', 'c', '--prefix', 'p', 'asset.txt'], '--bucket'], + [['--bucket', 'b', '--prefix', 'p', 'asset.txt'], '--config'], + [['--bucket', 'b', '--config', 'c', '--prefix', 'p'], 'ASSET path'], + ])('rejects when %j is missing', (argv, expectedFragment) => { + expect(() => parseUploadArgs(argv)).toThrow( + new RegExp(expectedFragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + }); + + it('rejects unknown options', () => { + expect(() => + parseUploadArgs([ + '--bucket', + 'b', + '--config', + 'c', + '--prefix', + 'p', + '--bogus', + 'asset.txt', + ]), + ).toThrow(/Unknown option: --bogus/); + }); + + it('errors when an option is missing its value', () => { + expect(() => parseUploadArgs(['--bucket'])).toThrow(); + }); +}); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 6339ab5f59d..b5121bc518c 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -37,10 +37,14 @@ const EXPECTED_RELEASE_ASSET_NAMES = [ ]; const REMOTE_FETCH_TIMEOUT_MS = 30_000; +// Mirrors `build-standalone-release.js`'s archive-name derivation. The two +// must stay aligned: any new platform/extension landing in RELEASE_TARGETS +// 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.startsWith('win-') ? 'zip' : 'tar.gz'}`, + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, ); } From 547713af7ff9d9615ff8e2eb75112e747e9b40b4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 23:45:37 +0800 Subject: [PATCH 115/129] test(installer): add uploadAssets integration tests with fake ossutil Add two integration tests that route a temp-directory ossutil shim onto PATH so uploadAssets actually spawns the real binary with the real cp argv: - happy-path test asserts the destination URI, `-c `, `--acl public-read`, and per-asset cp invocations land for both inputs. - failure-path test asserts non-zero ossutil exits surface as an aggregate `asset uploads failed` error after the retry budget runs out. --- .../tests/upload-aliyun-oss-assets.test.js | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js index 74d42632ddf..07c854ff08e 100644 --- a/scripts/tests/upload-aliyun-oss-assets.test.js +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { parseUploadArgs } from '../upload-aliyun-oss-assets.js'; +import { parseUploadArgs, uploadAssets } from '../upload-aliyun-oss-assets.js'; describe('parseUploadArgs', () => { it('returns help=true and skips later validation when --help is passed', () => { @@ -78,3 +81,92 @@ describe('parseUploadArgs', () => { expect(() => parseUploadArgs(['--bucket'])).toThrow(); }); }); + +describe('uploadAssets (integration)', () => { + function makeOssutilShim(workDir, behavior = 'success') { + fs.mkdirSync(workDir, { recursive: true }); + const ossutilPath = path.join(workDir, 'ossutil'); + const logPath = path.join(workDir, 'ossutil.log'); + const successScript = `#!/usr/bin/env bash +printf '%s\\n' "$@" >> "${logPath}" +exit 0 +`; + const failScript = `#!/usr/bin/env bash +printf '%s\\n' "$@" >> "${logPath}" +exit 1 +`; + fs.writeFileSync( + ossutilPath, + behavior === 'fail' ? failScript : successScript, + ); + fs.chmodSync(ossutilPath, 0o755); + return { ossutilPath, logPath }; + } + + it('spawns ossutil with the expected cp arguments per asset', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-')); + try { + const { logPath } = makeOssutilShim(tmp); + const assets = ['a.tar.gz', 'b.zip'].map((name) => { + const filePath = path.join(tmp, name); + fs.writeFileSync(filePath, name); + return filePath; + }); + const configPath = path.join(tmp, '.ossutilconfig'); + fs.writeFileSync(configPath, '[Credentials]\n'); + + const previousPath = process.env.PATH; + process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; + try { + await uploadAssets({ + assets, + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }); + } finally { + process.env.PATH = previousPath; + } + + const log = fs.readFileSync(logPath, 'utf8'); + expect(log).toContain( + `oss://qwen-test-bucket/releases/qwen-code/v0.0.0/a.tar.gz`, + ); + expect(log).toContain( + `oss://qwen-test-bucket/releases/qwen-code/v0.0.0/b.zip`, + ); + expect(log).toContain(`-c\n${configPath}`); + expect(log).toContain('--acl\npublic-read'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('aggregates failures from ossutil non-zero exits', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fail-')); + try { + makeOssutilShim(tmp, 'fail'); + const assetPath = path.join(tmp, 'asset.tar.gz'); + fs.writeFileSync(assetPath, 'asset'); + const configPath = path.join(tmp, '.ossutilconfig'); + fs.writeFileSync(configPath, '[Credentials]\n'); + + const previousPath = process.env.PATH; + process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; + try { + await expect( + uploadAssets({ + assets: [assetPath], + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }), + ).rejects.toThrow(/asset uploads failed/); + } finally { + process.env.PATH = previousPath; + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 30_000); +}); From bab96dc0946687b9fed43f670ad398ed0879a84b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 00:05:38 +0800 Subject: [PATCH 116/129] revert(installer): drop over-engineered ossutil/upload changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roll back two changes from a1ef8697b/0a5d308c9 that were not justified by the actual threat model or release-pipeline needs: - .github/workflows/release.yml: restore the supported `ossutil config -k` invocation. The earlier switch to writing the .ossutilconfig INI file in-process was meant to keep the access-key out of /proc//cmdline, but GitHub-hosted runners are single-tenant ephemeral VMs where no other user can read that namespace. The benefit was theoretical; the cost was taking on a brittle dependency on ossutil's undocumented config format. - scripts/upload-aliyun-oss-assets.js: revert the uploadAssets parallel rewrite (Promise.all + spawn + setTimeout) back to the original sync spawnSync loop with retry. Release-time uploads of ~6 small files do not need parallelism, and the async refactor changed the public contract (sync→async) for no real wall-clock win. Kept from those commits: - The cleanup `if: always()` step that removes RUNNER_TEMP/.ossutilconfig at the end of the publish job. - The cross-platform sleepSync(ms) helper, since `spawnSync('sleep', ...)` still does not work on Windows runners. - The INSTALLATION_GUIDE.md doc fix. - All other round-2 fixes. Test assertions updated for the restored sync uploadAssets contract. --- .github/workflows/release.yml | 18 ++-- .../tests/upload-aliyun-oss-assets.test.js | 6 +- scripts/upload-aliyun-oss-assets.js | 84 ++++++++----------- 3 files changed, 42 insertions(+), 66 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa4a8d9fa06..d8f2765dd8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -434,18 +434,12 @@ jobs: exit 1 fi - # Write the ossutil config file directly instead of invoking - # `ossutil config -k `, so the access-key secret never lands - # in /proc//cmdline. Restrictive umask scopes the file to the - # current runner user. - umask 077 - { - printf '[Credentials]\n' - printf 'language=EN\n' - printf 'endpoint=%s\n' "${ALIYUN_OSS_ENDPOINT}" - printf 'accessKeyID=%s\n' "${ALIYUN_OSS_ACCESS_KEY_ID}" - printf 'accessKeySecret=%s\n' "${ALIYUN_OSS_ACCESS_KEY_SECRET}" - } > "${RUNNER_TEMP}/.ossutilconfig" + ossutil config \ + -e "${ALIYUN_OSS_ENDPOINT}" \ + -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ + -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ + -L EN \ + -c "${RUNNER_TEMP}/.ossutilconfig" - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js index 07c854ff08e..7ebe81c52f7 100644 --- a/scripts/tests/upload-aliyun-oss-assets.test.js +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -118,7 +118,7 @@ exit 1 const previousPath = process.env.PATH; process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; try { - await uploadAssets({ + uploadAssets({ assets, bucket: 'qwen-test-bucket', config: configPath, @@ -154,14 +154,14 @@ exit 1 const previousPath = process.env.PATH; process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; try { - await expect( + expect(() => uploadAssets({ assets: [assetPath], bucket: 'qwen-test-bucket', config: configPath, prefix: 'releases/qwen-code/v0.0.0', }), - ).rejects.toThrow(/asset uploads failed/); + ).toThrow(/ossutil failed after 3 attempts/); } finally { process.env.PATH = previousPath; } diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index c5bf998c5e8..cb7244f8566 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -7,26 +7,25 @@ */ import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { setTimeout as delay } from 'node:timers/promises'; +import { spawnSync } from 'node:child_process'; import { fail, isMainModule, readOptionValue } from './release-script-utils.js'; if (isMainModule(import.meta.url)) { try { - await main(process.argv.slice(2)); + main(process.argv.slice(2)); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } } -async function main(argv) { +function main(argv) { const args = parseUploadArgs(argv); if (args.help) { printUsage(); return; } - await uploadAssets(args); + uploadAssets(args); } function printUsage() { @@ -100,36 +99,34 @@ function parseUploadArgs(argv) { const MAX_UPLOAD_ATTEMPTS = 3; const INITIAL_BACKOFF_MS = 2000; -async function uploadAssets({ assets, bucket, config, prefix }) { - // Upload assets in parallel; each asset has its own retry budget. Failures - // are collected and reported together so a flaky run is not masked by a - // later failure aborting earlier ones. - const failures = []; - await Promise.all( - assets.map(async (asset) => { - const key = `${prefix}/${path.basename(asset)}`; - try { - await uploadWithRetry(asset, bucket, key, config); - } catch (error) { - failures.push({ asset, error }); - } - }), - ); - if (failures.length > 0) { - for (const { asset, error } of failures) { - console.error( - `Upload failed for ${asset}: ${error instanceof Error ? error.message : error}`, - ); - } - fail(`${failures.length} of ${assets.length} asset uploads failed.`); +function uploadAssets({ assets, bucket, config, prefix }) { + for (const asset of assets) { + const key = `${prefix}/${path.basename(asset)}`; + uploadWithRetry(asset, bucket, key, config); } } -async function uploadWithRetry(asset, bucket, key, config) { +function uploadWithRetry(asset, bucket, key, config) { for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { - const exitInfo = await runOssutilCp(asset, bucket, key, config); + const result = spawnSync( + 'ossutil', + [ + 'cp', + asset, + `oss://${bucket}/${key}`, + '-c', + config, + '-f', + '--acl', + 'public-read', + ], + { stdio: 'inherit' }, + ); - if (exitInfo.status === 0) { + if (result.error) { + throw result.error; + } + if (result.status === 0) { return; } if (attempt < MAX_UPLOAD_ATTEMPTS) { @@ -137,33 +134,18 @@ async function uploadWithRetry(asset, bucket, key, config) { console.warn( `Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`, ); - await delay(delayMs); + sleepSync(delayMs); } } - throw new Error( + fail( `ossutil failed after ${MAX_UPLOAD_ATTEMPTS} attempts while uploading ${asset}`, ); } -function runOssutilCp(asset, bucket, key, config) { - return new Promise((resolve, reject) => { - const child = spawn( - 'ossutil', - [ - 'cp', - asset, - `oss://${bucket}/${key}`, - '-c', - config, - '-f', - '--acl', - 'public-read', - ], - { stdio: 'inherit' }, - ); - child.once('error', reject); - child.once('close', (status) => resolve({ status: status ?? 1 })); - }); +// Cross-platform synchronous sleep. `spawnSync('sleep', ...)` is unavailable +// on Windows runners; Atomics.wait blocks the current thread without spawning. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } export { parseUploadArgs, uploadAssets }; From 3caf3e2f2abfa3c67e45adacb214cfde030833c4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 10:33:13 +0800 Subject: [PATCH 117/129] test(installer): cover Windows release script regressions --- scripts/tests/install-script.test.js | 167 ++++++++++++++++-- .../tests/upload-aliyun-oss-assets.test.js | 85 ++++++++- 2 files changed, 232 insertions(+), 20 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 827c3986191..88fa74a4445 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -3054,6 +3054,70 @@ describe('Windows installer end-to-end', () => { }); }); +describe('Windows PowerShell uninstaller end-to-end', () => { + itOnWindows('prints help without deleting standalone files', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'qwen-code'); + const home = path.join(tmpDir, 'home'); + createFakeWindowsStandaloneInstall(installRoot); + + const output = runWindowsPowerShellScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ['-Help'], + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + }, + ).toString(); + + expect(output).toContain('Usage:'); + expect(output).toContain('-Purge'); + expect(existsSync(installDir)).toBe(true); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnWindows('purges the source marker while preserving other config', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'qwen-code'); + const installBinDir = path.join(installRoot, 'bin'); + const home = path.join(tmpDir, 'home'); + const qwenConfigDir = path.join(home, '.qwen'); + const sourceMarker = path.join(qwenConfigDir, 'source.json'); + const settingsFile = path.join(qwenConfigDir, 'settings.json'); + + createFakeWindowsStandaloneInstall(installRoot); + mkdirSync(qwenConfigDir, { recursive: true }); + writeFileSync(sourceMarker, '{"source":"smoke"}\n'); + writeFileSync(settingsFile, '{"theme":"dark"}\n'); + + const output = runWindowsPowerShellScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ['-Purge'], + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + }, + ).toString(); + + expect(output).toContain('Removed'); + expect(existsSync(installDir)).toBe(false); + expect(existsSync(path.join(installBinDir, 'qwen.cmd'))).toBe(false); + expect(existsSync(sourceMarker)).toBe(false); + expect(readScript(settingsFile)).toContain('"theme":"dark"'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + function ensureMinimalDist() { const distPath = path.resolve('dist'); const backupPath = existsSync(distPath) @@ -3185,6 +3249,30 @@ function createFakeWindowsStandaloneArchive(tmpDir) { return archive; } +function createFakeWindowsStandaloneInstall(installRoot) { + const installDir = path.join(installRoot, 'qwen-code'); + const installBinDir = path.join(installRoot, 'bin'); + mkdirSync(path.join(installDir, 'bin'), { recursive: true }); + mkdirSync(path.join(installDir, 'node'), { recursive: true }); + mkdirSync(installBinDir, { recursive: true }); + + writeFileSync( + path.join(installDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }), + ); + writeFileSync( + path.join(installDir, 'bin', 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), + ); + writeFileSync(path.join(installDir, 'node', 'node.exe'), 'fake node.exe\n'); + writeFileSync( + path.join(installBinDir, 'qwen.cmd'), + ['@echo off', `"${path.join(installDir, 'bin', 'qwen.cmd')}" %*`, ''].join( + '\r\n', + ), + ); +} + function createFakeWindowsNpmTools(fakeBin) { mkdirSync(fakeBin, { recursive: true }); writeFileSync( @@ -3222,7 +3310,19 @@ function createFakeWindowsCurlCommand(fakeBin) { 'if "%~1"=="" goto done_parse', 'set "arg=%~1"', 'if "!arg:~0,1!"=="-" (', - ' echo(!arg! | findstr /C:"o" >nul && (', + ' if /i "!arg!"=="-o" (', + ' shift', + ' set "destination=%~1"', + ' shift', + ' goto parse_args', + ' )', + ' if /i "!arg!"=="--output" (', + ' shift', + ' set "destination=%~1"', + ' shift', + ' goto parse_args', + ' )', + ' if not "!arg:~0,2!"=="--" if /i "!arg:~-1!"=="o" (', ' shift', ' set "destination=%~1"', ' shift', @@ -3457,18 +3557,61 @@ function runWindowsInstaller( function runWindowsCommand(command, env = {}) { const prepared = prepareWindowsCommand(command, env); - return execFileSync( - process.env.ComSpec || 'cmd.exe', - ['/d', '/c', prepared.command], - { - env: { - ...prepared.env, + try { + return execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/c', prepared.command], + { + env: { + ...prepared.env, + }, + stdio: 'pipe', + // cmd.exe parses the command string itself; preserve quoted paths. + windowsVerbatimArguments: true, }, - stdio: 'pipe', - // cmd.exe parses the command string itself; preserve quoted paths. - windowsVerbatimArguments: true, - }, - ); + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function runWindowsPowerShellScript(scriptPath, args = [], env = {}) { + try { + return execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.resolve(scriptPath), + ...args, + ], + { + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } } const WINDOWS_COMMAND_ENV_OVERRIDES = [ diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js index 7ebe81c52f7..619e5a21005 100644 --- a/scripts/tests/upload-aliyun-oss-assets.test.js +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -83,10 +83,76 @@ describe('parseUploadArgs', () => { }); describe('uploadAssets (integration)', () => { + function prependProcessPath(directory) { + const pathKeys = Object.keys(process.env).filter( + (key) => key.toLowerCase() === 'path', + ); + const pathKey = pathKeys[0] || 'PATH'; + const previousValues = new Map( + pathKeys.map((key) => [key, process.env[key]]), + ); + const nextValue = `${directory}${path.delimiter}${process.env[pathKey] || ''}`; + + if (pathKeys.length === 0) { + process.env[pathKey] = nextValue; + } else { + for (const key of pathKeys) { + process.env[key] = nextValue; + } + } + return () => { + if (previousValues.size === 0) { + delete process.env[pathKey]; + return; + } + for (const key of pathKeys) { + const previousValue = previousValues.get(key); + if (previousValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = previousValue; + } + } + }; + } + function makeOssutilShim(workDir, behavior = 'success') { fs.mkdirSync(workDir, { recursive: true }); - const ossutilPath = path.join(workDir, 'ossutil'); + const ossutilPath = path.join( + workDir, + process.platform === 'win32' ? 'ossutil.cmd' : 'ossutil', + ); const logPath = path.join(workDir, 'ossutil.log'); + if (process.platform === 'win32') { + const successScript = [ + '@echo off', + ':log_args', + 'if "%~1"=="" goto done_log_args', + `>>"${logPath}" echo(%~1`, + 'shift', + 'goto log_args', + ':done_log_args', + 'exit /b 0', + '', + ].join('\r\n'); + const failScript = [ + '@echo off', + ':log_args', + 'if "%~1"=="" goto done_log_args', + `>>"${logPath}" echo(%~1`, + 'shift', + 'goto log_args', + ':done_log_args', + 'exit /b 1', + '', + ].join('\r\n'); + fs.writeFileSync( + ossutilPath, + behavior === 'fail' ? failScript : successScript, + ); + return { ossutilPath, logPath }; + } + const successScript = `#!/usr/bin/env bash printf '%s\\n' "$@" >> "${logPath}" exit 0 @@ -115,8 +181,7 @@ exit 1 const configPath = path.join(tmp, '.ossutilconfig'); fs.writeFileSync(configPath, '[Credentials]\n'); - const previousPath = process.env.PATH; - process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; + const restorePath = prependProcessPath(tmp); try { uploadAssets({ assets, @@ -125,7 +190,7 @@ exit 1 prefix: 'releases/qwen-code/v0.0.0', }); } finally { - process.env.PATH = previousPath; + restorePath(); } const log = fs.readFileSync(logPath, 'utf8'); @@ -145,14 +210,13 @@ exit 1 it('aggregates failures from ossutil non-zero exits', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fail-')); try { - makeOssutilShim(tmp, 'fail'); + const { logPath } = makeOssutilShim(tmp, 'fail'); const assetPath = path.join(tmp, 'asset.tar.gz'); fs.writeFileSync(assetPath, 'asset'); const configPath = path.join(tmp, '.ossutilconfig'); fs.writeFileSync(configPath, '[Credentials]\n'); - const previousPath = process.env.PATH; - process.env.PATH = `${tmp}${path.delimiter}${previousPath}`; + const restorePath = prependProcessPath(tmp); try { expect(() => uploadAssets({ @@ -162,8 +226,13 @@ exit 1 prefix: 'releases/qwen-code/v0.0.0', }), ).toThrow(/ossutil failed after 3 attempts/); + const uploadAttempts = fs + .readFileSync(logPath, 'utf8') + .split(/\r?\n/) + .filter((line) => line === assetPath); + expect(uploadAttempts).toHaveLength(3); } finally { - process.env.PATH = previousPath; + restorePath(); } } finally { fs.rmSync(tmp, { recursive: true, force: true }); From 4a8fea536307b8930cc55854b77eaba1e0f4842e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 18 May 2026 10:58:27 +0800 Subject: [PATCH 118/129] test(release): avoid Windows shim lookup in oss upload tests --- scripts/tests/install-script.test.js | 2 +- .../tests/upload-aliyun-oss-assets.test.js | 136 +++++------------- scripts/upload-aliyun-oss-assets.js | 21 ++- 3 files changed, 53 insertions(+), 106 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 88fa74a4445..d026f7320ec 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -3339,7 +3339,7 @@ function createFakeWindowsCurlCommand(fakeBin) { 'if "!url!"=="" echo missing url or destination 1>&2 & exit /b 2', 'if "!destination!"=="" echo missing url or destination 1>&2 & exit /b 2', 'echo(!url! | findstr /I /C:"/releases/qwen-code/latest/VERSION" >nul && (', - ' > "!destination!" echo v0.0.0-smoke', + ' > "!destination!" echo 0.0.0-smoke', ' exit /b 0', ')', 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip" >nul && (', diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js index 619e5a21005..00b54cab7f0 100644 --- a/scripts/tests/upload-aliyun-oss-assets.test.js +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -83,96 +83,31 @@ describe('parseUploadArgs', () => { }); describe('uploadAssets (integration)', () => { - function prependProcessPath(directory) { - const pathKeys = Object.keys(process.env).filter( - (key) => key.toLowerCase() === 'path', - ); - const pathKey = pathKeys[0] || 'PATH'; - const previousValues = new Map( - pathKeys.map((key) => [key, process.env[key]]), - ); - const nextValue = `${directory}${path.delimiter}${process.env[pathKey] || ''}`; - - if (pathKeys.length === 0) { - process.env[pathKey] = nextValue; - } else { - for (const key of pathKeys) { - process.env[key] = nextValue; - } - } - return () => { - if (previousValues.size === 0) { - delete process.env[pathKey]; - return; - } - for (const key of pathKeys) { - const previousValue = previousValues.get(key); - if (previousValue === undefined) { - delete process.env[key]; - } else { - process.env[key] = previousValue; - } - } - }; - } - function makeOssutilShim(workDir, behavior = 'success') { fs.mkdirSync(workDir, { recursive: true }); - const ossutilPath = path.join( - workDir, - process.platform === 'win32' ? 'ossutil.cmd' : 'ossutil', - ); + const ossutilPath = path.join(workDir, 'ossutil-shim.cjs'); const logPath = path.join(workDir, 'ossutil.log'); - if (process.platform === 'win32') { - const successScript = [ - '@echo off', - ':log_args', - 'if "%~1"=="" goto done_log_args', - `>>"${logPath}" echo(%~1`, - 'shift', - 'goto log_args', - ':done_log_args', - 'exit /b 0', - '', - ].join('\r\n'); - const failScript = [ - '@echo off', - ':log_args', - 'if "%~1"=="" goto done_log_args', - `>>"${logPath}" echo(%~1`, - 'shift', - 'goto log_args', - ':done_log_args', - 'exit /b 1', - '', - ].join('\r\n'); - fs.writeFileSync( - ossutilPath, - behavior === 'fail' ? failScript : successScript, - ); - return { ossutilPath, logPath }; - } - - const successScript = `#!/usr/bin/env bash -printf '%s\\n' "$@" >> "${logPath}" -exit 0 -`; - const failScript = `#!/usr/bin/env bash -printf '%s\\n' "$@" >> "${logPath}" -exit 1 -`; fs.writeFileSync( ossutilPath, - behavior === 'fail' ? failScript : successScript, + [ + "const fs = require('node:fs');", + `fs.appendFileSync(${JSON.stringify(logPath)}, process.argv.slice(2).join('\\n') + '\\n');`, + `process.exit(${behavior === 'fail' ? 1 : 0});`, + '', + ].join('\n'), ); - fs.chmodSync(ossutilPath, 0o755); - return { ossutilPath, logPath }; + return { + logPath, + ossutilCommand: process.execPath, + ossutilCommandArgs: [ossutilPath], + }; } it('spawns ossutil with the expected cp arguments per asset', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-')); try { - const { logPath } = makeOssutilShim(tmp); + const { logPath, ossutilCommand, ossutilCommandArgs } = + makeOssutilShim(tmp); const assets = ['a.tar.gz', 'b.zip'].map((name) => { const filePath = path.join(tmp, name); fs.writeFileSync(filePath, name); @@ -181,17 +116,15 @@ exit 1 const configPath = path.join(tmp, '.ossutilconfig'); fs.writeFileSync(configPath, '[Credentials]\n'); - const restorePath = prependProcessPath(tmp); - try { - uploadAssets({ + uploadAssets( + { assets, bucket: 'qwen-test-bucket', config: configPath, prefix: 'releases/qwen-code/v0.0.0', - }); - } finally { - restorePath(); - } + }, + { ossutilCommand, ossutilCommandArgs }, + ); const log = fs.readFileSync(logPath, 'utf8'); expect(log).toContain( @@ -210,30 +143,31 @@ exit 1 it('aggregates failures from ossutil non-zero exits', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fail-')); try { - const { logPath } = makeOssutilShim(tmp, 'fail'); + const { logPath, ossutilCommand, ossutilCommandArgs } = makeOssutilShim( + tmp, + 'fail', + ); const assetPath = path.join(tmp, 'asset.tar.gz'); fs.writeFileSync(assetPath, 'asset'); const configPath = path.join(tmp, '.ossutilconfig'); fs.writeFileSync(configPath, '[Credentials]\n'); - const restorePath = prependProcessPath(tmp); - try { - expect(() => - uploadAssets({ + expect(() => + uploadAssets( + { assets: [assetPath], bucket: 'qwen-test-bucket', config: configPath, prefix: 'releases/qwen-code/v0.0.0', - }), - ).toThrow(/ossutil failed after 3 attempts/); - const uploadAttempts = fs - .readFileSync(logPath, 'utf8') - .split(/\r?\n/) - .filter((line) => line === assetPath); - expect(uploadAttempts).toHaveLength(3); - } finally { - restorePath(); - } + }, + { ossutilCommand, ossutilCommandArgs }, + ), + ).toThrow(/ossutil failed after 3 attempts/); + const uploadAttempts = fs + .readFileSync(logPath, 'utf8') + .split(/\r?\n/) + .filter((line) => line === assetPath); + expect(uploadAttempts).toHaveLength(3); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index cb7244f8566..3d485133c22 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -99,18 +99,31 @@ function parseUploadArgs(argv) { const MAX_UPLOAD_ATTEMPTS = 3; const INITIAL_BACKOFF_MS = 2000; -function uploadAssets({ assets, bucket, config, prefix }) { +function uploadAssets( + { assets, bucket, config, prefix }, + { ossutilCommand = 'ossutil', ossutilCommandArgs = [] } = {}, +) { for (const asset of assets) { const key = `${prefix}/${path.basename(asset)}`; - uploadWithRetry(asset, bucket, key, config); + uploadWithRetry(asset, bucket, key, config, { + ossutilCommand, + ossutilCommandArgs, + }); } } -function uploadWithRetry(asset, bucket, key, config) { +function uploadWithRetry( + asset, + bucket, + key, + config, + { ossutilCommand, ossutilCommandArgs }, +) { for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { const result = spawnSync( - 'ossutil', + ossutilCommand, [ + ...ossutilCommandArgs, 'cp', asset, `oss://${bucket}/${key}`, From 98315b21104664f54d0616f9774936632a5a4116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 18 May 2026 11:22:21 +0800 Subject: [PATCH 119/129] test(installer): use stable fake Aliyun version on Windows --- scripts/tests/install-script.test.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index d026f7320ec..bf7ea5bb0e5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -2914,11 +2914,9 @@ describe('Windows installer end-to-end', () => { const curlUrls = readScript(curlLog); expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); expect(curlUrls).toContain( - '/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip', - ); - expect(curlUrls).toContain( - '/releases/qwen-code/v0.0.0-smoke/SHA256SUMS', + '/releases/qwen-code/v0.0.0/qwen-code-win-x64.zip', ); + expect(curlUrls).toContain('/releases/qwen-code/v0.0.0/SHA256SUMS'); expect(curlUrls).not.toContain( '/releases/qwen-code/latest/qwen-code-win-x64.zip', ); @@ -3339,14 +3337,14 @@ function createFakeWindowsCurlCommand(fakeBin) { 'if "!url!"=="" echo missing url or destination 1>&2 & exit /b 2', 'if "!destination!"=="" echo missing url or destination 1>&2 & exit /b 2', 'echo(!url! | findstr /I /C:"/releases/qwen-code/latest/VERSION" >nul && (', - ' > "!destination!" echo 0.0.0-smoke', + ' > "!destination!" echo 0.0.0', ' exit /b 0', ')', - 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0-smoke/qwen-code-win-x64.zip" >nul && (', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0/qwen-code-win-x64.zip" >nul && (', ' copy /Y "%QWEN_FAKE_ARCHIVE%" "!destination!" >nul', ' exit /b 0', ')', - 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0-smoke/SHA256SUMS" >nul && (', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0/SHA256SUMS" >nul && (', ' copy /Y "%QWEN_FAKE_SHA256SUMS%" "!destination!" >nul', ' exit /b 0', ')', From 584bb59ab573771753d39318ac1dbc8a40ac0ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 18 May 2026 11:59:51 +0800 Subject: [PATCH 120/129] fix(installer): parse Aliyun latest version in batch --- .../installation/install-qwen-standalone.bat | 2639 +++++++++-------- scripts/tests/install-script.test.js | 5 +- 2 files changed, 1325 insertions(+), 1319 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index ecd0e683922..e85e21a6d92 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -1,1318 +1,1321 @@ -@echo off -REM Qwen Code Installation Script -REM Installs Qwen Code from a standalone archive when available, with npm fallback. -REM This script intentionally does not install Node.js or change npm config. - -setlocal enabledelayedexpansion - -call :ValidateRawEnvironmentOptions -if %ERRORLEVEL% NEQ 0 exit /b 1 - -set "SOURCE=unknown" -set "METHOD=" -if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" -set "MIRROR=auto" -if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" -set "NO_MODIFY_PATH=0" -if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" -set "BASE_URL=" -if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" -set "ARCHIVE_PATH=" -if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" -set "VERSION=latest" -if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" -set "NPM_REGISTRY=https://registry.npmmirror.com" -if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" -if defined LOCALAPPDATA ( - set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" -) else ( - set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" -) -if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" -set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" -if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" -set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" -if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" - -REM Parse flags before any network or filesystem work. -:parse_args -if "%~1"=="" goto end_parse -set "ARG_RAW=%~1" -set "ARG_KEY=%~1" -set "ARG_VALUE=" -set "ARG_HAS_INLINE_VALUE=0" -for /f "tokens=1,* delims==" %%A in ("%~1") do ( - set "ARG_KEY=%%~A" - set "ARG_VALUE=%%~B" -) -if not "!ARG_KEY!"=="!ARG_RAW!" set "ARG_HAS_INLINE_VALUE=1" -if /i "!ARG_KEY!"=="--source" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --source requires a value - exit /b 1 - ) - set "SOURCE=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --source requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="-s" ( - if "%~2"=="" ( - echo ERROR: -s requires a value - exit /b 1 - ) - set "SOURCE=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--method" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --method requires a value - exit /b 1 - ) - set "METHOD=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --method requires a value - exit /b 1 - ) - set "METHOD=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--mirror" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --mirror requires a value - exit /b 1 - ) - set "MIRROR=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --mirror requires a value - exit /b 1 - ) - set "MIRROR=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--base-url" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --base-url requires a value - exit /b 1 - ) - set "BASE_URL=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --base-url requires a value - exit /b 1 - ) - set "BASE_URL=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--archive" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --archive requires a value - exit /b 1 - ) - set "ARCHIVE_PATH=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --archive requires a value - exit /b 1 - ) - set "ARCHIVE_PATH=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--version" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --version requires a value - exit /b 1 - ) - set "VERSION=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --version requires a value - exit /b 1 - ) - set "VERSION=%~2" - shift - shift - goto parse_args -) -if /i "!ARG_KEY!"=="--registry" ( - if "!ARG_HAS_INLINE_VALUE!"=="1" ( - if "!ARG_VALUE!"=="" ( - echo ERROR: --registry requires a value - exit /b 1 - ) - set "NPM_REGISTRY=!ARG_VALUE!" - shift - goto parse_args - ) - if "%~2"=="" ( - echo ERROR: --registry requires a value - exit /b 1 - ) - set "NPM_REGISTRY=%~2" - shift - shift - goto parse_args -) -if /i "%~1"=="--no-modify-path" ( - set "NO_MODIFY_PATH=1" - shift - goto parse_args -) -if /i "%~1"=="-h" goto usage -if /i "%~1"=="--help" goto usage - -echo ERROR: Unknown option. -echo. -goto usage_error - -:end_parse - -call :ValidateOptions -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :PrintHeader - -REM Discover all qwen executables on disk BEFORE we install. We can't -REM reliably simulate the user's PATH ordering, so enumerate well-known -REM per-tool bin directories plus everything `where qwen` returns. -call :CreateTempFile "qwen-pre-install" -if !ERRORLEVEL! NEQ 0 exit /b 1 -set "PRE_INSTALL_QWENS_FILE=!TEMP_FILE!" -rem Avoid `call echo` here: `call` triggers an extra parse pass on the -rem expanded path, so a directory containing &/|/<,>/etc. would be re-evaluated -rem as command separators. Plain `echo` writes the literal value. -for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" -for %%c in ( - "!USERPROFILE!\.opencode\bin\qwen.cmd" - "!APPDATA!\npm\qwen.cmd" - "!USERPROFILE!\.bun\bin\qwen.cmd" - "!LOCALAPPDATA!\bun\bin\qwen.cmd" - "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" -) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" -for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( - if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" -) -set "PRE_INSTALL_QWENS_LIST=" -if exist "!PRE_INSTALL_QWENS_FILE!" ( - for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( - if "!PRE_INSTALL_QWENS_LIST!"=="" ( - set "PRE_INSTALL_QWENS_LIST=%%i" - ) else ( - echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 - if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" - ) - ) - del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 -) - -REM Dispatch after validation; detect falls back to npm only when unavailable. -if /i "!METHOD!"=="standalone" ( - call :InstallStandalone - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" - endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" - exit /b 0 -) - -if /i "!METHOD!"=="npm" ( - call :InstallNpm - if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" "" "npm" - endlocal - exit /b 0 -) - -call :InstallStandalone -set "STANDALONE_STATUS=!ERRORLEVEL!" -if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" - endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" - exit /b 0 -) - -if !STANDALONE_STATUS! EQU 2 ( - echo WARNING: Falling back to npm installation. - call :InstallNpm - if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. - echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. - exit /b !ERRORLEVEL! - ) - call :PrintFinalInstructions "" "" "npm" - endlocal - exit /b 0 -) - -echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. -exit /b !STANDALONE_STATUS! - -:usage -call :PrintUsage -exit /b 0 - -:usage_error -call :PrintUsage -exit /b 1 - -:PrintUsage -echo Qwen Code Installer -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. -exit /b 0 - -:PrintHeader -set "DISPLAY_VERSION=!VERSION!" -if /i not "!DISPLAY_VERSION!"=="latest" ( - if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" -) -echo Installing Qwen Code version: !DISPLAY_VERSION! -exit /b 0 - -:ValidateRawEnvironmentOptions -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO','QWEN_INSTALL_CURL_EXE'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" -if %ERRORLEVEL% EQU 0 exit /b 0 -echo ERROR: installer options contain unsafe command characters. -exit /b 1 - -:ValidateOptions -if "!METHOD!"=="" set "METHOD=detect" - -set "QWEN_VALIDATE_METHOD=!METHOD!" -set "QWEN_VALIDATE_MIRROR=!MIRROR!" -set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" -set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" -set "QWEN_VALIDATE_VERSION=!VERSION!" -set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" -set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" -set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -set "QWEN_VALIDATE_SOURCE=!SOURCE!" -call :CreateTempFile "qwen-validate-options" ".ps1" -if !ERRORLEVEL! NEQ 0 exit /b 1 -set "QWEN_VALIDATE_OPTIONS_SCRIPT=!TEMP_FILE!" -> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $names = @('METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo foreach ($name in $names) { ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name) ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo } ->> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo exit 0 -powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!" -set "PS_STATUS=%ERRORLEVEL%" -del /F /Q "!QWEN_VALIDATE_OPTIONS_SCRIPT!" >nul 2>&1 -set "QWEN_VALIDATE_OPTIONS_SCRIPT=" -set "QWEN_VALIDATE_METHOD=" -set "QWEN_VALIDATE_MIRROR=" -set "QWEN_VALIDATE_BASE_URL=" -set "QWEN_VALIDATE_ARCHIVE_PATH=" -set "QWEN_VALIDATE_VERSION=" -set "QWEN_VALIDATE_NPM_REGISTRY=" -set "QWEN_VALIDATE_INSTALL_BASE=" -set "QWEN_VALIDATE_INSTALL_DIR=" -set "QWEN_VALIDATE_INSTALL_BIN_DIR=" -set "QWEN_VALIDATE_SOURCE=" -if %PS_STATUS% NEQ 0 ( - echo ERROR: installer options contain unsafe command characters. - exit /b 1 -) - -if "!INSTALL_BASE!"=="" ( - echo ERROR: QWEN_INSTALL_ROOT must not be empty. - exit /b 1 -) -if "!INSTALL_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BIN_DIR!"=="" ( - echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. - exit /b 1 -) -if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok -if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok -if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok -echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. -exit /b 1 -:validate_install_base_ok -if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok -if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok -if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok -echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. -exit /b 1 -:validate_install_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok -if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok -echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. -exit /b 1 -:validate_install_bin_dir_ok - -if /i "!METHOD!"=="detect" goto validate_method_ok -if /i "!METHOD!"=="standalone" goto validate_method_ok -if /i "!METHOD!"=="npm" goto validate_method_ok -echo ERROR: --method must be detect, standalone, or npm. -exit /b 1 - -:validate_method_ok -if /i "!MIRROR!"=="github" goto validate_mirror_ok -if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok -if /i "!MIRROR!"=="auto" goto validate_mirror_ok -echo ERROR: --mirror must be auto, github, or aliyun. -exit /b 1 - -:validate_mirror_ok -call :ValidateHttpsUrlVar "BASE_URL" "--base-url" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateVersion -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateGithubRepo -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :ValidateSource -exit /b %ERRORLEVEL% - -:ValidateHttpsUrlVar -set "URL_VALUE=!%~1!" -set "URL_OPTION=%~2" -if "!URL_VALUE!"=="" exit /b 0 -if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 - -echo ERROR: !URL_OPTION! must start with https:// -exit /b 1 - -:ValidateVersion -if /i "!VERSION!"=="latest" 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 - -:ValidateGithubRepo -if not defined QWEN_INSTALL_GITHUB_REPO exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_INSTALL_GITHUB_REPO; if ($value -match '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { exit 0 }; exit 1" -if %ERRORLEVEL% EQU 0 exit /b 0 - -echo ERROR: QWEN_INSTALL_GITHUB_REPO must be in owner/repo format. -exit /b 1 - -:ValidateSource -if "!SOURCE!"=="unknown" exit /b 0 -echo(!SOURCE!| findstr /R /C:"^[A-Za-z][A-Za-z0-9._-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 - -echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. -exit /b 1 - -:DetectTarget -set "TARGET=" -rem Keep :DetectTarget in sync with RELEASE_TARGETS in scripts/build-standalone-release.js. -rem RELEASE_TARGETS currently has no win-arm64 entry, so ARM64 falls through -rem to the unsupported-architecture branch and the caller can fall back to npm. -if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" -if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" -if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" -if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" -if "!TARGET!"=="" ( - echo WARNING: Standalone archive is not available for this Windows architecture. - exit /b 1 -) -exit /b 0 - -:ReleaseVersionPath -if /i "!VERSION!"=="latest" ( - set "VERSION_PATH=latest" - exit /b 0 -) -set "VERSION_PATH=!VERSION!" -if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 -set "VERSION_PATH=v!VERSION_PATH!" -exit /b 0 - -:GithubBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_GH_BASE_URL -set "QWEN_GH_REPO=QwenLM/qwen-code" -if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" -if /i "%~1"=="latest" ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" -) else ( - set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" -) -set "QWEN_GH_REPO=" -exit /b 0 - -:AliyunBaseUrlForVersion -rem args: %~1=version_path → sets QWEN_OSS_BASE_URL -set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" -exit /b 0 - -:AliyunLatestVersionUrl -set "QWEN_OSS_LATEST_VERSION_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" -exit /b 0 - -:RaceMirrorHead -rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url -rem Sets QWEN_RACE_RESULT to "aliyun", "github", or "timeout". Sequential -rem (OSS first, GH fallback) keeps the PowerShell snippet small; a true -rem parallel race adds a lot of escaping for marginal speedup since OSS HEAD -rem is sub-second when reachable. Caller decides what to do with "timeout" -rem (currently: log it and fall back to github). -set "QWEN_RACE_TIMEOUT=%~1" -set "QWEN_RACE_GH_URL=%~2" -set "QWEN_RACE_OSS_URL=%~3" -set "QWEN_RACE_RESULT=timeout" -for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'timeout'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" -set "QWEN_RACE_TIMEOUT=" -set "QWEN_RACE_GH_URL=" -set "QWEN_RACE_OSS_URL=" -exit /b 0 - -:StandaloneBaseUrl -set "STANDALONE_VERSION_PATH=" -if not "!BASE_URL!"=="" ( - set "STANDALONE_BASE_URL=!BASE_URL!" - exit /b 0 -) - -call :ReleaseVersionPath -set "STANDALONE_VERSION_PATH=!VERSION_PATH!" - -if /i "!MIRROR!"=="auto" ( - call :GithubBaseUrlForVersion "!VERSION_PATH!" - if /i "!VERSION_PATH!"=="latest" ( - call :AliyunLatestVersionUrl - set "QWEN_OSS_PROBE_URL=!QWEN_OSS_LATEST_VERSION_URL!" - ) else ( - call :AliyunBaseUrlForVersion "!VERSION_PATH!" - set "QWEN_OSS_PROBE_URL=!QWEN_OSS_BASE_URL!/SHA256SUMS" - ) - 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. - set "MIRROR=github" - ) else ( - set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT! - ) - set "QWEN_GH_BASE_URL=" - set "QWEN_OSS_BASE_URL=" - set "QWEN_OSS_LATEST_VERSION_URL=" - set "QWEN_OSS_PROBE_URL=" - set "QWEN_RACE_RESULT=" -) - -if /i "!MIRROR!"=="aliyun" ( - call :ResolveAliyunVersionPath "!VERSION_PATH!" - if !ERRORLEVEL! NEQ 0 exit /b 1 - set "STANDALONE_VERSION_PATH=!RESOLVED_VERSION_PATH!" - call :AliyunBaseUrlForVersion "!RESOLVED_VERSION_PATH!" - set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" - set "QWEN_OSS_BASE_URL=" - set "RESOLVED_VERSION_PATH=" - exit /b 0 -) - -call :GithubBaseUrlForVersion "!VERSION_PATH!" -set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" -set "QWEN_GH_BASE_URL=" -exit /b 0 - -:UseGithubFallbackBaseUrl -set "STANDALONE_BASE_URL=!GITHUB_FALLBACK_BASE_URL!" -set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" -set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" -set "GITHUB_FALLBACK_BASE_URL=" -set "MIRROR=github" -exit /b 0 - -:MaybeUpdateUserPath -rem args: %~1=install_bin_dir -rem Prepend the install dir to the user-level PATH (HKCU\Environment) via -rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is -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.'" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_NEW_BIN=" -exit /b %PS_STATUS% - -:UrlExists -set "QWEN_CHECK_URL=%~1" -rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. -rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. -powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Test-QwenUrl($method, $range) { try { $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = $method; if ($range) { $request.Headers.Add('Range', 'bytes=0-0') }; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; $response = $request.GetResponse(); $response.Close(); return $true } catch { return $false } }; if (Test-QwenUrl 'HEAD' $false) { exit 0 }; if (Test-QwenUrl 'GET' $true) { exit 0 }; exit 1" >nul 2>&1 -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_CHECK_URL=" -exit /b %PS_STATUS% - -:DownloadFile -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 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_DOWNLOAD_URL=" -set "QWEN_DOWNLOAD_DEST=" -exit /b %PS_STATUS% - -:DownloadFileQuiet -set "QWEN_DOWNLOAD_URL=%~1" -set "QWEN_DOWNLOAD_DEST=%~2" -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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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=" -exit /b %PS_STATUS% - -:ResolveAliyunVersionPath -set "RESOLVED_VERSION_PATH=" -if /i not "%~1"=="latest" ( - set "RESOLVED_VERSION_PATH=%~1" - exit /b 0 -) - -call :AliyunLatestVersionUrl -call :CreateTempFile "qwen-code-latest-version" -if !ERRORLEVEL! NEQ 0 exit /b 1 -set "TEMP_VERSION_FILE=!TEMP_FILE!" - -call :DownloadFileQuiet "!QWEN_OSS_LATEST_VERSION_URL!" "!TEMP_VERSION_FILE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 - set "TEMP_VERSION_FILE=" - set "QWEN_OSS_LATEST_VERSION_URL=" - echo WARNING: Failed to resolve Aliyun latest VERSION pointer. - exit /b 1 -) - -set "VERSION_POINTER_VALUE=" -for /f "usebackq delims=" %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" -set "QWEN_VERSION_VALUE=!VERSION_POINTER_VALUE!" -for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" -set "QWEN_VERSION_VALUE=" -set "VERSION_POINTER_VALUE=" -if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 -set "TEMP_VERSION_FILE=" -set "QWEN_OSS_LATEST_VERSION_URL=" - -if "!RESOLVED_VERSION_PATH!"=="" ( - echo ERROR: Aliyun latest VERSION pointer is not a valid semver value. - exit /b 1 -) - -echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. -exit /b 0 - -:VerifyChecksum -set "ARCHIVE_FILE=%~1" -set "CHECKSUM_SOURCE=%~2" -set "ARCHIVE_NAME=%~3" -set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" -set "TEMP_CHECKSUM=" -if "!CHECKSUM_FILE!"=="" ( - for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" -) else ( - if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( - call :CreateTempFile "qwen-code-checksums" - if !ERRORLEVEL! NEQ 0 exit /b 1 - set "TEMP_CHECKSUM=!TEMP_FILE!" - call :DownloadFile "!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. - exit /b 1 - ) - set "CHECKSUM_FILE=!TEMP_CHECKSUM!" - ) -) - -if not exist "!CHECKSUM_FILE!" ( - echo ERROR: SHA256SUMS not found; cannot verify archive. - exit /b 1 -) - -set "EXPECTED_HASH=" -for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( - set "CHECKSUM_HASH=%%H" - set "CHECKSUM_NAME=%%I" - if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" - if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( - if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" - ) -) - -if "!EXPECTED_HASH!"=="" ( - if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. - exit /b 1 -) - -set "ACTUAL_HASH=" -set "QWEN_HASH_FILE=!ARCHIVE_FILE!" -for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( - if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" -) -set "QWEN_HASH_FILE=" - -if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 - -if "!ACTUAL_HASH!"=="" ( - echo ERROR: Could not calculate SHA-256 checksum for archive. - exit /b 1 -) - -if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( - echo ERROR: Checksum mismatch for !ARCHIVE_NAME!: expected !EXPECTED_HASH!, got !ACTUAL_HASH!. - exit /b 1 -) - -echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. -exit /b 0 - -:InstallStandalone -set "TEMP_DIR=" -set "CHECKSUM_SOURCE=" - -REM Resolve the archive from a local file or from the configured release mirror. -if not "!ARCHIVE_PATH!"=="" ( - set "ARCHIVE_FILE=!ARCHIVE_PATH!" - for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" - if not exist "!ARCHIVE_FILE!" ( - echo ERROR: Standalone archive not found: !ARCHIVE_FILE! - exit /b 1 - ) -) else ( - call :DetectTarget - if !ERRORLEVEL! NEQ 0 exit /b 2 - - set "ARCHIVE_NAME=qwen-code-!TARGET!.zip" - set "REQUESTED_MIRROR=!MIRROR!" - set "REQUESTED_VERSION_PATH=" - set "GITHUB_FALLBACK_BASE_URL=" - if "!BASE_URL!"=="" if /i "!REQUESTED_MIRROR!"=="auto" ( - call :ReleaseVersionPath - set "REQUESTED_VERSION_PATH=!VERSION_PATH!" - call :GithubBaseUrlForVersion "!VERSION_PATH!" - set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" - set "QWEN_GH_BASE_URL=" - ) - - call :StandaloneBaseUrl - if !ERRORLEVEL! NEQ 0 ( - set "USE_GITHUB_FALLBACK=0" - if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!MIRROR!"=="aliyun" set "USE_GITHUB_FALLBACK=1" - if "!USE_GITHUB_FALLBACK!"=="1" ( - echo WARNING: Aliyun standalone release metadata unavailable; retrying GitHub mirror. - call :UseGithubFallbackBaseUrl - ) else ( - if /i "!METHOD!"=="detect" exit /b 2 - exit /b 1 - ) - ) - if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!REQUESTED_VERSION_PATH!"=="latest" if /i "!MIRROR!"=="aliyun" if not "!STANDALONE_VERSION_PATH!"=="" ( - call :GithubBaseUrlForVersion "!STANDALONE_VERSION_PATH!" - set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" - set "QWEN_GH_BASE_URL=" - ) - if /i "!STANDALONE_BASE_URL!"=="!GITHUB_FALLBACK_BASE_URL!" set "GITHUB_FALLBACK_BASE_URL=" - set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" - set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" - - if /i "!METHOD!"=="detect" ( - call :UrlExists "!ARCHIVE_URL!" - if !ERRORLEVEL! NEQ 0 ( - set "USE_GITHUB_FALLBACK=0" - if not "!GITHUB_FALLBACK_BASE_URL!"=="" set "USE_GITHUB_FALLBACK=1" - if "!USE_GITHUB_FALLBACK!"=="1" ( - set "GITHUB_ARCHIVE_URL=!GITHUB_FALLBACK_BASE_URL!/!ARCHIVE_NAME!" - call :UrlExists "!GITHUB_ARCHIVE_URL!" - if !ERRORLEVEL! EQU 0 ( - echo WARNING: Aliyun standalone archive not found; retrying GitHub mirror. - call :UseGithubFallbackBaseUrl - ) else ( - set "GITHUB_ARCHIVE_URL=" - echo WARNING: Standalone archive not found: !ARCHIVE_NAME! - exit /b 2 - ) - set "GITHUB_ARCHIVE_URL=" - ) else ( - echo WARNING: Standalone archive not found: !ARCHIVE_NAME! - exit /b 2 - ) - ) - ) - - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 - set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - - echo Downloading !ARCHIVE_NAME! - call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" - set "DOWNLOAD_STATUS=!ERRORLEVEL!" - if not "!DOWNLOAD_STATUS!"=="0" if not "!GITHUB_FALLBACK_BASE_URL!"=="" ( - 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!" - ) - if not "!DOWNLOAD_STATUS!"=="0" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo WARNING: Failed to download standalone archive. - if /i "!METHOD!"=="detect" exit /b 2 - exit /b 1 - ) -) - -if "!TEMP_DIR!"=="" ( - call :CreateTempDir - if !ERRORLEVEL! NEQ 0 exit /b 1 -) - -REM Verify integrity before extraction or changing the install directory. -call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Extract into a temporary directory, then validate required entry points. -set "EXTRACT_DIR=!TEMP_DIR!\extract" -call :EnsureDir "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :ValidateArchiveContents "!ARCHIVE_FILE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" -set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_ARCHIVE_FILE=" -set "QWEN_EXTRACT_DIR=" -if !PS_STATUS! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to extract standalone archive. - exit /b 1 -) - -call :RejectArchiveLinks "!EXTRACT_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. - exit /b 1 -) - -if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Archive does not contain qwen-code\node\node.exe. - exit /b 1 -) - -call :EnsureDir "!INSTALL_BASE!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureDir "!INSTALL_BIN_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" -call :EnsureDir "!INSTALL_PARENT!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -REM Stage into .new and keep .old so failed upgrades can roll back. -set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" -set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" - -call :EnsureManagedInstallDir "!INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) -call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -call :RestoreStaleInstallBackup -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - exit /b 1 -) - -if exist "!NEW_INSTALL_DIR!" ( - rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. - exit /b 1 - ) -) -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale install backup: !OLD_INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to stage standalone archive. - exit /b 1 -) - -if exist "!INSTALL_DIR!" ( - move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul - if !ERRORLEVEL! NEQ 0 ( - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to back up existing install at !INSTALL_DIR!. - exit /b 1 - ) -) -move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. - exit /b 1 -) - -rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated -rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels -rem (`!`) and other shell-metacharacters in those values; if that validator is ever -rem loosened, the wrapper write below becomes a command injection sink. -( -echo @echo off -echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* -) > "!INSTALL_BIN_DIR!\qwen.cmd.new" -if !ERRORLEVEL! NEQ 0 ( - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) -move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul -if !ERRORLEVEL! NEQ 0 ( - if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 - call :RemoveInstalledDirWithWarning - call :RestoreOldInstall - if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. - exit /b 1 -) - -if exist "!OLD_INSTALL_DIR!" ( - rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 - if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! -) - -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! -exit /b 0 - -:CreateTempDir -set "TEMP_DIR=" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" -if "!TEMP_DIR!"=="" ( - echo ERROR: Failed to create a temporary directory. - exit /b 1 -) -exit /b 0 - -:CreateTempFile -set "TEMP_FILE=" -set "QWEN_TEMP_FILE_PREFIX=%~1" -set "QWEN_TEMP_FILE_EXTENSION=%~2" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" -set "QWEN_TEMP_FILE_PREFIX=" -set "QWEN_TEMP_FILE_EXTENSION=" -if "!TEMP_FILE!"=="" ( - echo ERROR: Failed to create a temporary file. - exit /b 1 -) -exit /b 0 - -:EnsureDir -set "REQUIRED_DIR=%~1" -set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_REQUIRED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 -if !PS_STATUS! EQU 2 ( - echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! - exit /b 1 -) -echo ERROR: Failed to create directory: !REQUIRED_DIR! -exit /b 1 - -:ValidateArchiveContents -set "QWEN_ARCHIVE_FILE=%~1" -REM Normalize backslashes to forward slashes before checking. Some Windows -REM zip producers (including PowerShell's Compress-Archive) emit entries -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() } }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_ARCHIVE_FILE=" -if %PS_STATUS% EQU 0 exit /b 0 -if %PS_STATUS% EQU 1 ( - echo ERROR: Archive contains unsafe path entries. - exit /b 1 -) -if %PS_STATUS% EQU 2 ( - echo ERROR: Archive could not be inspected before extraction. - exit /b 1 -) -echo ERROR: Archive validation failed before extraction. -exit /b %PS_STATUS% - -:RemoveInstalledDirWithWarning -if not exist "!INSTALL_DIR!" exit /b 0 -rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 -if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! -exit /b 0 - -:RestoreOldInstall -if not exist "!OLD_INSTALL_DIR!" exit /b 0 -move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. - exit /b 1 -) -exit /b 0 - -:RestoreStaleInstallBackup -if exist "!INSTALL_DIR!" exit /b 0 -if not exist "!OLD_INSTALL_DIR!" exit /b 0 -echo WARNING: Found previous install backup without an active install: !OLD_INSTALL_DIR! -echo WARNING: Restoring backup to !INSTALL_DIR! before continuing. -move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul -if !ERRORLEVEL! NEQ 0 ( - echo ERROR: Failed to restore previous install from !OLD_INSTALL_DIR!. - exit /b 1 -) -exit /b 0 - -:RejectArchiveLinks -set "QWEN_EXTRACT_DIR=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" -set "PS_STATUS=%ERRORLEVEL%" -set "QWEN_EXTRACT_DIR=" -if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. -exit /b %PS_STATUS% - -:EnsureManagedInstallDir -set "MANAGED_DIR=%~1" -set "QWEN_MANAGED_DIR=!MANAGED_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" -set "PS_STATUS=!ERRORLEVEL!" -set "QWEN_MANAGED_DIR=" -if !PS_STATUS! EQU 0 exit /b 0 - -rem Directory exists but is not a qwen-code standalone install. -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! -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. - exit /b 1 -) -exit /b 0 - -:RequireNode -where node >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Node.js was not found. - echo. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" -if "%NODE_VERSION%"=="" ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" -set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 -if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Unable to determine Node.js version. - echo Node.js 22 or newer is required before installing Qwen Code with npm. - exit /b 1 -) - -if %NODE_MAJOR_NUM% LSS 22 ( - echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. - echo Please install Node.js from https://nodejs.org/ and rerun this installer. - exit /b 1 -) - -echo SUCCESS: 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. - exit /b 1 -) - -for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" -echo SUCCESS: npm %NPM_VERSION% detected. -exit /b 0 - -:NpmPackageSpec -set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@latest" -if /i "!VERSION!"=="latest" exit /b 0 -set "NPM_VERSION_SPEC=!VERSION!" -if /i "!NPM_VERSION_SPEC:~0,1!"=="v" set "NPM_VERSION_SPEC=!NPM_VERSION_SPEC:~1!" -set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@!NPM_VERSION_SPEC!" -exit /b 0 - -:InstallNpm -call :RequireNode -if %ERRORLEVEL% NEQ 0 exit /b 1 - -call :RequireNpm -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! - exit /b 1 -) - -echo SUCCESS: Qwen Code installed successfully. -call :CreateSourceJson -exit /b 0 - -:CreateSourceJson -if "!SOURCE!"=="unknown" exit /b 0 - -set "QWEN_DIR=!USERPROFILE!\.qwen" -call :EnsureDir "!QWEN_DIR!" -if !ERRORLEVEL! NEQ 0 exit /b 1 - -( -echo { -echo "source": "!SOURCE!" -echo } -) > "!QWEN_DIR!\source.json" - -echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json -exit /b 0 - -:PrintFinalInstructions -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" -if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" - -set "INSTALLED_BIN=" -if not "!EXTRA_BIN!"=="" ( - set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" - set "PATH=!EXTRA_BIN!;!PATH!" -) - -echo. - -set "INSTALLED_VERSION=unknown" -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! - ) -) - -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 -) - -if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( - echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. - exit /b 0 -) -echo qwen is ready to use in this terminal. -exit /b 0 +@echo off +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. + +setlocal enabledelayedexpansion + +call :ValidateRawEnvironmentOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=auto" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "NO_MODIFY_PATH=0" +if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" + +REM Parse flags before any network or filesystem work. +:parse_args +if "%~1"=="" goto end_parse +set "ARG_RAW=%~1" +set "ARG_KEY=%~1" +set "ARG_VALUE=" +set "ARG_HAS_INLINE_VALUE=0" +for /f "tokens=1,* delims==" %%A in ("%~1") do ( + set "ARG_KEY=%%~A" + set "ARG_VALUE=%%~B" +) +if not "!ARG_KEY!"=="!ARG_RAW!" set "ARG_HAS_INLINE_VALUE=1" +if /i "!ARG_KEY!"=="--source" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-s" ( + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--method" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--mirror" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--base-url" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--archive" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--version" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--registry" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--no-modify-path" ( + set "NO_MODIFY_PATH=1" + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option. +echo. +goto usage_error + +:end_parse + +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :PrintHeader + +REM Discover all qwen executables on disk BEFORE we install. We can't +REM reliably simulate the user's PATH ordering, so enumerate well-known +REM per-tool bin directories plus everything `where qwen` returns. +call :CreateTempFile "qwen-pre-install" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "PRE_INSTALL_QWENS_FILE=!TEMP_FILE!" +rem Avoid `call echo` here: `call` triggers an extra parse pass on the +rem expanded path, so a directory containing &/|/<,>/etc. would be re-evaluated +rem as command separators. Plain `echo` writes the literal value. +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for %%c in ( + "!USERPROFILE!\.opencode\bin\qwen.cmd" + "!APPDATA!\npm\qwen.cmd" + "!USERPROFILE!\.bun\bin\qwen.cmd" + "!LOCALAPPDATA!\bun\bin\qwen.cmd" + "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" +) +set "PRE_INSTALL_QWENS_LIST=" +if exist "!PRE_INSTALL_QWENS_FILE!" ( + for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( + if "!PRE_INSTALL_QWENS_LIST!"=="" ( + set "PRE_INSTALL_QWENS_LIST=%%i" + ) else ( + echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 + if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" + ) + ) + del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +) + +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" "" "npm" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" "" "npm" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +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. +exit /b 0 + +:PrintHeader +set "DISPLAY_VERSION=!VERSION!" +if /i not "!DISPLAY_VERSION!"=="latest" ( + if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" +) +echo Installing Qwen Code version: !DISPLAY_VERSION! +exit /b 0 + +:ValidateRawEnvironmentOptions +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO','QWEN_INSTALL_CURL_EXE'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" +if %ERRORLEVEL% EQU 0 exit /b 0 +echo ERROR: installer options contain unsafe command characters. +exit /b 1 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +call :CreateTempFile "qwen-validate-options" ".ps1" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=!TEMP_FILE!" +> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $names = @('METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo foreach ($name in $names) { +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo exit 0 +powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!" +set "PS_STATUS=%ERRORLEVEL%" +del /F /Q "!QWEN_VALIDATE_OPTIONS_SCRIPT!" >nul 2>&1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +if /i "!MIRROR!"=="auto" goto validate_mirror_ok +echo ERROR: --mirror must be auto, github, or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateGithubRepo +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 + +:ValidateVersion +if /i "!VERSION!"=="latest" 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 + +:ValidateGithubRepo +if not defined QWEN_INSTALL_GITHUB_REPO exit /b 0 +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_INSTALL_GITHUB_REPO; if ($value -match '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { exit 0 }; exit 1" +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: QWEN_INSTALL_GITHUB_REPO must be in owner/repo format. +exit /b 1 + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +rem Keep :DetectTarget in sync with RELEASE_TARGETS in scripts/build-standalone-release.js. +rem RELEASE_TARGETS currently has no win-arm64 entry, so ARM64 falls through +rem to the unsupported-architecture branch and the caller can fall back to npm. +if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:GithubBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_GH_BASE_URL +set "QWEN_GH_REPO=QwenLM/qwen-code" +if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" +if /i "%~1"=="latest" ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" +) else ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" +) +set "QWEN_GH_REPO=" +exit /b 0 + +:AliyunBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_OSS_BASE_URL +set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" +exit /b 0 + +:AliyunLatestVersionUrl +set "QWEN_OSS_LATEST_VERSION_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" +exit /b 0 + +:RaceMirrorHead +rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url +rem Sets QWEN_RACE_RESULT to "aliyun", "github", or "timeout". Sequential +rem (OSS first, GH fallback) keeps the PowerShell snippet small; a true +rem parallel race adds a lot of escaping for marginal speedup since OSS HEAD +rem is sub-second when reachable. Caller decides what to do with "timeout" +rem (currently: log it and fall back to github). +set "QWEN_RACE_TIMEOUT=%~1" +set "QWEN_RACE_GH_URL=%~2" +set "QWEN_RACE_OSS_URL=%~3" +set "QWEN_RACE_RESULT=timeout" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'timeout'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_TIMEOUT=" +set "QWEN_RACE_GH_URL=" +set "QWEN_RACE_OSS_URL=" +exit /b 0 + +:StandaloneBaseUrl +set "STANDALONE_VERSION_PATH=" +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath +set "STANDALONE_VERSION_PATH=!VERSION_PATH!" + +if /i "!MIRROR!"=="auto" ( + call :GithubBaseUrlForVersion "!VERSION_PATH!" + if /i "!VERSION_PATH!"=="latest" ( + call :AliyunLatestVersionUrl + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_LATEST_VERSION_URL!" + ) else ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_BASE_URL!/SHA256SUMS" + ) + 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. + set "MIRROR=github" + ) else ( + set "MIRROR=!QWEN_RACE_RESULT!" + echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT! + ) + set "QWEN_GH_BASE_URL=" + set "QWEN_OSS_BASE_URL=" + set "QWEN_OSS_LATEST_VERSION_URL=" + set "QWEN_OSS_PROBE_URL=" + set "QWEN_RACE_RESULT=" +) + +if /i "!MIRROR!"=="aliyun" ( + call :ResolveAliyunVersionPath "!VERSION_PATH!" + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "STANDALONE_VERSION_PATH=!RESOLVED_VERSION_PATH!" + call :AliyunBaseUrlForVersion "!RESOLVED_VERSION_PATH!" + set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" + set "QWEN_OSS_BASE_URL=" + set "RESOLVED_VERSION_PATH=" + exit /b 0 +) + +call :GithubBaseUrlForVersion "!VERSION_PATH!" +set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" +set "QWEN_GH_BASE_URL=" +exit /b 0 + +:UseGithubFallbackBaseUrl +set "STANDALONE_BASE_URL=!GITHUB_FALLBACK_BASE_URL!" +set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" +set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" +set "GITHUB_FALLBACK_BASE_URL=" +set "MIRROR=github" +exit /b 0 + +:MaybeUpdateUserPath +rem args: %~1=install_bin_dir +rem Prepend the install dir to the user-level PATH (HKCU\Environment) via +rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is +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.'" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_NEW_BIN=" +exit /b %PS_STATUS% + +:UrlExists +set "QWEN_CHECK_URL=%~1" +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Test-QwenUrl($method, $range) { try { $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = $method; if ($range) { $request.Headers.Add('Range', 'bytes=0-0') }; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; $response = $request.GetResponse(); $response.Close(); return $true } catch { return $false } }; if (Test-QwenUrl 'HEAD' $false) { exit 0 }; if (Test-QwenUrl 'GET' $true) { exit 0 }; exit 1" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +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 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:DownloadFileQuiet +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +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 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; 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=" +exit /b %PS_STATUS% + +:ResolveAliyunVersionPath +set "RESOLVED_VERSION_PATH=" +if /i not "%~1"=="latest" ( + set "RESOLVED_VERSION_PATH=%~1" + exit /b 0 +) + +call :AliyunLatestVersionUrl +call :CreateTempFile "qwen-code-latest-version" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "TEMP_VERSION_FILE=!TEMP_FILE!" + +call :DownloadFileQuiet "!QWEN_OSS_LATEST_VERSION_URL!" "!TEMP_VERSION_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 + set "TEMP_VERSION_FILE=" + set "QWEN_OSS_LATEST_VERSION_URL=" + echo WARNING: Failed to resolve Aliyun latest VERSION pointer. + exit /b 1 +) + +set "VERSION_POINTER_VALUE=" +for /f "usebackq tokens=* delims= " %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" +set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE: =!" +if /i "!VERSION_POINTER_VALUE:~0,1!"=="v" set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE:~1!" +if not "!VERSION_POINTER_VALUE!"=="" ( + echo(!VERSION_POINTER_VALUE!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul + if !ERRORLEVEL! EQU 0 set "RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!" +) +set "VERSION_POINTER_VALUE=" +if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 +set "TEMP_VERSION_FILE=" +set "QWEN_OSS_LATEST_VERSION_URL=" + +if "!RESOLVED_VERSION_PATH!"=="" ( + echo ERROR: Aliyun latest VERSION pointer is not a valid semver value. + exit /b 1 +) + +echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. +exit /b 0 + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + call :CreateTempFile "qwen-code-checksums" + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "TEMP_CHECKSUM=!TEMP_FILE!" + call :DownloadFile "!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. + exit /b 1 + ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" + ) +) + +if not exist "!CHECKSUM_FILE!" ( + echo ERROR: SHA256SUMS not found; cannot verify archive. + exit /b 1 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum mismatch for !ARCHIVE_NAME!: expected !EXPECTED_HASH!, got !ACTUAL_HASH!. + exit /b 1 +) + +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +exit /b 0 + +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-!TARGET!.zip" + set "REQUESTED_MIRROR=!MIRROR!" + set "REQUESTED_VERSION_PATH=" + set "GITHUB_FALLBACK_BASE_URL=" + if "!BASE_URL!"=="" if /i "!REQUESTED_MIRROR!"=="auto" ( + call :ReleaseVersionPath + set "REQUESTED_VERSION_PATH=!VERSION_PATH!" + call :GithubBaseUrlForVersion "!VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" + ) + + call :StandaloneBaseUrl + if !ERRORLEVEL! NEQ 0 ( + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!MIRROR!"=="aliyun" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + echo WARNING: Aliyun standalone release metadata unavailable; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) + ) + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!REQUESTED_VERSION_PATH!"=="latest" if /i "!MIRROR!"=="aliyun" if not "!STANDALONE_VERSION_PATH!"=="" ( + call :GithubBaseUrlForVersion "!STANDALONE_VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" + ) + if /i "!STANDALONE_BASE_URL!"=="!GITHUB_FALLBACK_BASE_URL!" set "GITHUB_FALLBACK_BASE_URL=" + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + set "GITHUB_ARCHIVE_URL=!GITHUB_FALLBACK_BASE_URL!/!ARCHIVE_NAME!" + call :UrlExists "!GITHUB_ARCHIVE_URL!" + if !ERRORLEVEL! EQU 0 ( + echo WARNING: Aliyun standalone archive not found; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + set "GITHUB_ARCHIVE_URL=" + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + set "GITHUB_ARCHIVE_URL=" + ) else ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + ) + + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo Downloading !ARCHIVE_NAME! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + set "DOWNLOAD_STATUS=!ERRORLEVEL!" + if not "!DOWNLOAD_STATUS!"=="0" if not "!GITHUB_FALLBACK_BASE_URL!"=="" ( + 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!" + ) + if not "!DOWNLOAD_STATUS!"=="0" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) +) + +if "!TEMP_DIR!"=="" ( + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 +) + +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +call :EnsureDir "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +call :EnsureDir "!INSTALL_BASE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureDir "!INSTALL_BIN_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +call :EnsureDir "!INSTALL_PARENT!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +call :RestoreStaleInstallBackup +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale install backup: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated +rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels +rem (`!`) and other shell-metacharacters in those values; if that validator is ever +rem loosened, the wrapper write below becomes a command injection sink. +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) + +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! +exit /b 0 + +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:CreateTempFile +set "TEMP_FILE=" +set "QWEN_TEMP_FILE_PREFIX=%~1" +set "QWEN_TEMP_FILE_EXTENSION=%~2" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" +set "QWEN_TEMP_FILE_PREFIX=" +set "QWEN_TEMP_FILE_EXTENSION=" +if "!TEMP_FILE!"=="" ( + echo ERROR: Failed to create a temporary file. + exit /b 1 +) +exit /b 0 + +:EnsureDir +set "REQUIRED_DIR=%~1" +set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_REQUIRED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 +if !PS_STATUS! EQU 2 ( + echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! + exit /b 1 +) +echo ERROR: Failed to create directory: !REQUIRED_DIR! +exit /b 1 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +REM Normalize backslashes to forward slashes before checking. Some Windows +REM zip producers (including PowerShell's Compress-Archive) emit entries +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() } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% EQU 0 exit /b 0 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RestoreStaleInstallBackup +if exist "!INSTALL_DIR!" exit /b 0 +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +echo WARNING: Found previous install backup without an active install: !OLD_INSTALL_DIR! +echo WARNING: Restoring backup to !INSTALL_DIR! before continuing. +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo ERROR: Failed to restore previous install from !OLD_INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +set "QWEN_MANAGED_DIR=!MANAGED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_MANAGED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 + +rem Directory exists but is not a qwen-code standalone install. +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! +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. + exit /b 1 +) +exit /b 0 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 22 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: 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. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:NpmPackageSpec +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@latest" +if /i "!VERSION!"=="latest" exit /b 0 +set "NPM_VERSION_SPEC=!VERSION!" +if /i "!NPM_VERSION_SPEC:~0,1!"=="v" set "NPM_VERSION_SPEC=!NPM_VERSION_SPEC:~1!" +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@!NPM_VERSION_SPEC!" +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +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! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +call :EnsureDir "!QWEN_DIR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +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" +if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" + +set "INSTALLED_BIN=" +if not "!EXTRA_BIN!"=="" ( + set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" + set "PATH=!EXTRA_BIN!;!PATH!" +) + +echo. + +set "INSTALLED_VERSION=unknown" +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! + ) +) + +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 +) + +if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( + echo INFO: 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/tests/install-script.test.js b/scripts/tests/install-script.test.js index bf7ea5bb0e5..df47a052006 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -224,7 +224,10 @@ describe('installation scripts', () => { expect(script).toContain('Uninstall:'); expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('VERSION_POINTER_VALUE'); - expect(script).toContain('IsNullOrWhiteSpace($value)'); + expect(script).toContain('RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!'); + expect(script).toContain( + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', + ); expect(script).not.toContain('QWEN_VERSION_POINTER_FILE'); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); From 62ab5f47de056e3df766d53e81d95f13c633c545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 18 May 2026 13:06:47 +0800 Subject: [PATCH 121/129] fix(installer): validate Aliyun latest version without findstr --- .../installation/install-qwen-standalone.bat | 43 ++++++++++++++++++- scripts/tests/install-script.test.js | 5 ++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index e85e21a6d92..94f93d1465a 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -652,7 +652,7 @@ for /f "usebackq tokens=* delims= " %%V in ("!TEMP_VERSION_FILE!") do if not def set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE: =!" if /i "!VERSION_POINTER_VALUE:~0,1!"=="v" set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE:~1!" if not "!VERSION_POINTER_VALUE!"=="" ( - echo(!VERSION_POINTER_VALUE!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul + call :ValidateSemverCore "!VERSION_POINTER_VALUE!" if !ERRORLEVEL! EQU 0 set "RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!" ) set "VERSION_POINTER_VALUE=" @@ -668,6 +668,47 @@ if "!RESOLVED_VERSION_PATH!"=="" ( echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. exit /b 0 +:ValidateSemverCore +set "SEMVER_VALUE=%~1" +set "SEMVER_MAJOR=" +set "SEMVER_MINOR=" +set "SEMVER_PATCH=" +set "SEMVER_EXTRA=" +for /f "tokens=1,2,3,4 delims=." %%A in ("!SEMVER_VALUE!") do ( + set "SEMVER_MAJOR=%%A" + set "SEMVER_MINOR=%%B" + set "SEMVER_PATCH=%%C" + set "SEMVER_EXTRA=%%D" +) +set "SEMVER_VALUE=" +if "!SEMVER_MAJOR!"=="" exit /b 1 +if "!SEMVER_MINOR!"=="" exit /b 1 +if "!SEMVER_PATCH!"=="" exit /b 1 +if not "!SEMVER_EXTRA!"=="" exit /b 1 +call :ValidateNumericToken "!SEMVER_MAJOR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 +call :ValidateNumericToken "!SEMVER_MINOR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 +call :ValidateNumericToken "!SEMVER_PATCH!" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "SEMVER_MAJOR=" +set "SEMVER_MINOR=" +set "SEMVER_PATCH=" +exit /b 0 + +:ValidateNumericToken +set "NUMERIC_TOKEN=%~1" +if "!NUMERIC_TOKEN!"=="" exit /b 1 +set "NUMERIC_REMAINDER=!NUMERIC_TOKEN!" +for %%D in (0 1 2 3 4 5 6 7 8 9) do set "NUMERIC_REMAINDER=!NUMERIC_REMAINDER:%%D=!" +set "NUMERIC_TOKEN=" +if "!NUMERIC_REMAINDER!"=="" ( + set "NUMERIC_REMAINDER=" + exit /b 0 +) +set "NUMERIC_REMAINDER=" +exit /b 1 + :VerifyChecksum set "ARCHIVE_FILE=%~1" set "CHECKSUM_SOURCE=%~2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index df47a052006..0807e4b1923 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -225,7 +225,10 @@ describe('installation scripts', () => { expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('VERSION_POINTER_VALUE'); expect(script).toContain('RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!'); - expect(script).toContain( + expect(script).toContain('call :ValidateSemverCore'); + expect(script).toContain(':ValidateSemverCore'); + expect(script).toContain(':ValidateNumericToken'); + expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); expect(script).not.toContain('QWEN_VERSION_POINTER_FILE'); From b69af6715662b02f3a8d426e73578574bc099531 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 14:18:07 +0800 Subject: [PATCH 122/129] fix(installer): normalize Aliyun latest version via PowerShell --- .../installation/install-qwen-standalone.bat | 49 ++----------------- scripts/tests/install-script.test.js | 6 +-- 2 files changed, 5 insertions(+), 50 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 94f93d1465a..1252534767c 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -650,11 +650,9 @@ if !ERRORLEVEL! NEQ 0 ( set "VERSION_POINTER_VALUE=" for /f "usebackq tokens=* delims= " %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE: =!" -if /i "!VERSION_POINTER_VALUE:~0,1!"=="v" set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE:~1!" -if not "!VERSION_POINTER_VALUE!"=="" ( - call :ValidateSemverCore "!VERSION_POINTER_VALUE!" - if !ERRORLEVEL! EQU 0 set "RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!" -) +set "QWEN_VERSION_VALUE=!VERSION_POINTER_VALUE!" +for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" +set "QWEN_VERSION_VALUE=" set "VERSION_POINTER_VALUE=" if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 set "TEMP_VERSION_FILE=" @@ -668,47 +666,6 @@ if "!RESOLVED_VERSION_PATH!"=="" ( echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. exit /b 0 -:ValidateSemverCore -set "SEMVER_VALUE=%~1" -set "SEMVER_MAJOR=" -set "SEMVER_MINOR=" -set "SEMVER_PATCH=" -set "SEMVER_EXTRA=" -for /f "tokens=1,2,3,4 delims=." %%A in ("!SEMVER_VALUE!") do ( - set "SEMVER_MAJOR=%%A" - set "SEMVER_MINOR=%%B" - set "SEMVER_PATCH=%%C" - set "SEMVER_EXTRA=%%D" -) -set "SEMVER_VALUE=" -if "!SEMVER_MAJOR!"=="" exit /b 1 -if "!SEMVER_MINOR!"=="" exit /b 1 -if "!SEMVER_PATCH!"=="" exit /b 1 -if not "!SEMVER_EXTRA!"=="" exit /b 1 -call :ValidateNumericToken "!SEMVER_MAJOR!" -if !ERRORLEVEL! NEQ 0 exit /b 1 -call :ValidateNumericToken "!SEMVER_MINOR!" -if !ERRORLEVEL! NEQ 0 exit /b 1 -call :ValidateNumericToken "!SEMVER_PATCH!" -if !ERRORLEVEL! NEQ 0 exit /b 1 -set "SEMVER_MAJOR=" -set "SEMVER_MINOR=" -set "SEMVER_PATCH=" -exit /b 0 - -:ValidateNumericToken -set "NUMERIC_TOKEN=%~1" -if "!NUMERIC_TOKEN!"=="" exit /b 1 -set "NUMERIC_REMAINDER=!NUMERIC_TOKEN!" -for %%D in (0 1 2 3 4 5 6 7 8 9) do set "NUMERIC_REMAINDER=!NUMERIC_REMAINDER:%%D=!" -set "NUMERIC_TOKEN=" -if "!NUMERIC_REMAINDER!"=="" ( - set "NUMERIC_REMAINDER=" - exit /b 0 -) -set "NUMERIC_REMAINDER=" -exit /b 1 - :VerifyChecksum set "ARCHIVE_FILE=%~1" set "CHECKSUM_SOURCE=%~2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 0807e4b1923..741d06fda45 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -224,10 +224,8 @@ describe('installation scripts', () => { expect(script).toContain('Uninstall:'); expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('VERSION_POINTER_VALUE'); - expect(script).toContain('RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!'); - expect(script).toContain('call :ValidateSemverCore'); - expect(script).toContain(':ValidateSemverCore'); - expect(script).toContain(':ValidateNumericToken'); + expect(script).toContain('QWEN_VERSION_VALUE'); + expect(script).toContain("Write-Output ('v' + $value)"); expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); From f832d3330b717c8fcdd74c07ecf521108e0b6211 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 14:40:48 +0800 Subject: [PATCH 123/129] fix(installer): avoid captured PowerShell output in batch latest parsing --- .../installation/install-qwen-standalone.bat | 20 ++++++++++++++++--- scripts/tests/install-script.test.js | 4 +++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 1252534767c..d74b9257f34 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -650,9 +650,16 @@ if !ERRORLEVEL! NEQ 0 ( set "VERSION_POINTER_VALUE=" for /f "usebackq tokens=* delims= " %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE: =!" -set "QWEN_VERSION_VALUE=!VERSION_POINTER_VALUE!" -for /f "delims=" %%V in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if ($value.StartsWith('v')) { Write-Output $value } else { Write-Output ('v' + $value) }; exit 0 }; exit 1"') do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" -set "QWEN_VERSION_VALUE=" +if not "!VERSION_POINTER_VALUE!"=="" ( + call :ValidateSemverValue "!VERSION_POINTER_VALUE!" + if !ERRORLEVEL! EQU 0 ( + if /i "!VERSION_POINTER_VALUE:~0,1!"=="v" ( + set "RESOLVED_VERSION_PATH=!VERSION_POINTER_VALUE!" + ) else ( + set "RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!" + ) + ) +) set "VERSION_POINTER_VALUE=" if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 set "TEMP_VERSION_FILE=" @@ -666,6 +673,13 @@ if "!RESOLVED_VERSION_PATH!"=="" ( echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. exit /b 0 +:ValidateSemverValue +set "QWEN_VERSION_VALUE=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); 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=" +exit /b %PS_STATUS% + :VerifyChecksum set "ARCHIVE_FILE=%~1" set "CHECKSUM_SOURCE=%~2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 741d06fda45..0b6d5ec50d3 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -224,8 +224,10 @@ describe('installation scripts', () => { expect(script).toContain('Uninstall:'); expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('VERSION_POINTER_VALUE'); + expect(script).toContain('call :ValidateSemverValue'); + expect(script).toContain('RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!'); expect(script).toContain('QWEN_VERSION_VALUE'); - expect(script).toContain("Write-Output ('v' + $value)"); + expect(script).toContain(':ValidateSemverValue'); expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); From 994f2f49370500a15d854ca65ef8c6aeee88d470 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 15:17:16 +0800 Subject: [PATCH 124/129] fix(installer): normalize Aliyun latest pointer from file --- .../installation/install-qwen-standalone.bat | 32 +++++++------------ scripts/tests/install-script.test.js | 15 +++++---- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index d74b9257f34..b40a4cad930 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -647,20 +647,17 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) -set "VERSION_POINTER_VALUE=" -for /f "usebackq tokens=* delims= " %%V in ("!TEMP_VERSION_FILE!") do if not defined VERSION_POINTER_VALUE set "VERSION_POINTER_VALUE=%%V" -set "VERSION_POINTER_VALUE=!VERSION_POINTER_VALUE: =!" -if not "!VERSION_POINTER_VALUE!"=="" ( - call :ValidateSemverValue "!VERSION_POINTER_VALUE!" - if !ERRORLEVEL! EQU 0 ( - if /i "!VERSION_POINTER_VALUE:~0,1!"=="v" ( - set "RESOLVED_VERSION_PATH=!VERSION_POINTER_VALUE!" - ) else ( - set "RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!" - ) - ) -) -set "VERSION_POINTER_VALUE=" +set "NORMALIZED_VERSION_FILE=!TEMP_VERSION_FILE!.normalized" +set "QWEN_VERSION_POINTER_FILE=!TEMP_VERSION_FILE!" +set "QWEN_NORMALIZED_VERSION_FILE=!NORMALIZED_VERSION_FILE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = [IO.File]::ReadAllText($env:QWEN_VERSION_POINTER_FILE).Trim(); $value = $value.Trim([char]0xfeff); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if (-not $value.StartsWith('v')) { $value = 'v' + $value }; [IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE, $value, [Text.UTF8Encoding]::new($false)); exit 0 }; exit 1" +if !ERRORLEVEL! EQU 0 ( + for /f "usebackq delims=" %%V in ("!NORMALIZED_VERSION_FILE!") do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" +) +set "QWEN_VERSION_POINTER_FILE=" +set "QWEN_NORMALIZED_VERSION_FILE=" +if exist "!NORMALIZED_VERSION_FILE!" del /F /Q "!NORMALIZED_VERSION_FILE!" >nul 2>&1 +set "NORMALIZED_VERSION_FILE=" if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 set "TEMP_VERSION_FILE=" set "QWEN_OSS_LATEST_VERSION_URL=" @@ -673,13 +670,6 @@ if "!RESOLVED_VERSION_PATH!"=="" ( echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. exit /b 0 -:ValidateSemverValue -set "QWEN_VERSION_VALUE=%~1" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ([string]::IsNullOrWhiteSpace($value)) { exit 1 }; $value = $value.Trim(); 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=" -exit /b %PS_STATUS% - :VerifyChecksum set "ARCHIVE_FILE=%~1" set "CHECKSUM_SOURCE=%~2" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 0b6d5ec50d3..d029caeb85e 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -223,15 +223,18 @@ describe('installation scripts', () => { expect(script).toContain('Installed to:'); expect(script).toContain('Uninstall:'); expect(script).toContain('uninstall-qwen-standalone.ps1'); - expect(script).toContain('VERSION_POINTER_VALUE'); - expect(script).toContain('call :ValidateSemverValue'); - expect(script).toContain('RESOLVED_VERSION_PATH=v!VERSION_POINTER_VALUE!'); - expect(script).toContain('QWEN_VERSION_VALUE'); - expect(script).toContain(':ValidateSemverValue'); + expect(script).toContain('QWEN_VERSION_POINTER_FILE'); + expect(script).toContain('QWEN_NORMALIZED_VERSION_FILE'); + expect(script).toContain('NORMALIZED_VERSION_FILE'); + expect(script).toContain( + '[IO.File]::ReadAllText($env:QWEN_VERSION_POINTER_FILE)', + ); + expect(script).toContain( + '[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE', + ); expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); - expect(script).not.toContain('QWEN_VERSION_POINTER_FILE'); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); From ba695a8a6c02de7c9f0f05fd9e2d6d205c19dd72 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 18 May 2026 15:42:51 +0800 Subject: [PATCH 125/129] test(installer): fix fake Windows curl output parsing --- scripts/tests/install-script.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index d029caeb85e..b85e9bc0e00 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -407,6 +407,8 @@ describe('installation scripts', () => { expect(readScript(fakeCurl)).toContain( '/releases/qwen-code/latest/VERSION', ); + expect(readScript(fakeCurl)).toContain('set "destination=%~2"'); + expect(readScript(fakeCurl)).not.toContain('set "destination=%~1"'); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -3318,20 +3320,20 @@ function createFakeWindowsCurlCommand(fakeBin) { 'set "arg=%~1"', 'if "!arg:~0,1!"=="-" (', ' if /i "!arg!"=="-o" (', + ' set "destination=%~2"', ' shift', - ' set "destination=%~1"', ' shift', ' goto parse_args', ' )', ' if /i "!arg!"=="--output" (', + ' set "destination=%~2"', ' shift', - ' set "destination=%~1"', ' shift', ' goto parse_args', ' )', ' if not "!arg:~0,2!"=="--" if /i "!arg:~-1!"=="o" (', + ' set "destination=%~2"', ' shift', - ' set "destination=%~1"', ' shift', ' goto parse_args', ' )', From 09b04d6dcdc06ce70cb7e6468d555ce8b4f3a63d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 20 May 2026 14:09:57 +0800 Subject: [PATCH 126/129] fix(installer): print checksum path on miss, gate hardcoded version pin in ps1 [skip ci] Address two narrow follow-ups from PR #3828 review: - build-hosted-installation-assets.js: add a HOSTED_INSTALLER_FORBIDDEN_PATTERNS guard for install-qwen-standalone.ps1. The ps1 shim has no VERSION variable of its own (it forwards @args to the .bat), so the existing default-version positive-match patterns don't apply. The new guard fails the build if a $env:QWEN_INSTALL_VERSION assignment or a --version flag prepended to the forwarded argument list ever lands in the shim. Patterns are line-anchored with /m so the documented usage examples in the header docstring stay valid. Two vitest cases cover the reject and allow paths. - install-qwen-standalone.sh / .bat: include the searched checksum-file path in the "SHA256SUMS not found" error. Operators triaging --archive failures could not tell from the prior message whether the fallback path (next to the archive) or the remote URL was being looked up. Existing test assertions updated to match the new wording. Local validation: npm run test:scripts -> 160 passed | 9 skipped (was 158 | 9). --- scripts/build-hosted-installation-assets.js | 29 +++++ .../installation/install-qwen-standalone.bat | 2 +- .../installation/install-qwen-standalone.sh | 2 +- scripts/tests/install-script.test.js | 120 +++++++++++++++++- 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index 42c88c14a7b..e4fbfb68073 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -132,6 +132,26 @@ const HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS = { /VERSION\s*=\s*"\$\{QWEN_INSTALL_VERSION:-latest\}"/, 'install-qwen-standalone.bat': /set\s+"VERSION=latest"/, }; +// install-qwen-standalone.ps1 is a shim that downloads the .bat and forwards +// `@args` unchanged, so it has no VERSION variable to default-pin. Guard the +// shim instead with forbidden-content patterns: any attempt to hardcode a +// specific version (either by assigning $env:QWEN_INSTALL_VERSION or by +// prepending --version to the forwarded argument list) fails the build. +// Patterns are matched per non-comment line (PowerShell line comments start +// with `#`) so the usage examples in the header docstring keep working. +const HOSTED_INSTALLER_FORBIDDEN_PATTERNS = { + 'install-qwen-standalone.ps1': [ + { + name: 'no hardcoded QWEN_INSTALL_VERSION assignment', + pattern: /^\s*\$env:QWEN_INSTALL_VERSION\s*=/m, + }, + { + name: 'no hardcoded --version prepended to forwarded args', + pattern: + /^\s*&\s+\$qwenInstallerPath\s+(?:'--version'|"--version"|--version)/m, + }, + ], +}; // SHA256SUMS is allowed in an existing output directory because every staging // run rewrites it from scratch after copying the hosted installer assets. const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ @@ -237,6 +257,15 @@ function assertHostedInstallerSource(source, output) { `${output} default install version must be 'latest' for the hosted entrypoint`, ); } + + const forbidden = (HOSTED_INSTALLER_FORBIDDEN_PATTERNS[output] || []).filter( + ({ pattern }) => pattern.test(contents), + ); + if (forbidden.length > 0) { + fail( + `${output} must not contain: ${forbidden.map(({ name }) => name).join(', ')}`, + ); + } } async function writeHostedSha256Sums(outDir) { diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index b40a4cad930..94c224eda9c 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -694,7 +694,7 @@ if "!CHECKSUM_FILE!"=="" ( ) if not exist "!CHECKSUM_FILE!" ( - echo ERROR: SHA256SUMS not found; cannot verify archive. + echo ERROR: SHA256SUMS not found at !CHECKSUM_FILE!; cannot verify archive. exit /b 1 ) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index ccc7f05edb9..3dc8d5c6685 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -821,7 +821,7 @@ verify_checksum() { if [[ ! -f "${checksum_file}" ]]; then rm -f "${temp_checksum}" - log_error "SHA256SUMS not found; cannot verify archive." + log_error "SHA256SUMS not found at ${checksum_file}; cannot verify archive." return 1 fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index b85e9bc0e00..ca9eae30d48 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -94,7 +94,9 @@ describe('installation scripts', () => { expect(script).toContain('install_npm()'); expect(script).toContain('detect_target()'); expect(script).toContain('verify_checksum()'); - expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain( + 'SHA256SUMS not found at ${checksum_file}; cannot verify archive', + ); expect(script).toContain('awk -v archive_name'); expect(script).not.toContain( 'grep -E "(^|[[:space:]])[*]?${archive_name}$"', @@ -251,7 +253,9 @@ describe('installation scripts', () => { expect(script).toContain(':InstallStandalone'); expect(script).toContain(':InstallNpm'); expect(script).toContain(':VerifyChecksum'); - expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain( + 'SHA256SUMS not found at !CHECKSUM_FILE!; cannot verify archive', + ); expect(script).toContain('Get-FileHash -Algorithm SHA256'); expect(script).toContain('tokens=1,2'); expect(script).toContain('CHECKSUM_NAME'); @@ -1025,6 +1029,118 @@ describe('standalone release packaging', () => { } }); + it('rejects hosted ps1 shim with a hardcoded version pin', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; --version=*) VERSION="${1#*=}" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=%QWEN_INSTALL_VERSION%"\r\nif "%VERSION%"=="" set "VERSION=latest"\r\nset "VERSION=latest"\r\nif "%~1"=="--version" set "VERSION=%~2"\r\n', + ); + // The ps1 shim has every required behavior pattern but also contains + // a hardcoded $env:QWEN_INSTALL_VERSION assignment, which must be + // rejected by the forbidden-patterns guard. + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '# QWEN_INSTALL_VERSION documentation\n' + + '$env:QWEN_INSTALL_VERSION = "v0.1.0"\n' + + '$tmp = Get-FileHash $env:TEMP\n' + + '# SHA256SUMS\n' + + '& $qwenInstallerPath @args\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'is_qwen_standalone_install_dir() { return 0; }\n' + + 'remove_shell_path_entry() { :; }\n' + + 'QWEN_UNINSTALL_PURGE=""\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n' + + 'function Remove-UserPathEntry { }\n' + + 'function Remove-CurrentCmdPathShim { }\n' + + '$env:QWEN_UNINSTALL_PURGE = ""\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow( + /install-qwen-standalone\.ps1 must not contain.*no hardcoded QWEN_INSTALL_VERSION assignment/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('allows hosted ps1 shim that only documents QWEN_INSTALL_VERSION in comments', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; --version=*) VERSION="${1#*=}" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=%QWEN_INSTALL_VERSION%"\r\nif "%VERSION%"=="" set "VERSION=latest"\r\nset "VERSION=latest"\r\nif "%~1"=="--version" set "VERSION=%~2"\r\n', + ); + // ps1 contains the exact docstring shipped in production + // ("$env:QWEN_INSTALL_VERSION = 'vX.Y.Z'") as a `#` comment; the + // forbidden-pattern guard must not regress on that documented example. + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking,\n' + + "# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing\n" + + '# --version vX.Y.Z to install-qwen-standalone.bat directly.\n' + + '$tmp = Get-FileHash $env:TEMP\n' + + '# SHA256SUMS\n' + + '& $qwenInstallerPath @args\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'is_qwen_standalone_install_dir() { return 0; }\n' + + 'remove_shell_path_entry() { :; }\n' + + 'QWEN_UNINSTALL_PURGE=""\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n' + + 'function Remove-UserPathEntry { }\n' + + 'function Remove-CurrentCmdPathShim { }\n' + + '$env:QWEN_UNINSTALL_PURGE = ""\n', + ); + + // Build should succeed (only resolves; throws would fail the test). + await buildHostedInstallationAssets(tmpDir, { root: tmpRoot }); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects stale hosted installation assets in the output directory', async () => { const { buildHostedInstallationAssets } = await import( hostedInstallationScriptUrl From 89d58aa0f5c53b489c8d498bccaa01333e7c917d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 01:37:53 +0800 Subject: [PATCH 127/129] fix: stamp release version in hosted installers and add Zip Slip protection [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. The hosted installation asset build now accepts --version and stamps it into the copied .sh/.bat installers so they default to the tagged release version instead of 'latest'. The release workflow passes the version. 2. install-qwen-with-source.bat now validates archive entries before calling Expand-Archive, rejecting paths with '..', leading '/', drive-rooted paths, empty names, or control characters — matching the protection already present in install-qwen-standalone.bat and the .sh installer. --- .github/workflows/release.yml | 4 +- scripts/build-hosted-installation-assets.js | 45 ++++++++++++++++++- .../installation/install-qwen-with-source.bat | 25 +++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f2765dd8c..782c6afe92a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -390,7 +390,9 @@ jobs: run: 'npm run verify:installation-release -- --dir dist/standalone' - name: 'Package Hosted Installation Assets' - run: 'npm run package:hosted-installation -- --out-dir dist/installation' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: 'npm run package:hosted-installation -- --out-dir dist/installation --version "${RELEASE_VERSION}"' - name: 'Install ossutil' if: |- diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index e4fbfb68073..f8ab7a5def0 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -161,6 +161,7 @@ const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ const ARG_DEFS = { '--out-dir': { key: 'outDir', type: 'value' }, + '--version': { key: 'version', type: 'value' }, }; if (isMainModule(import.meta.url)) { @@ -182,7 +183,7 @@ async function main() { const outDir = path.resolve( args.outDir || path.join(rootDir, 'dist', 'installation'), ); - await buildHostedInstallationAssets(outDir); + await buildHostedInstallationAssets(outDir, { version: args.version }); } function printUsage() { @@ -192,12 +193,16 @@ Stages hosted installer entrypoint assets for CDN/OSS upload. Options: --out-dir PATH Output directory. Defaults to dist/installation. + --version VERSION Stamp the release version into copied installers so + they default to installing that version instead of + 'latest'. Should match the release tag (e.g. v1.2.3). -h, --help Show this help message. `); } async function buildHostedInstallationAssets(outDir, options = {}) { const root = options.root || rootDir; + const version = options.version || undefined; fs.mkdirSync(outDir, { recursive: true }); assertNoUnexpectedHostedFiles(outDir); @@ -210,6 +215,9 @@ async function buildHostedInstallationAssets(outDir, options = {}) { const destination = path.join(outDir, asset.output); copyHostedInstallationAsset(source, destination, asset); + if (version) { + stampVersionInAsset(destination, asset.output, version); + } if (asset.mode !== undefined) { fs.chmodSync(destination, asset.mode); } @@ -240,6 +248,41 @@ function copyHostedInstallationAsset(source, destination, asset) { fs.copyFileSync(source, destination); } +/** + * Replaces the default 'latest' version in a copied installer asset with the + * given release version so the hosted installer installs the tagged version. + * + * @param {string} filePath - Path to the copied asset on disk. + * @param {string} assetName - Logical asset name (e.g. 'install-qwen-standalone.sh'). + * @param {string} version - Release version to stamp (e.g. 'v1.2.3' or '1.2.3'). + */ +function stampVersionInAsset(filePath, assetName, version) { + const replacements = { + 'install-qwen-standalone.sh': { + from: 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + to: `VERSION="\${QWEN_INSTALL_VERSION:-${version}}"`, + }, + 'install-qwen-standalone.bat': { + from: 'set "VERSION=latest"', + to: `set "VERSION=${version}"`, + }, + }; + + const replacement = replacements[assetName]; + if (!replacement) { + return; + } + + let contents = fs.readFileSync(filePath, 'utf8'); + if (!contents.includes(replacement.from)) { + fail( + `Cannot stamp version in ${assetName}: expected default version pattern not found`, + ); + } + contents = contents.replace(replacement.from, replacement.to); + fs.writeFileSync(filePath, contents); +} + function assertHostedInstallerSource(source, output) { const contents = fs.readFileSync(source, 'utf8'); const missing = (HOSTED_INSTALLER_BEHAVIOR_PATTERNS[output] || []) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c8ce8cf067a..de8132f5a50 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -509,6 +509,11 @@ if !ERRORLEVEL! NEQ 0 ( REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" @@ -620,6 +625,26 @@ echo SUCCESS: Qwen Code standalone archive installed successfully. echo INFO: Installed to !INSTALL_DIR! exit /b 0 +:ValidateArchiveContents +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() } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% EQU 0 exit /b 0 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" From cb3f356a3a1385a8ae85376d84e496781025403d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 01:54:49 +0800 Subject: [PATCH 128/129] fix(installer): add SOURCE to PowerShell unsafe-character validation [skip ci] The SOURCE variable is user-provided and used in path operations but was not included in the :ValidateOptions unsafe-character check. Add it alongside the other validated variables. --- scripts/installation/install-qwen-with-source.bat | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index de8132f5a50..246ffc50492 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -221,7 +221,8 @@ set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_VALIDATE_METHOD=" set "QWEN_VALIDATE_MIRROR=" @@ -232,6 +233,7 @@ set "QWEN_VALIDATE_NPM_REGISTRY=" set "QWEN_VALIDATE_INSTALL_BASE=" set "QWEN_VALIDATE_INSTALL_DIR=" set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" if %PS_STATUS% NEQ 0 ( echo ERROR: installer options contain unsafe command characters. exit /b 1 From 18525b3c7c9c2da82f798cbf2d0590eb27043108 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 02:00:48 +0800 Subject: [PATCH 129/129] fix: correct copyright year 2025 -> 2026 in new files [skip ci] --- scripts/build-hosted-installation-assets.js | 2 +- scripts/release-script-utils.js | 2 +- scripts/tests/install-script.test.js | 4 ++-- scripts/tests/upload-aliyun-oss-assets.test.js | 2 +- scripts/upload-aliyun-oss-assets.js | 2 +- scripts/verify-installation-release.js | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js index f8ab7a5def0..732e2f55488 100644 --- a/scripts/build-hosted-installation-assets.js +++ b/scripts/build-hosted-installation-assets.js @@ -2,7 +2,7 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js index 9580ab3f0ff..a574915dbae 100644 --- a/scripts/release-script-utils.js +++ b/scripts/release-script-utils.js @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index ca9eae30d48..10ba907b6bd 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -616,7 +616,7 @@ describe('standalone release packaging', () => { const hostedInstallScript = readScript( 'scripts/build-hosted-installation-assets.js', ); - expect(hostedInstallScript).toContain('Copyright 2025 Qwen Team'); + expect(hostedInstallScript).toContain('Copyright 2026 Qwen Team'); expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); expect(hostedInstallScript).toContain( @@ -633,7 +633,7 @@ describe('standalone release packaging', () => { const releaseVerifyScript = readScript( 'scripts/verify-installation-release.js', ); - expect(releaseVerifyScript).toContain('Copyright 2025 Qwen Team'); + expect(releaseVerifyScript).toContain('Copyright 2026 Qwen Team'); expect(releaseVerifyScript).toContain('verifyReleaseDirectory'); expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js index 00b54cab7f0..0705b421ef5 100644 --- a/scripts/tests/upload-aliyun-oss-assets.test.js +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js index 3d485133c22..7013c677ec3 100644 --- a/scripts/upload-aliyun-oss-assets.js +++ b/scripts/upload-aliyun-oss-assets.js @@ -2,7 +2,7 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index b5121bc518c..283001959db 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -2,7 +2,7 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */