diff --git a/.gitattributes b/.gitattributes index deab5ae88b..3c21e99713 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/.github/workflows/release.yml b/.github/workflows/release.yml index 59e7dac836..782c6afe92 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' @@ -386,6 +386,63 @@ 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: 'Package Hosted Installation Assets' + 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: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + 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 + + tmp_dir="$(mktemp -d)" + 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}" + + 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}" + mkdir -p "${HOME}/.local/bin" + install -m 0755 "${ossutil_path}" "${HOME}/.local/bin/ossutil" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + rm -rf "${tmp_dir}" + "${HOME}/.local/bin/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: |- @@ -411,21 +468,139 @@ 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-* \ - dist/standalone/SHA256SUMS \ + "${release_assets[@]}" \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "${PREVIOUS_RELEASE_TAG}" \ --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' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + 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: |- + ${{ 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' }}" + 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}" + + - 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 + + 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: |- + ${{ 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 + 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}" + 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}/versioned/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + 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}/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' }} + 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/package.json b/package.json index e0106e61ff..349e6aa5cb 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,10 @@ "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: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/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js new file mode 100644 index 0000000000..732e2f5548 --- /dev/null +++ b/scripts/build-hosted-installation-assets.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +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); +const rootDir = path.resolve(__dirname, '..'); + +const HOSTED_INSTALLATION_ASSETS = [ + { + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.sh'], + output: 'install-qwen-standalone.sh', + mode: 0o755, + }, + { + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.bat'], + output: 'install-qwen-standalone.bat', + lineEndings: 'crlf', + }, + { + 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, +); +/** 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': [ + { + 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: 'SHA256SUMS verification', + pattern: /SHA256SUMS/, + }, + { + name: 'bat checksum verification', + pattern: /Get-FileHash/, + }, + { + name: 'QWEN_INSTALL_VERSION documentation', + 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 +// 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-standalone.sh': + /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([ + ...HOSTED_INSTALLATION_ASSET_NAMES, + 'SHA256SUMS', +]); + +const ARG_DEFS = { + '--out-dir': { key: 'outDir', type: 'value' }, + '--version': { key: 'version', type: 'value' }, +}; + +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 = parseArgs(process.argv.slice(2), ARG_DEFS); + if (args.help) { + printUsage(); + return; + } + + const outDir = path.resolve( + args.outDir || path.join(rootDir, 'dist', 'installation'), + ); + await buildHostedInstallationAssets(outDir, { version: args.version }); +} + +function printUsage() { + console.log(`Usage: npm run package:hosted-installation -- [options] + +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); + + for (const asset of HOSTED_INSTALLATION_ASSETS) { + const source = path.join(root, ...asset.sourcePath); + if (!fs.existsSync(source)) { + fail(`Hosted installer source asset not found: ${source}`); + } + assertHostedInstallerSource(source, asset.output); + + 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); + } + } + + await writeHostedSha256Sums(outDir); + 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 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); +} + +/** + * 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] || []) + .filter(({ pattern }) => !pattern.test(contents)) + .map(({ name }) => name); + if (missing.length > 0) { + fail( + `${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`, + ); + } + + 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) { + const lines = []; + 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}`); + } + fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +async function assertHostedInstallationAssetChecksums(outDir) { + const checksumsPath = path.join(outDir, 'SHA256SUMS'); + const checksums = parseSha256Sums(fs.readFileSync(checksumsPath, 'utf8')); + + for (const { output } of HOSTED_INSTALLATION_ASSETS) { + const expected = checksums.get(output); + if (!expected) { + fail(`Missing checksum entry for ${output}`); + } + + const actual = await sha256File(path.join(outDir, output)); + if (actual !== expected) { + fail( + `Checksum mismatch for ${output}: expected ${expected}, got ${actual}`, + ); + } + } +} + +export { + HOSTED_INSTALLATION_ASSETS, + HOSTED_INSTALLATION_ASSET_NAMES, + assertHostedInstallationAssetChecksums, + buildHostedInstallationAssets, +}; diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 21a2b52f2b..e2d863e328 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -10,20 +10,23 @@ 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. 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. ## Installation Scripts -- Linux/macOS: `install-qwen-with-source.sh` -- Windows: `install-qwen-with-source.bat` +- 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 @@ -36,6 +39,70 @@ GitHub releases publish these standalone archives: - `qwen-code-win-x64.zip` - `SHA256SUMS` +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. + +Public installation documentation intentionally continues to use the existing +production installer in this PR. Update README and other public quick-install +instructions in a follow-up after the standalone-suffixed hosted installers and +release archive sync have been validated in production. + +Hosted installer assets are staged separately from GitHub Release archives: + +- `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint. +- `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. + +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 +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. 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, 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 +standalone release directly. + +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: ```text @@ -62,13 +129,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-standalone.sh --method standalone +bash install-qwen-standalone.sh --method npm ``` ```bat -install-qwen-with-source.bat --method standalone -install-qwen-with-source.bat --method npm +install-qwen-standalone.bat --method standalone +install-qwen-standalone.bat --method npm ``` ## Optional Native Modules @@ -86,20 +153,20 @@ modules for the current machine. ```bash # Default: standalone archive with npm fallback -bash install-qwen-with-source.sh +bash install-qwen-standalone.sh # Record a source value -bash install-qwen-with-source.sh --source github +bash install-qwen-standalone.sh --source github # Use npm explicitly -bash install-qwen-with-source.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-with-source.sh --mirror aliyun +bash install-qwen-standalone.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-standalone.sh --archive ./qwen-code-linux-x64.tar.gz ``` Standalone installs to: @@ -110,24 +177,35 @@ 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 REM Default: standalone archive with npm fallback -install-qwen-with-source.bat +install-qwen-standalone.bat REM Record a source value -install-qwen-with-source.bat --source github +install-qwen-standalone.bat --source github REM Use npm explicitly -install-qwen-with-source.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-with-source.bat --mirror aliyun +install-qwen-standalone.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-standalone.bat --archive qwen-code-win-x64.zip ``` Standalone installs to: @@ -140,6 +218,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: @@ -165,9 +255,12 @@ 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 `v0.16.0/`, 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 `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 @@ -199,7 +292,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 @@ -220,7 +313,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-standalone.bat b/scripts/installation/install-qwen-standalone.bat new file mode 100644 index 0000000000..94c224eda9 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.bat @@ -0,0 +1,1323 @@ +@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 "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=" + +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 at !CHECKSUM_FILE!; 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/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 new file mode 100644 index 0000000000..430547a444 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -0,0 +1,415 @@ +# Qwen Code Windows hosted PowerShell entrypoint. +# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP, +# verifies its checksum, and runs it with forwarded arguments. +# +# 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 +# --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. +# +# 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' + +function Download-File { + param([string]$Url, [string]$OutFile) + $prevProgressPreference = $global:ProgressPreference + $global:ProgressPreference = 'SilentlyContinue' + try { + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + 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)" + } + return + } + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300 + } finally { + $global:ProgressPreference = $prevProgressPreference + } +} + +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 + } + + 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 { + 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 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 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) + + $pathEntries = @($PathValue -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $candidates = [System.Collections.Generic.List[string]]::new() + $preferredDirectories = @() + + 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) { + if (Test-SystemManagedPathDirectory -Directory $entry) { + continue + } + $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" + # 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 + } + + return $null +} + +function Update-CurrentShell { + $qwenInstallBinDir = Get-QwenInstallBinDir + $qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd' + if (-not (Test-Path -LiteralPath $qwenCommandPath -PathType Leaf)) { + 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 + + 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' +$qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' +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 +} + +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' +} +if ([string]::IsNullOrEmpty($env:TEMP)) { + Write-Error "TEMP environment variable is not set. Please set TEMP to a writable directory." + exit 1 +} +# 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 +} catch { + Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" + exit 1 +} + +try { + 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)" + 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 mismatch for ${qwenInstallerName}: expected ${qwenExpectedHash}, got ${qwenActualHash}." + exit 1 +} + +$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 +} + +if ($qwenInstallerExitCode -ne 0) { + exit $qwenInstallerExitCode +} + +Update-CurrentShell diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh new file mode 100755 index 0000000000..3dc8d5c668 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.sh @@ -0,0 +1,1467 @@ +#!/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-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 + 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() { + 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 +} + +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")" +} + +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 + +print_usage() { + cat </dev/null || echo unknown)" in + Darwin) + echo " brew install node" + echo " # or download from https://nodejs.org/" + ;; + Linux) + echo " # Use your distribution package manager or:" + echo " https://nodejs.org/en/download/package-manager" + ;; + *) + echo " https://nodejs.org/" + ;; + esac + echo "" + echo "If you already use a Node version manager, activate Node.js 22+" + echo "in this shell before rerunning the installer." +} + +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 +} + +get_npm_global_root() { + npm root -g 2>/dev/null || true +} + +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" ;; + *) + 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 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 + if [[ "${rc_file}" == *config.fish ]]; then + export_line="set -gx PATH ${quoted_install_bin_dir} \$PATH" + else + export_line="export PATH=${quoted_install_bin_dir}:\$PATH" + fi + + if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then + log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." + return 0 + fi + + mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true + { + echo "" + echo "${begin_marker}" + echo "${export_line}" + echo "${end_marker}" + } >> "${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}" +} + +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 --connect-timeout 10 --max-time 30 "${url}" + return $? + fi + + if command_exists wget; then + 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 + + 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}" +} + +# 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}" + local gh_url="$2" + local oss_url="$3" + local tmpdir + 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") & + local oss_pid=$! + (probe_url_available "${gh_url}" "${timeout}" && : > "${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:-timeout}" +} + +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" + 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 + selected="github" + else + log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 + fi + MIRROR="${selected}" + 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 + + github_base_url_for_version "${version_path}" +} + +download_file() { + local url="$1" + local destination="$2" + + if command_exists curl; then + 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 "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + else + wget "${wget_args[@]}" "${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" + + probe_url_available "${url}" 30 +} + +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 at ${checksum_file}; 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 mismatch for ${archive_name}: expected ${expected}, got ${actual}." + return 1 + fi + + log_success "Checksum verified for ${archive_name}." +} + +validate_archive_entry_path() { + local entry="$1" + entry="${entry//\\//}" + + 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 + + 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 +} + +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" + + [[ -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}") + + if ! cat > "${wrapper_path}" </dev/null || echo "unknown") + log_info "Existing Qwen Code detected: ${qwen_version}" + if [[ "${VERSION}" == "latest" ]]; then + log_info "Upgrading to the latest version." + else + log_info "Installing requested version ${VERSION}." + fi + fi + + local install_cmd=( + npm + install + -g + "${package_spec}" + --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 + return 0 + fi + + log_error "Failed to install Qwen Code." + echo "" + echo "This installer does not change your npm prefix or shell profile." + echo "If the failure is a permission error, install Node.js with a user-owned" + echo "Node version manager or fix your npm global package directory, then run:" + echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}" + return 1 +} + +print_final_instructions() { + local install_bin_dir="${1:-}" + local install_dir="${2:-}" + local install_method="${3:-standalone}" + local installed_bin="" + local quoted_install_bin_dir="" + local standalone_uninstall_url="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh" + if [[ -n "${install_bin_dir}" ]]; then + installed_bin="${install_bin_dir}/qwen" + quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") + 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 "" + + 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 + + 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 "${install_bin_dir}" ]]; then + echo " curl -fsSL ${standalone_uninstall_url} | QWEN_INSTALL_LIB_DIR=$(shell_quote "${install_dir}") QWEN_INSTALL_BIN_DIR=$(shell_quote "${install_bin_dir}") bash" + else + echo " curl -fsSL ${standalone_uninstall_url} | bash" + fi + + if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then + maybe_update_shell_path "${install_bin_dir}" + fi + + if [[ -n "${other_qwens}" ]]; then + echo "" + log_warning "Other 'qwen' executables exist on this system. Depending on your" + log_warning "shell PATH order, one of these may run instead of the install above:" + local saved_ifs="${IFS}" + IFS=$'\n' + local path + for path in ${other_qwens}; do + [[ -z "${path}" ]] && continue + log_warning " ${path}" + done + IFS="${saved_ifs}" + echo "" + echo "To make this install take priority, restart your terminal." + echo "Or invoke directly: ${installed_bin}" + return 0 + fi + + echo "(Open a new terminal for the PATH change to take effect.)" +} + +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}" "${INSTALL_LIB_DIR}" "standalone" + ;; + npm) + install_npm + print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" + ;; + detect) + # Try the standalone archive first; fall back only when unavailable. + if install_standalone; then + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" + else + 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)" "$(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." + 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/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c8ce8cf067..246ffc5049 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 @@ -509,6 +511,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 +627,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 }" diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 new file mode 100644 index 0000000000..cee6854bef --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -0,0 +1,372 @@ +# 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' (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" +} + +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-CurrentCmdShimStatePath { + return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt' +} + +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-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)) { + 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' + Remove-CurrentCmdPathShimFile -ShimPath $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-CurrentCmdPathShim + 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-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 0000000000..b59230758e --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -0,0 +1,312 @@ +#!/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 +} + +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" + + 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 + + # 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 + + # 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}" +} + +remove_shell_path_entry() { + local begin_marker="# Qwen Code PATH block begin" + local end_marker="# Qwen Code PATH block end" + local legacy_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 "${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") || { + log_warning "Could not create temp file for ${rc_file}; leaving PATH entry unchanged." + continue + } + + 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=/ || + $0 ~ /^[[:space:]]*set -gx PATH /) { + next + } + } + { 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}." + 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/release-script-utils.js b/scripts/release-script-utils.js new file mode 100644 index 0000000000..a574915dba --- /dev/null +++ b/scripts/release-script-utils.js @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 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}`); + } + if (checksums.has(match[2])) { + fail(`Duplicate SHA256SUMS entry for: ${match[2]}`); + } + 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); + if (key === '--help' || key === '-h') { + fail(`${key} does not accept a value`); + } + const def = definitions[key]; + if (!def) { + fail(`Unknown option: ${key}`); + } + if (def.type === 'flag') { + fail(`${key} does not accept a value`); + } + if (!value || value.startsWith('-')) { + fail(`${key} requires 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 a83a54292d..763fe61cc6 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -13,7 +13,9 @@ const { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -27,15 +29,26 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.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 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; +vi.setConfig({ testTimeout: 30_000 }); + describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', ); expect(script).not.toContain('install_nvm'); @@ -48,21 +61,29 @@ 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', ); - expect(script).toContain('Node.js 20 or newer is required'); + 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('Installing Qwen Code version:'); + expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', + 'Qwen Code ${installed_version} installed successfully.', ); - expect(script).toContain('You can now run: qwen'); + 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', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', ); expect(script).toContain('--method METHOD'); @@ -73,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}$"', @@ -87,6 +110,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', ); @@ -109,20 +138,71 @@ 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 --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}"', + ); + expect(script).toContain('wget_args+=(--read-timeout=300)'); + expect(script).toContain( + 'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"', + ); + expect(script).toContain('wget_args+=(--read-timeout=30)'); + expect(script).toContain('echo "Downloading ${archive_name}"'); + expect(script).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( + '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('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'); + 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'); }); it('keeps the Windows installer lightweight', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', + 'scripts/installation/install-qwen-standalone.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).toContain('Invoke-WebRequest'); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo', + ); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -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); @@ -132,17 +212,38 @@ 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(':NpmPackageSpec'); + expect(script).toContain('@qwen-code/qwen-code@latest'); + expect(script).toContain('Installing Qwen Code version:'); + expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', + 'Qwen Code !INSTALLED_VERSION! installed successfully.', ); - expect(script).toContain('You can now run: qwen'); + expect(script).toContain('To start:'); + expect(script).toContain('Installed to:'); + expect(script).toContain('Uninstall:'); + expect(script).toContain('uninstall-qwen-standalone.ps1'); + 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('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); + expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); it('supports code-server-style standalone install on Windows', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', + 'scripts/installation/install-qwen-standalone.bat', ); expect(script).toContain('--method METHOD'); @@ -152,14 +253,20 @@ 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'); 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"', + ); + 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'); @@ -171,7 +278,12 @@ describe('installation scripts', () => { expect(script).toContain( 'installer options contain unsafe command characters', ); - expect(script).toContain('[char[]](10,13,33,34'); + 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!"==":/"'); @@ -179,21 +291,267 @@ describe('installation scripts', () => { expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); - expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); + 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.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + '--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( + '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', + ); + 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!"'); 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'); + 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'); + expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); + 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).toContain('endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%"'); + 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( + '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'); + // 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%'); + }); + + 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', + ); + + 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('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 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', + ); + expect(readScript(fakeCurl)).toContain('set "destination=%~2"'); + expect(readScript(fakeCurl)).not.toContain('set "destination=%~1"'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + 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', + ); + + 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', () => { + 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('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 = { + '--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='], defs)).toThrow( + /--out-dir requires a value/, + ); + 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 () => { + 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); + expect(() => parseArgs(['--help=anything'], {})).toThrow( + /--help does not accept a value/, + ); + }); + + it('fail() wraps messages with ERROR: prefix', async () => { + const { fail } = await import(releaseScriptUtilsUrl); + expect(() => fail('something went wrong')).toThrow( + 'ERROR: something went wrong', + ); }); }); @@ -207,8 +565,20 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:standalone:release']).toBe( 'node scripts/build-standalone-release.js', ); + 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', + ); + 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-hosted-installation-assets.js')).toBe( + true, + ); + expect(existsSync('scripts/verify-installation-release.js')).toBe(true); + expect(existsSync('scripts/build-installation-assets.js')).toBe(false); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -242,6 +612,39 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); + + const hostedInstallScript = readScript( + 'scripts/build-hosted-installation-assets.js', + ); + expect(hostedInstallScript).toContain('Copyright 2026 Qwen Team'); + expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); + expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); + 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( + 'scripts/verify-installation-release.js', + ); + expect(releaseVerifyScript).toContain('Copyright 2026 Qwen Team'); + expect(releaseVerifyScript).toContain('verifyReleaseDirectory'); + 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'); }); it('loads the standalone release packaging helper', () => { @@ -255,21 +658,92 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); + it('loads the hosted installation release helpers', () => { + const hostedOutput = execFileSync( + process.execPath, + ['scripts/build-hosted-installation-assets.js', '--help'], + { encoding: 'utf8' }, + ); + const verifierOutput = execFileSync( + process.execPath, + ['scripts/verify-installation-release.js', '--help'], + { encoding: 'utf8' }, + ); + + 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', () => { + 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/, + ); + 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 () => { 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', + '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 () => { @@ -297,6 +771,686 @@ describe('standalone release packaging', () => { } }); + it('installer scripts honor --version for hosted entrypoints', () => { + const installShellSource = readScript( + 'scripts/installation/install-qwen-standalone.sh', + ); + expect(installShellSource).toContain( + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + ); + expect(installShellSource).toContain('--version)'); + expect(installShellSource).toContain('--version requires a value'); + + const installBatchSource = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + expect(installBatchSource).toContain('set "VERSION=latest"'); + expect(installBatchSource).toContain( + 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', + ); + expect(installBatchSource).toContain('!ARG_KEY!"=="--version"'); + expect(installBatchSource).toContain('--version requires a value'); + + const installPowerShellSource = readScript( + 'scripts/installation/install-qwen-standalone.ps1', + ); + expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); + expect(installPowerShellSource).toContain('Invoke-WebRequest'); + expect(installPowerShellSource).toContain('Download-File'); + expect(installPowerShellSource).toContain( + 'curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo', + ); + expect(installPowerShellSource).toContain('-TimeoutSec 300'); + expect(installPowerShellSource).toContain( + "$global:ProgressPreference = 'SilentlyContinue'", + ); + 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 mismatch'); + 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('Save-CurrentCmdPathShim'); + 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.', + ); + 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, + HOSTED_INSTALLATION_ASSETS, + assertHostedInstallationAssetChecksums, + buildHostedInstallationAssets, + } = await import(hostedInstallationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + + try { + await buildHostedInstallationAssets(tmpDir); + + 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'); + + expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ + '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, + ); + expect(readScript(installSh)).toBe( + readScript('scripts/installation/install-qwen-standalone.sh'), + ); + expect(readScript(installBat)).toBe( + readScript('scripts/installation/install-qwen-standalone.bat').replace( + /\r?\n/g, + '\r\n', + ), + ); + 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, + ); + 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, + ); + 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'); + await expect( + assertHostedInstallationAssetChecksums(tmpDir), + ).rejects.toThrow(/Checksum mismatch for install-qwen-standalone\.sh/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects hosted installer sources without pinned hosted 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-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-standalone.bat'), + '@echo off\r\nset "VERSION=latest"\r\n', + ); + writeFileSync( + 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 }), + ).rejects.toThrow( + /install-qwen-standalone\.sh default install version must be 'latest'/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + 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', + ); + 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 }), + ).rejects.toThrow(/install-qwen-standalone\.sh.*--version parser/); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + 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 + ); + 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('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(); + + appendFileSync( + path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]), + 'tamper', + ); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + new RegExp( + `Checksum mismatch for ${escapeRegExp(EXPECTED_STANDALONE_ARCHIVE_NAMES[0])}`, + ), + ); + } 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('verifies release asset URLs from SHA256SUMS', 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 = {}) => { + fetchedUrls.push([url, options.method || 'GET', !!options.signal]); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + + 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}`, + '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 mismatch for qwen-code-/); + }); + + it('rejects a release base URL that is not https', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( + /--base-url must use https/, + ); + await expect( + verifyReleaseBaseUrl('http://example.com/release/'), + ).rejects.toThrow(/--base-url must use https/); + }); + + it('downloads release archive bodies instead of relying on HEAD probes', 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); + } + if (method === 'HEAD') { + return new Response(null, { status: 405 }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).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, 'GET', '']); + expect(fetchedUrls).not.toContainEqual([assetUrl, 'HEAD', '']); + } + }); + + 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 }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow( + new RegExp( + `Unavailable or invalid release asset\\(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); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + }); + + 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-')); @@ -327,9 +1481,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); } }); @@ -372,9 +1524,7 @@ describe('standalone release packaging', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 30_000); @@ -401,9 +1551,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); } }); @@ -433,9 +1581,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); } }); @@ -465,9 +1611,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); } }); @@ -497,22 +1641,153 @@ 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); } }); - 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('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); - expect(workflow).toContain('dist/standalone/qwen-code-*'); - 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', + ); + 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('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'", + ); + const verifyStepIndex = workflow.indexOf( + "name: 'Verify Aliyun OSS Release Assets'", + ); + 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(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', + ); + expect(workflow.slice(publishLatestStepIndex)).toContain( + 'releases/qwen-code/latest/VERSION', + ); + const syncStep = workflow.slice(syncStepIndex, verifyStepIndex); + 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('--prefix "installation/${RELEASE_TAG}"'); + expect(syncHostedStep).toContain('--prefix "installation"'); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.sh', + ); + 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}"', + ); + 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 --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"', + ); + 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)"'); + 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( + '(cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS)', + ); + expect(workflow).toContain( + '(cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS)', + ); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -526,9 +1801,58 @@ describe('standalone release packaging', () => { const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); expect(guide).toContain('Optional Native Modules'); + expect(guide).toContain('package:hosted-installation'); + 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'); + expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); + expect(guide).toContain('Public installation documentation'); 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('shell_quote'); + expect(uninstallShellSource).toContain('quoted_qwen_bin'); + 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( + 'Remove-RecordedCurrentCmdPathShim', + ); + expect(uninstallPowerShellSource).toContain('current-cmd-shim.txt'); + expect(uninstallPowerShellSource).toContain( + 'Qwen Code current-session shim', + ); + 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\)/, + ); + }); }); // These end-to-end installs spawn child processes via execFileSync; @@ -545,7 +1869,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { 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( @@ -563,11 +1887,421 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { .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( + '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) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); + } + }, + ); + + 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 qwen-code-linux-x64.tar.gz'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 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 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, + ); + + 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', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + '# Qwen Code PATH block end', + '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 }); + restoreMinimalDist(createdDist); + } + }); + + 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 }); + 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); } }, ); @@ -603,12 +2337,239 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { 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); } }); + 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 }); + 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, + ); + + 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 }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + itOnUnix('rejects a tampered local archive', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -623,12 +2584,10 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -649,9 +2608,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -694,7 +2651,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); - 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-')); @@ -705,17 +2662,29 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { 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) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -739,14 +2708,12 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { 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 { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -772,8 +2739,8 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { '#!/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', @@ -805,7 +2772,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { const output = execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -835,6 +2802,80 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); + 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-')); @@ -849,7 +2890,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -934,7 +2975,7 @@ describe('Windows installer end-to-end', { timeout: 30000 }, () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -963,25 +3004,290 @@ describe('Windows installer end-to-end', { timeout: 30000 }, () => { rmSync(tmpDir, { recursive: true, force: true }); } }); + + 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'); + + const fakeCurl = createFakeWindowsCurlCommand(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, + QWEN_INSTALL_CURL_EXE: fakeCurl, + ...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/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', + ); + expect(output).toContain('Downloading 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', + () => { + 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'), + ...prependWindowsPath(fakeBin), + 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('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-')); + + 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'), + ...prependWindowsPath(fakeBin), + 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 }); + } + }); +}); + +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() { - if (existsSync('dist')) { - return false; + const distPath = path.resolve('dist'); + const backupPath = existsSync(distPath) + ? path.join( + path.dirname(distPath), + `qwen-dist-backup-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ) + : null; + if (backupPath) { + renameSync(distPath, backupPath); } - mkdirSync('dist/vendor', { recursive: true }); - mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); - writeFileSync('dist/cli.js', 'console.log("qwen");\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 true; + return { backupPath, distPath }; +} + +function restoreMinimalDist(state) { + rmSync(state?.distPath || path.resolve('dist'), { + recursive: true, + force: true, + }); + if (state?.backupPath) { + renameSync(state.backupPath, state.distPath); + } } 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'), @@ -1005,7 +3311,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)], @@ -1041,11 +3347,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; } @@ -1064,7 +3370,7 @@ function createFakeWindowsStandaloneArchive(tmpDir) { 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({ name: '@qwen-code/qwen-code', target: 'win-x64' }), ); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); @@ -1073,6 +3379,127 @@ 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( + 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 createFakeWindowsCurlCommand(fakeBin) { + mkdirSync(fakeBin, { recursive: true }); + const outputPath = path.join(fakeBin, 'curl.cmd'); + writeFileSync( + outputPath, + [ + '@echo off', + 'setlocal EnableExtensions EnableDelayedExpansion', + 'set "destination="', + 'set "url="', + ':parse_args', + 'if "%~1"=="" goto done_parse', + 'set "arg=%~1"', + 'if "!arg:~0,1!"=="-" (', + ' if /i "!arg!"=="-o" (', + ' set "destination=%~2"', + ' shift', + ' shift', + ' goto parse_args', + ' )', + ' if /i "!arg!"=="--output" (', + ' set "destination=%~2"', + ' shift', + ' shift', + ' goto parse_args', + ' )', + ' if not "!arg:~0,2!"=="--" if /i "!arg:~-1!"=="o" (', + ' set "destination=%~2"', + ' shift', + ' 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 0.0.0', + ' exit /b 0', + ')', + '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/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) { + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || + 'Path'; + const value = `${directory};${process.env[pathKey] || ''}`; + return { + PATH: value, + Path: value, + [pathKey]: value, + }; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( @@ -1163,7 +3590,7 @@ function runUnixInstaller( return execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', method, '--archive', @@ -1193,6 +3620,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, @@ -1204,7 +3659,7 @@ function runWindowsInstaller( try { return runWindowsCommand( [ - `call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, '--method', method, '--archive', @@ -1231,15 +3686,90 @@ function runWindowsInstaller( } function runWindowsCommand(command, env = {}) { - return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], { - env: { - ...process.env, - ...env, - }, - stdio: 'pipe', - // cmd.exe parses the command string itself; preserve quoted paths. - windowsVerbatimArguments: true, - }); + const prepared = prepareWindowsCommand(command, 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, + }, + ); + } 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 = [ + '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) { @@ -1254,7 +3784,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'); @@ -1289,7 +3819,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'); @@ -1312,3 +3842,38 @@ function writeChecksumFile(outDir, archiveName) { .digest('hex'); writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); } + +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); + 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 placeholderChecksumContent(archiveNames) { + return `${archiveNames + .map( + (assetName) => + `${crypto + .createHash('sha256') + .update(`${assetName}\n`) + .digest('hex')} ${assetName}`, + ) + .join('\n')}\n`; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} 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 0000000000..0705b421ef --- /dev/null +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * 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, uploadAssets } 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(); + }); +}); + +describe('uploadAssets (integration)', () => { + function makeOssutilShim(workDir, behavior = 'success') { + fs.mkdirSync(workDir, { recursive: true }); + const ossutilPath = path.join(workDir, 'ossutil-shim.cjs'); + const logPath = path.join(workDir, 'ossutil.log'); + fs.writeFileSync( + ossutilPath, + [ + "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'), + ); + 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, ossutilCommand, ossutilCommandArgs } = + 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'); + + uploadAssets( + { + assets, + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }, + { ossutilCommand, ossutilCommandArgs }, + ); + + 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 { + 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'); + + expect(() => + uploadAssets( + { + assets: [assetPath], + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }, + { 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 }); + } + }, 30_000); +}); diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js new file mode 100644 index 0000000000..7013c677ec --- /dev/null +++ b/scripts/upload-aliyun-oss-assets.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 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; +} + +const MAX_UPLOAD_ATTEMPTS = 3; +const INITIAL_BACKOFF_MS = 2000; + +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, { + ossutilCommand, + ossutilCommandArgs, + }); + } +} + +function uploadWithRetry( + asset, + bucket, + key, + config, + { ossutilCommand, ossutilCommandArgs }, +) { + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { + const result = spawnSync( + ossutilCommand, + [ + ...ossutilCommandArgs, + 'cp', + asset, + `oss://${bucket}/${key}`, + '-c', + config, + '-f', + '--acl', + 'public-read', + ], + { stdio: 'inherit' }, + ); + + if (result.error) { + throw result.error; + } + 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...`, + ); + sleepSync(delayMs); + } + } + fail( + `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); +} + +export { parseUploadArgs, uploadAssets }; diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js new file mode 100644 index 0000000000..283001959d --- /dev/null +++ b/scripts/verify-installation-release.js @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * 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 { + fail, + isMainModule, + parseArgs, + 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 = + 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 +// `package:hosted-installation`. +const EXPECTED_RELEASE_ASSET_NAMES = [ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'SHA256SUMS', +]; +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 === 'win-x64' ? 'zip' : 'tar.gz'}`, + ); +} + +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)) { + 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), ARG_DEFS); + if (args.help) { + printUsage(); + return; + } + 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; + } + 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 contains the expected standalone +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, options = {}) { + const { silent = false } = options; + const checksums = readReleaseChecksums(dir); + assertExpectedChecksumEntries(checksums); + assertExpectedArchiveFiles(dir); + + 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); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } + } + + if (!silent) { + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`, + ); + } +} + +async function verifyReleaseBaseUrl(baseUrl, options = {}) { + const { fetchImpl = fetch } = options; + const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl); + const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); + const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); + assertExpectedChecksumEntries(checksums); + + await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl); + + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets 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) => !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(', ')}`); + } +} + +function assertExpectedArchiveFiles(dir) { + const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES); + const extra = fs + .readdirSync(dir) + .filter((assetName) => !expected.has(assetName)) + .sort(); + + if (extra.length > 0) { + fail(`Unexpected release asset: ${extra.join(', ')}`); + } +} + +function releaseAssetPaths(dir) { + return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => + path.join(dir, assetName), + ); +} + +async function assertRemoteAssetChecksums( + normalizedBaseUrl, + checksums, + fetchImpl, +) { + const failures = []; + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + try { + const assetUrl = new URL(assetName, normalizedBaseUrl).toString(); + const actual = await fetchSha256(assetUrl, fetchImpl); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } + } catch (reason) { + failures.push({ + assetName, + reason: formatErrorReason(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 or invalid release asset(s): ${failures + .map(({ assetName, reason }) => `${assetName} (${reason})`) + .join('; ')}`, + ); +} + +async function fetchSha256(url, fetchImpl) { + const response = await fetchWithTimeout(fetchImpl, url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + 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) { + if (reason instanceof Error) { + return reason.message.replace(/^ERROR:\s*/, ''); + } + return String(reason); +} + +async function fetchText(url, fetchImpl) { + const response = await fetchWithTimeout(fetchImpl, url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + 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 { + parsed = new URL(baseUrl); + } catch { + fail(`--base-url must be a valid URL: ${baseUrl}`); + } + if (parsed.protocol !== 'https:') { + fail(`--base-url must use https: ${baseUrl}`); + } + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); +} + +export { + EXPECTED_STANDALONE_ARCHIVE_NAMES, + EXPECTED_RELEASE_ASSET_NAMES, + releaseAssetPaths, + verifyReleaseBaseUrl, + verifyReleaseDirectory, +};