From 98d2be71549637870feca858a28a72a75bb03402 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 7 May 2026 21:08:48 +0800 Subject: [PATCH 01/28] fix(installer): tighten verifier base-url + clarify test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small refinements from the second review pass: - normalizeHttpsBaseUrl rejects everything except https, since real release URLs are always HTTPS. Accepting http previously would let an operator silently target a stale or attacker-controlled mirror. - Drop EXPECTED_RELEASE_ASSET_NAMES from the public exports; it was only used internally for the verification log line. - Rename the test helper standaloneChecksumContent to placeholderChecksumContent and document that the hashes in its output are placeholders — the remote verifier does not download archives or compare hashes, it only validates that SHA256SUMS lists the expected names and that each archive URL is reachable. The non-https rejection test now also covers `http://` in addition to the existing `file://` case. --- scripts/tests/install-script.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 763fe61cc6a..ba7fe028661 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1283,6 +1283,7 @@ describe('standalone release packaging', () => { installationReleaseVerificationScriptUrl ); + // file:// must be rejected as a URL the verifier cannot reach safely. await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( /--base-url must use https/, ); From 05e79e7101b1f31749addf5324f4bfc187aad6e5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 15:48:44 +0800 Subject: [PATCH 02/28] style(installer): align installer completion output --- .../installation/install-qwen-with-source.bat | 76 +++++++++------ .../installation/install-qwen-with-source.sh | 95 +++++++++++-------- 2 files changed, 103 insertions(+), 68 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 246ffc50492..3b638dde99e 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -124,29 +124,13 @@ goto usage_error call :ValidateOptions if %ERRORLEVEL% NEQ 0 exit /b 1 -echo =========================================== -echo Qwen Code Installation Script -echo =========================================== -echo. -echo INFO: Install method: !METHOD! -if /i not "!METHOD!"=="npm" ( - echo INFO: Standalone mirror: !MIRROR! - if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! - if not "!ARCHIVE_PATH!"=="" ( - echo INFO: Standalone archive: !ARCHIVE_PATH! - ) else ( - echo INFO: Standalone version: !VERSION! - ) -) -if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! -if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! -echo. +call :PrintHeader REM Dispatch after validation; detect falls back to npm only when unavailable. if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" endlocal exit /b 0 ) @@ -154,7 +138,7 @@ if /i "!METHOD!"=="standalone" ( if /i "!METHOD!"=="npm" ( call :InstallNpm if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" + call :PrintFinalInstructions "" "" "npm" endlocal exit /b 0 ) @@ -162,7 +146,7 @@ if /i "!METHOD!"=="npm" ( call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" endlocal exit /b 0 ) @@ -175,7 +159,7 @@ if !STANDALONE_STATUS! EQU 2 ( echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. exit /b !ERRORLEVEL! ) - call :PrintFinalInstructions "" + call :PrintFinalInstructions "" "" "npm" endlocal exit /b 0 ) @@ -209,6 +193,14 @@ echo Defaults to QWEN_NPM_REGISTRY or https://registr echo -h, --help Show this help message. exit /b 0 +:PrintHeader +set "DISPLAY_VERSION=!VERSION!" +if /i not "!DISPLAY_VERSION!"=="latest" ( + if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" +) +echo Installing Qwen Code version: !DISPLAY_VERSION! +exit /b 0 + :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" @@ -487,7 +479,7 @@ if not "!ARCHIVE_PATH!"=="" ( mkdir "!TEMP_DIR!" >nul 2>&1 set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - echo INFO: Downloading !ARCHIVE_URL! + echo Downloading !ARCHIVE_NAME! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 @@ -756,22 +748,48 @@ exit /b 0 :PrintFinalInstructions set "EXTRA_BIN=%~1" +set "SUMMARY_INSTALL_DIR=%~2" +set "SUMMARY_INSTALL_METHOD=%~3" +if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" echo. -echo =========================================== -echo Installation completed! -echo =========================================== +echo QWEN CODE echo. +set "QWEN_VERSION=" where qwen >nul 2>&1 if %ERRORLEVEL% EQU 0 ( for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" - echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! - echo. - echo You can now run: qwen +) + +if not "!QWEN_VERSION!"=="" ( + echo Qwen Code !QWEN_VERSION! installed successfully. +) else ( + echo Qwen Code installed successfully. +) + +echo. +echo To start: +echo cd ^ +echo qwen + +if not "!SUMMARY_INSTALL_DIR!"=="" ( echo. - echo INFO: Run qwen in your project directory to start an interactive session. + echo Installed to: + echo !SUMMARY_INSTALL_DIR! +) + +echo. +echo Uninstall: +if /i "!SUMMARY_INSTALL_METHOD!"=="npm" ( + echo npm uninstall -g @qwen-code/qwen-code +) else ( + if not "!SUMMARY_INSTALL_DIR!"=="" echo rmdir /S /Q "!SUMMARY_INSTALL_DIR!" + if not "!EXTRA_BIN!"=="" echo del /F /Q "!EXTRA_BIN!\qwen.cmd" +) + +if not "!QWEN_VERSION!"=="" ( exit /b 0 ) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1d8c5d7d753..1867e1edf69 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -71,6 +71,15 @@ shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } +display_install_version() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + echo "${VERSION#v}" +} + trap cleanup_temp_dirs EXIT trap 'cleanup_temp_dirs; exit 130' INT trap 'cleanup_temp_dirs; exit 143' TERM @@ -290,30 +299,7 @@ done validate_options print_header() { - echo "==========================================" - echo " Qwen Code Installation Script" - echo "==========================================" - echo "" - log_info "System: $(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)" - log_info "Install method: ${METHOD}" - if [[ "${METHOD}" != "npm" ]]; then - log_info "Standalone mirror: ${MIRROR}" - if [[ -n "${BASE_URL}" ]]; then - log_info "Standalone base URL: ${BASE_URL}" - fi - if [[ -n "${ARCHIVE_PATH}" ]]; then - log_info "Standalone archive: ${ARCHIVE_PATH}" - else - log_info "Standalone version: ${VERSION}" - fi - fi - if [[ "${METHOD}" != "standalone" ]]; then - log_info "npm registry: ${NPM_REGISTRY}" - fi - if [[ "${SOURCE}" != "unknown" ]]; then - log_info "Installation source: ${SOURCE}" - fi - echo "" + echo "Installing Qwen Code version: $(display_install_version)" } print_node_help() { @@ -398,6 +384,10 @@ get_npm_global_bin() { esac } +get_npm_global_root() { + npm root -g 2>/dev/null || true +} + create_source_json() { if [[ "${SOURCE}" == "unknown" ]]; then return 0 @@ -505,12 +495,12 @@ download_file() { local destination="$2" if command_exists curl; then - curl -fsSL --retry 2 "${url}" -o "${destination}" + curl -fL --retry 2 --progress-bar "${url}" -o "${destination}" return $? fi if command_exists wget; then - wget -q --tries=3 "${url}" -O "${destination}" || return 1 + wget --tries=3 "${url}" -O "${destination}" || return 1 return $? fi @@ -763,7 +753,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - log_info "Downloading ${archive_url}" + echo "Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then rm -rf "${temp_dir}" log_warning "Failed to download standalone archive." @@ -896,24 +886,51 @@ install_npm() { print_final_instructions() { local install_bin_dir="${1:-}" + local install_dir="${2:-}" + local install_method="${3:-standalone}" if [[ -n "${install_bin_dir}" ]]; then export PATH="${install_bin_dir}:${PATH}" fi echo "" - echo "==========================================" - echo "Installation completed!" - echo "==========================================" + echo "QWEN CODE" echo "" + local qwen_version="" if command_exists qwen; then - local qwen_version qwen_version=$(qwen --version 2>/dev/null || echo "unknown") - log_success "Qwen Code is ready to use: ${qwen_version}" - echo "" - echo "You can now run: qwen" + fi + + if [[ -n "${qwen_version}" ]]; then + echo "Qwen Code ${qwen_version} installed successfully." + else + echo "Qwen Code installed successfully." + fi + + echo "" + echo "To start:" + echo " cd " + echo " qwen" + + if [[ -n "${install_dir}" ]]; then echo "" - log_info "Run qwen in your project directory to start an interactive session." + 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 " rm -rf $(shell_quote "${install_dir}") $(shell_quote "${install_bin_dir}/qwen")" + elif [[ -n "${install_dir}" ]]; then + echo " rm -rf $(shell_quote "${install_dir}")" + else + echo " npm uninstall -g @qwen-code/qwen-code" + fi + + if [[ -n "${qwen_version}" ]]; then return 0 fi @@ -939,22 +956,22 @@ main() { case "${METHOD}" in standalone) install_standalone - print_final_instructions "${INSTALL_BIN_DIR}" + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" ;; npm) install_npm - print_final_instructions "$(get_npm_global_bin)" + print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" ;; detect) # Try the standalone archive first; fall back only when unavailable. if install_standalone; then - print_final_instructions "${INSTALL_BIN_DIR}" + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" else standalone_status=$? if [[ "${standalone_status}" -eq 2 ]]; then log_warning "Falling back to npm installation." if install_npm; then - print_final_instructions "$(get_npm_global_bin)" + print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" else log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed." log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm." From e6a1459a1a4925be3ea09e0b92c53f3dbe7b5855 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 15 May 2026 17:06:50 +0800 Subject: [PATCH 03/28] revert(installer): keep hosted installer output unchanged --- .../installation/install-qwen-with-source.bat | 76 ++++++--------- .../installation/install-qwen-with-source.sh | 95 ++++++++----------- scripts/tests/install-script.test.js | 2 +- 3 files changed, 69 insertions(+), 104 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 3b638dde99e..246ffc50492 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -124,13 +124,29 @@ goto usage_error call :ValidateOptions if %ERRORLEVEL% NEQ 0 exit /b 1 -call :PrintHeader +echo =========================================== +echo Qwen Code Installation Script +echo =========================================== +echo. +echo INFO: Install method: !METHOD! +if /i not "!METHOD!"=="npm" ( + echo INFO: Standalone mirror: !MIRROR! + if not "!BASE_URL!"=="" echo INFO: Standalone base URL: !BASE_URL! + if not "!ARCHIVE_PATH!"=="" ( + echo INFO: Standalone archive: !ARCHIVE_PATH! + ) else ( + echo INFO: Standalone version: !VERSION! + ) +) +if /i not "!METHOD!"=="standalone" echo INFO: npm registry: !NPM_REGISTRY! +if not "!SOURCE!"=="unknown" echo INFO: Installation source: !SOURCE! +echo. REM Dispatch after validation; detect falls back to npm only when unavailable. if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" endlocal exit /b 0 ) @@ -138,7 +154,7 @@ if /i "!METHOD!"=="standalone" ( if /i "!METHOD!"=="npm" ( call :InstallNpm if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! - call :PrintFinalInstructions "" "" "npm" + call :PrintFinalInstructions "" endlocal exit /b 0 ) @@ -146,7 +162,7 @@ if /i "!METHOD!"=="npm" ( call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( - call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" endlocal exit /b 0 ) @@ -159,7 +175,7 @@ if !STANDALONE_STATUS! EQU 2 ( echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm. exit /b !ERRORLEVEL! ) - call :PrintFinalInstructions "" "" "npm" + call :PrintFinalInstructions "" endlocal exit /b 0 ) @@ -193,14 +209,6 @@ echo Defaults to QWEN_NPM_REGISTRY or https://registr echo -h, --help Show this help message. exit /b 0 -:PrintHeader -set "DISPLAY_VERSION=!VERSION!" -if /i not "!DISPLAY_VERSION!"=="latest" ( - if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" -) -echo Installing Qwen Code version: !DISPLAY_VERSION! -exit /b 0 - :ValidateOptions if "!METHOD!"=="" set "METHOD=detect" @@ -479,7 +487,7 @@ if not "!ARCHIVE_PATH!"=="" ( mkdir "!TEMP_DIR!" >nul 2>&1 set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" - echo Downloading !ARCHIVE_NAME! + echo INFO: Downloading !ARCHIVE_URL! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 @@ -748,48 +756,22 @@ exit /b 0 :PrintFinalInstructions set "EXTRA_BIN=%~1" -set "SUMMARY_INSTALL_DIR=%~2" -set "SUMMARY_INSTALL_METHOD=%~3" -if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" if not "!EXTRA_BIN!"=="" set "PATH=!EXTRA_BIN!;!PATH!" echo. -echo QWEN CODE +echo =========================================== +echo Installation completed! +echo =========================================== echo. -set "QWEN_VERSION=" where qwen >nul 2>&1 if %ERRORLEVEL% EQU 0 ( for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" -) - -if not "!QWEN_VERSION!"=="" ( - echo Qwen Code !QWEN_VERSION! installed successfully. -) else ( - echo Qwen Code installed successfully. -) - -echo. -echo To start: -echo cd ^ -echo qwen - -if not "!SUMMARY_INSTALL_DIR!"=="" ( + echo SUCCESS: Qwen Code is ready to use: !QWEN_VERSION! echo. - echo Installed to: - echo !SUMMARY_INSTALL_DIR! -) - -echo. -echo Uninstall: -if /i "!SUMMARY_INSTALL_METHOD!"=="npm" ( - echo npm uninstall -g @qwen-code/qwen-code -) else ( - if not "!SUMMARY_INSTALL_DIR!"=="" echo rmdir /S /Q "!SUMMARY_INSTALL_DIR!" - if not "!EXTRA_BIN!"=="" echo del /F /Q "!EXTRA_BIN!\qwen.cmd" -) - -if not "!QWEN_VERSION!"=="" ( + echo You can now run: qwen + echo. + echo INFO: Run qwen in your project directory to start an interactive session. exit /b 0 ) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1867e1edf69..1d8c5d7d753 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -71,15 +71,6 @@ 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 @@ -299,7 +290,30 @@ done validate_options print_header() { - echo "Installing Qwen Code version: $(display_install_version)" + echo "==========================================" + echo " Qwen Code Installation Script" + echo "==========================================" + echo "" + log_info "System: $(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)" + log_info "Install method: ${METHOD}" + if [[ "${METHOD}" != "npm" ]]; then + log_info "Standalone mirror: ${MIRROR}" + if [[ -n "${BASE_URL}" ]]; then + log_info "Standalone base URL: ${BASE_URL}" + fi + if [[ -n "${ARCHIVE_PATH}" ]]; then + log_info "Standalone archive: ${ARCHIVE_PATH}" + else + log_info "Standalone version: ${VERSION}" + fi + fi + if [[ "${METHOD}" != "standalone" ]]; then + log_info "npm registry: ${NPM_REGISTRY}" + fi + if [[ "${SOURCE}" != "unknown" ]]; then + log_info "Installation source: ${SOURCE}" + fi + echo "" } print_node_help() { @@ -384,10 +398,6 @@ get_npm_global_bin() { esac } -get_npm_global_root() { - npm root -g 2>/dev/null || true -} - create_source_json() { if [[ "${SOURCE}" == "unknown" ]]; then return 0 @@ -495,12 +505,12 @@ download_file() { local destination="$2" if command_exists curl; then - curl -fL --retry 2 --progress-bar "${url}" -o "${destination}" + curl -fsSL --retry 2 "${url}" -o "${destination}" return $? fi if command_exists wget; then - wget --tries=3 "${url}" -O "${destination}" || return 1 + wget -q --tries=3 "${url}" -O "${destination}" || return 1 return $? fi @@ -753,7 +763,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - echo "Downloading ${archive_name}" + log_info "Downloading ${archive_url}" if ! download_file "${archive_url}" "${archive_path}"; then rm -rf "${temp_dir}" log_warning "Failed to download standalone archive." @@ -886,51 +896,24 @@ install_npm() { print_final_instructions() { local install_bin_dir="${1:-}" - local install_dir="${2:-}" - local install_method="${3:-standalone}" if [[ -n "${install_bin_dir}" ]]; then export PATH="${install_bin_dir}:${PATH}" fi echo "" - echo "QWEN CODE" + echo "==========================================" + echo "Installation completed!" + echo "==========================================" echo "" - local qwen_version="" if command_exists qwen; then + local qwen_version qwen_version=$(qwen --version 2>/dev/null || echo "unknown") - fi - - if [[ -n "${qwen_version}" ]]; then - echo "Qwen Code ${qwen_version} installed successfully." - else - echo "Qwen Code installed successfully." - fi - - echo "" - echo "To start:" - echo " cd " - echo " qwen" - - if [[ -n "${install_dir}" ]]; then + log_success "Qwen Code is ready to use: ${qwen_version}" 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 " rm -rf $(shell_quote "${install_dir}") $(shell_quote "${install_bin_dir}/qwen")" - elif [[ -n "${install_dir}" ]]; then - echo " rm -rf $(shell_quote "${install_dir}")" - else - echo " npm uninstall -g @qwen-code/qwen-code" - fi - - if [[ -n "${qwen_version}" ]]; then + echo "You can now run: qwen" + echo "" + log_info "Run qwen in your project directory to start an interactive session." return 0 fi @@ -956,22 +939,22 @@ main() { case "${METHOD}" in standalone) install_standalone - print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" + print_final_instructions "${INSTALL_BIN_DIR}" ;; npm) install_npm - print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" + print_final_instructions "$(get_npm_global_bin)" ;; detect) # Try the standalone archive first; fall back only when unavailable. if install_standalone; then - print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" + print_final_instructions "${INSTALL_BIN_DIR}" else standalone_status=$? if [[ "${standalone_status}" -eq 2 ]]; then log_warning "Falling back to npm installation." if install_npm; then - print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" + print_final_instructions "$(get_npm_global_bin)" else log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed." log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm." diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index ba7fe028661..0d13e0b5435 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1870,7 +1870,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'); - const output = runUnixInstaller(archive, installRoot, home).toString(); + runUnixInstaller(archive, installRoot, home); expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); expect( From 3eb5c496b4269d2f46c8d6fea81868986781c7fa Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 15:16:43 +0800 Subject: [PATCH 04/28] fix(installer): address release validation review feedback --- .../installation/install-qwen-with-source.bat | 4 +- .../installation/install-qwen-with-source.sh | 26 +++ scripts/tests/install-script.test.js | 154 ++++++++++++++++++ scripts/verify-installation-release.js | 60 ++++++- 4 files changed, 238 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 246ffc50492..9af8dc2c8db 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -306,7 +306,9 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^v*[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 1d8c5d7d753..02ab4fd58ee 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -624,6 +624,22 @@ validate_archive_entry_path() { esac } +archive_contains_symlinks() { + local archive_path="$1" + + case "${archive_path}" in + *.zip) + unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null + ;; + *.tar.gz|*.tgz|*.tar.xz) + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^l/ { found=1 } END { exit found ? 0 : 1 }' + ;; + *) + return 1 + ;; + esac +} + validate_archive_contents() { local archive_path="$1" local entries @@ -652,6 +668,16 @@ validate_archive_contents() { ;; esac + if [[ -z "${entries}" ]]; then + log_error "Archive is empty: ${archive_path}" + return 1 + fi + + if archive_contains_symlinks "${archive_path}"; then + log_error "Archive contains symlinks; refusing to install." + return 1 + fi + while IFS= read -r entry; do validate_archive_entry_path "${entry}" || return 1 done <<< "${entries}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 0d13e0b5435..0650decac57 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -127,6 +127,8 @@ describe('installation scripts', () => { expect(script).toContain('validate_https_url "${NPM_REGISTRY}"'); expect(script).toContain('qwen-code/node/bin/node'); expect(script).toContain('Archive contains symlinks; refusing to install'); + expect(script).toContain('Archive is empty'); + expect(script).toContain('archive_contains_symlinks()'); expect(script).toContain('not a Qwen Code standalone install'); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', @@ -288,6 +290,13 @@ describe('installation scripts', () => { expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); expect(script).toContain(':ValidateVersion'); + expect(script).toContain( + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', + ); + expect(script).toContain( + 'findstr /R /C:"^v[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', + ); + expect(script).not.toContain('/C:"^v*[0-9]'); expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); @@ -1201,6 +1210,32 @@ describe('standalone release packaging', () => { await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, ); + + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeStandaloneReleaseChecksums( + tmpDir, + EXPECTED_STANDALONE_ARCHIVE_NAMES.slice(1), + ); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Missing release asset checksum: qwen-code-/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects unexpected files in a release directory', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeFileSync(path.join(tmpDir, '.DS_Store'), 'finder metadata\n'); + + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected file\(s\) in release directory: \.DS_Store/, + ); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -1278,6 +1313,56 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/Checksum mismatch for qwen-code-/); }); + it('rejects remote SHA256SUMS responses that are unavailable', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async () => new Response('missing', { status: 404 }), + }), + ).rejects.toThrow(/Failed to download .*SHA256SUMS: 404/); + }); + + it('rejects remote SHA256SUMS with missing or extra archive entries', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response( + placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES.slice(1), + ), + ); + } + return new Response(null, { status: 200 }); + }, + }), + ).rejects.toThrow(/Missing release asset checksum: qwen-code-/); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response( + placeholderChecksumContent([ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'qwen-code-extra.tar.gz', + ]), + ); + } + return new Response(null, { status: 200 }); + }, + }), + ).rejects.toThrow( + /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, + ); + }); + it('rejects a release base URL that is not https', async () => { const { verifyReleaseBaseUrl } = await import( installationReleaseVerificationScriptUrl @@ -2631,6 +2716,64 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { } }); + itOnUnix('rejects archive symlinks before extraction', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createSymlinkStandaloneArchive(tmpDir); + const tarWrapperDir = path.join(tmpDir, 'bin'); + const marker = path.join(tmpDir, 'tar-extraction-attempted'); + mkdirSync(tarWrapperDir, { recursive: true }); + writeFileSync( + path.join(tarWrapperDir, 'tar'), + [ + '#!/usr/bin/env bash', + 'if [[ "$1" == "-xzf" || "$1" == "-xf" ]]; then', + ' touch "$QWEN_TAR_EXTRACT_MARKER"', + 'fi', + 'exec "$QWEN_REAL_TAR" "$@"', + '', + ].join('\n'), + ); + chmodSync(path.join(tarWrapperDir, 'tar'), 0o755); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + 'standalone', + { + PATH: `${tarWrapperDir}${path.delimiter}${process.env.PATH}`, + QWEN_REAL_TAR: execFileSync('which', ['tar']).toString().trim(), + QWEN_TAR_EXTRACT_MARKER: marker, + }, + ), + ).toThrow(/Archive contains symlinks/); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnUnix('rejects empty standalone archives with a clear error', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createEmptyStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive is empty/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnUnix( 'rejects standalone archives containing path traversal entries', () => { @@ -3803,6 +3946,17 @@ function createSymlinkStandaloneArchive(tmpDir) { return archive; } +function createEmptyStandaloneArchive(tmpDir) { + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.tar.gz'); + execFileSync('tar', ['-czf', archive, '-T', '/dev/null'], { + stdio: 'ignore', + }); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function createTraversalStandaloneArchive(tmpDir) { const maliciousRoot = path.join(tmpDir, 'malicious'); const packageRoot = path.join(maliciousRoot, 'qwen-code'); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 283001959db..6c92af44c54 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -120,11 +120,13 @@ async function verifyReleaseDirectory(dir, options = {}) { 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 unexpected = fs + .readdirSync(dir) + .filter((fileName) => !EXPECTED_RELEASE_ASSET_NAMES.includes(fileName)) + .sort(); + if (unexpected.length > 0) { + fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`); + } const actual = await sha256File(assetPath); const expected = checksums.get(assetName); @@ -289,12 +291,60 @@ function normalizeHttpsBaseUrl(baseUrl) { if (parsed.protocol !== 'https:') { fail(`--base-url must use https: ${baseUrl}`); } + if (isPrivateOrReservedHost(parsed.hostname)) { + fail(`--base-url must not target a private network: ${baseUrl}`); + } if (!parsed.pathname.endsWith('/')) { parsed.pathname = `${parsed.pathname}/`; } return parsed.toString(); } +function standaloneArchiveName(qwenTarget) { + const targetConfig = TARGETS.get(qwenTarget); + if (!targetConfig) { + fail(`Unknown release target: ${qwenTarget}`); + } + return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`; +} + +function isPrivateOrReservedHost(hostname) { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return true; + } + + const ipv4Parts = normalized.split('.'); + if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) { + const octets = ipv4Parts.map(Number); + if (octets.some((octet) => octet < 0 || octet > 255)) { + return false; + } + const [first, second] = octets; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); + } + + if (!normalized.includes(':')) { + return false; + } + + return ( + normalized === '::' || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe80:') + ); +} + export { EXPECTED_STANDALONE_ARCHIVE_NAMES, EXPECTED_RELEASE_ASSET_NAMES, From 20f5243f61315fb889afefbbb15addb19526567b Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 21 May 2026 16:12:30 +0800 Subject: [PATCH 05/28] docs: switch public install commands to standalone hosted entrypoint Update README, quickstart, and overview to point at the new install-qwen-standalone.sh / install-qwen-standalone.ps1 hosted URLs. Add standalone uninstall instructions to Uninstall.md. Remove the staged-rollout note from INSTALLATION_GUIDE.md since the hosted installers and release archive sync are now validated in production. --- README.md | 10 ++++------ docs/users/overview.md | 8 ++++---- docs/users/quickstart.md | 8 ++++---- docs/users/support/Uninstall.md | 20 +++++++++++++++++++- scripts/installation/INSTALLATION_GUIDE.md | 5 ----- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a0a95c88b11..ae671e153c2 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,13 @@ Qwen Code is an open-source AI agent for the terminal, optimized for Qwen series #### Linux / macOS ```bash -bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` -#### Windows (Run as Administrator) +#### Windows -Works in both Command Prompt and PowerShell: - -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > **Note**: It's recommended to restart your terminal after installation to ensure environment variables take effect. diff --git a/docs/users/overview.md b/docs/users/overview.md index a40753d7605..c9ed58196cd 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -10,19 +10,19 @@ ### Install Qwen Code: The recommended installer uses a standalone archive when one is available for -your platform. If it falls back to npm, Node.js 20 or later with npm must be +your platform. If it falls back to npm, Node.js 22 or later with npm must be available on PATH. **Linux / macOS** ```sh -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` **Windows** -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > [!note] diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 1d9fc203e7e..10bc4da31f3 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -21,13 +21,13 @@ To install Qwen Code, use one of the following methods: **Linux / macOS** ```sh -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash ``` -**Windows (Run as Administrator)** +**Windows** -```cmd -powershell -Command "Invoke-WebRequest 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path $env:TEMP 'install-qwen.bat')" +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex ``` > [!note] diff --git a/docs/users/support/Uninstall.md b/docs/users/support/Uninstall.md index f8970c88304..96a654381ab 100644 --- a/docs/users/support/Uninstall.md +++ b/docs/users/support/Uninstall.md @@ -1,6 +1,6 @@ # Uninstall -Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation. +Your uninstall method depends on how you installed the CLI. ## Method 1: Using npx @@ -40,3 +40,21 @@ npm uninstall -g @qwen-code/qwen-code ``` This command completely removes the package from your system. + +## Method 3: Standalone Install + +If you installed via the standalone installer (`curl ... | bash` or `irm ... | iex`), use the dedicated uninstall script. + +**Linux / macOS** + +```bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash +``` + +**Windows** + +```powershell +irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex +``` + +The uninstaller removes the standalone runtime, generated `qwen` wrapper, and installer-managed PATH changes. Your Qwen Code configuration (`~/.qwen`) is preserved by default. diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index e2d863e3283..b92075e49b4 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -46,11 +46,6 @@ standalone release. The `standalone` suffix intentionally avoids overwriting the existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during the staged rollout. -Public installation documentation intentionally continues to use the existing -production installer in this PR. Update README and other public quick-install -instructions in a follow-up after the standalone-suffixed hosted installers and -release archive sync have been validated in production. - Hosted installer assets are staged separately from GitHub Release archives: - `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint. From 523e03e8d4f4e59d83ba88b320a0fc981ba12737 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 22 May 2026 22:42:31 +0800 Subject: [PATCH 06/28] docs: clarify pull request size guidance --- CONTRIBUTING.md | 5 ++++- docs/developers/contributing.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3e641b3903..b96c586b6fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con - **Do:** Create a PR that fixes one specific bug or adds one specific feature. - **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. -Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. +As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed +lines. PRs above about 2,000 changed lines should either be split into a series +of smaller, logical PRs that can be reviewed and merged independently, or +explain in the PR description why the change needs to land together. #### 3. Use Draft PRs for Work in Progress diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md index b95ef828e40..6dd54b9fb75 100644 --- a/docs/developers/contributing.md +++ b/docs/developers/contributing.md @@ -30,7 +30,10 @@ We favor small, atomic PRs that address a single issue or add a single, self-con - **Do:** Create a PR that fixes one specific bug or adds one specific feature. - **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. -Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. +As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed +lines. PRs above about 2,000 changed lines should either be split into a series +of smaller, logical PRs that can be reviewed and merged independently, or +explain in the PR description why the change needs to land together. #### 3. Use Draft PRs for Work in Progress From c6e4244c13655d8a8ccd683f86a75eca7a184a92 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 22 May 2026 22:42:43 +0800 Subject: [PATCH 07/28] fix(installation): harden standalone release validation --- scripts/create-standalone-package.js | 2 +- .../installation/install-qwen-standalone.bat | 15 +- .../installation/install-qwen-standalone.sh | 26 ++++ scripts/tests/install-script.test.js | 23 ++- scripts/verify-installation-release.js | 131 +++++++++++++----- 5 files changed, 151 insertions(+), 46 deletions(-) diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 4f6e52f33ff..422d6bcfd9c 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -608,4 +608,4 @@ function fail(message) { throw new Error(`Error: ${message}`); } -export { writeSha256Sums }; +export { TARGETS, writeSha256Sums }; diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index d255767b5dc..35c17a3785c 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -439,11 +439,10 @@ 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(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 @@ -1050,7 +1049,7 @@ REM with backslash separators even though the ZIP spec requires '/'. We REM accept either separator and reject only entries that, after REM normalization, are empty, absolute, drive-rooted, or contain a '..' REM segment. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_ARCHIVE_FILE=" if %PS_STATUS% EQU 0 exit /b 0 @@ -1062,6 +1061,10 @@ if %PS_STATUS% EQU 2 ( echo ERROR: Archive could not be inspected before extraction. exit /b 1 ) +if %PS_STATUS% EQU 3 ( + echo ERROR: Archive is empty: !ARCHIVE_FILE! + exit /b 1 +) echo ERROR: Archive validation failed before extraction. exit /b %PS_STATUS% diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 3dc8d5c6685..db73c63d9de 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -884,6 +884,22 @@ validate_archive_entry_path() { esac } +archive_contains_symlinks() { + local archive_path="$1" + + case "${archive_path}" in + *.zip) + unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null + ;; + *.tar.gz|*.tgz|*.tar.xz) + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^l/ { found=1 } END { exit found ? 0 : 1 }' + ;; + *) + return 1 + ;; + esac +} + validate_archive_contents() { local archive_path="$1" local entries @@ -912,6 +928,16 @@ validate_archive_contents() { ;; esac + if [[ -z "${entries}" ]]; then + log_error "Archive is empty: ${archive_path}" + return 1 + fi + + if archive_contains_symlinks "${archive_path}"; then + log_error "Archive contains symlinks; refusing to install." + return 1 + fi + while IFS= read -r entry; do validate_archive_entry_path "${entry}" || return 1 done <<< "${entries}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 0650decac57..2cf9c71c428 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -336,6 +336,7 @@ describe('installation scripts', () => { ); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('Archive is empty'); expect(script).toContain('unsafe path with control character'); expect(script).toContain('Failed to update user PATH'); expect(script).toContain('QWEN_INSTALL_ROOT'); @@ -1377,6 +1378,25 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); + it('rejects private or reserved release base URL hosts', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + const fetchImpl = vi.fn(); + + for (const baseUrl of [ + 'https://localhost/release/', + 'https://127.0.0.1/release/', + 'https://[::ffff:127.0.0.1]/release/', + 'https://[fe90::1]/release/', + ]) { + await expect( + verifyReleaseBaseUrl(baseUrl, { fetchImpl }), + ).rejects.toThrow(/--base-url must not target a private network/); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1897,7 +1917,6 @@ describe('standalone release packaging', () => { expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(guide).toContain('ALIYUN_OSS_BUCKET'); expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); - expect(guide).toContain('Public installation documentation'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); @@ -1955,7 +1974,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( diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 6c92af44c54..98e513e8e81 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -13,6 +13,7 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { TARGETS } from './create-standalone-package.js'; import { fail, isMainModule, @@ -42,9 +43,8 @@ const REMOTE_FETCH_TIMEOUT_MS = 30_000; // has to be reflected here (and there) before a new target ships, otherwise // the verify and the build will disagree on expected filenames. function standaloneArchiveNamesFromReleaseTargets(releaseTargets) { - return releaseTargets.map( - ({ qwenTarget }) => - `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + return releaseTargets.map(({ qwenTarget }) => + standaloneArchiveName(qwenTarget), ); } @@ -118,7 +118,6 @@ async function verifyReleaseDirectory(dir, options = {}) { const { silent = false } = options; const checksums = readReleaseChecksums(dir); assertExpectedChecksumEntries(checksums); - assertExpectedArchiveFiles(dir); const unexpected = fs .readdirSync(dir) @@ -128,6 +127,14 @@ async function verifyReleaseDirectory(dir, options = {}) { fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`); } + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + const assetPath = path.join(dir, assetName); + if (!fs.existsSync(assetPath)) { + fail(`Missing release asset: ${assetName}`); + } + if (!fs.statSync(assetPath).isFile()) { + fail(`Release asset is not a file: ${assetName}`); + } const actual = await sha256File(assetPath); const expected = checksums.get(assetName); if (actual !== expected) { @@ -184,18 +191,6 @@ function assertExpectedChecksumEntries(checksums) { } } -function assertExpectedArchiveFiles(dir) { - const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES); - const extra = fs - .readdirSync(dir) - .filter((assetName) => !expected.has(assetName)) - .sort(); - - if (extra.length > 0) { - fail(`Unexpected release asset: ${extra.join(', ')}`); - } -} - function releaseAssetPaths(dir) { return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => path.join(dir, assetName), @@ -314,34 +309,96 @@ function isPrivateOrReservedHost(hostname) { return true; } - const ipv4Parts = normalized.split('.'); - if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) { - const octets = ipv4Parts.map(Number); - if (octets.some((octet) => octet < 0 || octet > 255)) { - return false; - } - const [first, second] = octets; - return ( - first === 0 || - first === 10 || - first === 127 || - (first === 169 && second === 254) || - (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 168) - ); + const mappedIpv4 = ipv4FromMappedIpv6(normalized); + if (mappedIpv4) { + return isPrivateOrReservedIpv4(mappedIpv4); + } + + if (parseIpv4Octets(normalized)) { + return isPrivateOrReservedIpv4(normalized); } if (!normalized.includes(':')) { return false; } + return isPrivateOrReservedIpv6(normalized); +} + +function parseIpv4Octets(value) { + const parts = value.split('.'); + if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) { + return null; + } + + const octets = parts.map(Number); + if (octets.some((octet) => octet < 0 || octet > 255)) { + return null; + } + return octets; +} + +function isPrivateOrReservedIpv4(value) { + const octets = parseIpv4Octets(value); + if (!octets) { + return false; + } + + const [first, second, third] = octets; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0 && third === 0) || + (first === 192 && second === 0 && third === 2) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ); +} + +function ipv4FromMappedIpv6(value) { + const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/); + if (!match) { + return null; + } + + const suffix = match[1]; + if (parseIpv4Octets(suffix)) { + return suffix; + } + + const hexParts = suffix.split(':'); + if ( + hexParts.length !== 2 || + !hexParts.every((part) => /^[0-9a-f]{1,4}$/.test(part)) + ) { + return null; + } + + const high = Number.parseInt(hexParts[0], 16); + const low = Number.parseInt(hexParts[1], 16); + return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; +} + +function isPrivateOrReservedIpv6(value) { + if (value === '::' || value === '::1' || value === '0:0:0:0:0:0:0:1') { + return true; + } + + const firstHextet = Number.parseInt(value.split(':', 1)[0] || '0', 16); + if (Number.isNaN(firstHextet)) { + return false; + } + return ( - normalized === '::' || - normalized === '::1' || - normalized === '0:0:0:0:0:0:0:1' || - normalized.startsWith('fc') || - normalized.startsWith('fd') || - normalized.startsWith('fe80:') + (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || + (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) ); } From 67cae75ca06508a06d58cafd308b468e931eacfe Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 22 May 2026 23:19:49 +0800 Subject: [PATCH 08/28] fix(installation): redact release verifier credentials --- scripts/tests/install-script.test.js | 134 +++++++++++++++++++++++++ scripts/verify-installation-release.js | 35 +++++-- 2 files changed, 161 insertions(+), 8 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 2cf9c71c428..5cd140be3b5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1397,6 +1397,78 @@ describe('standalone release packaging', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('redacts credentials in release base URL validation errors', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + const fetchImpl = vi.fn(); + + await expect( + verifyReleaseBaseUrl('https://user:secret@127.0.0.1/release/', { + fetchImpl, + }), + ).rejects.toThrow( + /--base-url must not target a private network: https:\/\/127\.0\.0\.1\/release\//, + ); + await expect( + verifyReleaseBaseUrl('https://user:secret@127.0.0.1/release/', { + fetchImpl, + }), + ).rejects.not.toThrow(/user:secret|secret/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('redacts credentials in invalid release base URL errors', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + for (const baseUrl of [ + 'https://user:p@ss@example.com:bad/release/', + 'https://user:my secret@example.com:bad/release/', + ]) { + await expect(verifyReleaseBaseUrl(baseUrl)).rejects.toThrow( + /--base-url must be a valid URL: /, + ); + await expect(verifyReleaseBaseUrl(baseUrl)).rejects.not.toThrow( + /user:|p@ss|my secret|ss@example/, + ); + } + }); + + it('redacts credentials in remote release URL errors', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const fetchedUrls = []; + const fetchImpl = async (url) => { + fetchedUrls.push(url); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response('missing', { status: 404, statusText: 'Not Found' }); + }; + + await expect( + verifyReleaseBaseUrl('https://user:secret@example.com/qwen-code/v0.0.0', { + fetchImpl, + }), + ).rejects.toThrow( + /check --base-url: https:\/\/example\.com\/qwen-code\/v0\.0\.0\//, + ); + await expect( + verifyReleaseBaseUrl('https://user:secret@example.com/qwen-code/v0.0.0', { + fetchImpl, + }), + ).rejects.not.toThrow(/user:secret|secret/); + for (const url of fetchedUrls) { + expect(url).not.toContain('user:secret'); + expect(url).not.toContain('secret'); + } + }); + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1767,6 +1839,22 @@ describe('standalone release packaging', () => { expect(workflow).toContain( 'npm run verify:installation-release -- --dir dist/standalone', ); + const buildStandaloneStepIndex = workflow.indexOf( + "name: 'Build Standalone Archives'", + ); + const localVerifyStepIndex = workflow.indexOf( + "name: 'Verify Installation Release Assets'", + ); + const npmPackagePublishStepIndex = workflow.indexOf( + "name: 'Publish @qwen-code/qwen-code'", + ); + const npmChannelPublishStepIndex = workflow.indexOf( + "name: 'Publish @qwen-code/channel-base'", + ); + expect(buildStandaloneStepIndex).toBeGreaterThanOrEqual(0); + expect(localVerifyStepIndex).toBeGreaterThan(buildStandaloneStepIndex); + expect(npmPackagePublishStepIndex).toBeGreaterThan(localVerifyStepIndex); + expect(npmChannelPublishStepIndex).toBeGreaterThan(localVerifyStepIndex); 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'); @@ -1788,6 +1876,7 @@ describe('standalone release packaging', () => { "name: 'Create GitHub Release and Tag'", ); expect(createReleaseStepIndex).toBeGreaterThanOrEqual(0); + expect(createReleaseStepIndex).toBeGreaterThan(localVerifyStepIndex); const createReleaseStep = workflow.slice(createReleaseStepIndex); expect(createReleaseStep).toContain('mapfile -t release_assets'); expect(createReleaseStep).toContain('"${release_assets[@]}"'); @@ -2735,6 +2824,24 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { } }); + itOnUnix('rejects standalone zip archives containing symlinks', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createZipSymlinkStandaloneArchive(tmpDir); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive contains symlinks/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnUnix('rejects archive symlinks before extraction', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -3965,6 +4072,33 @@ function createSymlinkStandaloneArchive(tmpDir) { return archive; } +function createZipSymlinkStandaloneArchive(tmpDir) { + const maliciousRoot = path.join(tmpDir, 'zip-malicious'); + const packageRoot = path.join(maliciousRoot, 'qwen-code'); + mkdirSync(path.join(packageRoot, 'bin'), { recursive: true }); + mkdirSync(path.join(packageRoot, 'node', 'bin'), { recursive: true }); + symlinkSync('/usr/bin/env', path.join(packageRoot, 'bin', 'qwen')); + writeFileSync( + path.join(packageRoot, 'node', 'bin', 'node'), + '#!/usr/bin/env sh\necho 0.0.0-smoke\n', + ); + chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); + writeFileSync( + path.join(packageRoot, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), + ); + + const outDir = path.join(tmpDir, 'out'); + mkdirSync(outDir, { recursive: true }); + const archive = path.join(outDir, 'qwen-code-linux-x64.zip'); + execFileSync('zip', ['-qry', archive, 'qwen-code'], { + cwd: maliciousRoot, + stdio: 'ignore', + }); + writeChecksumFile(outDir, path.basename(archive)); + return archive; +} + function createEmptyStandaloneArchive(tmpDir) { const outDir = path.join(tmpDir, 'out'); mkdirSync(outDir, { recursive: true }); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 98e513e8e81..44235105a36 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -154,6 +154,7 @@ async function verifyReleaseDirectory(dir, options = {}) { async function verifyReleaseBaseUrl(baseUrl, options = {}) { const { fetchImpl = fetch } = options; const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl); + const displayBaseUrl = redactUrlForLog(normalizedBaseUrl); const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); assertExpectedChecksumEntries(checksums); @@ -161,7 +162,7 @@ async function verifyReleaseBaseUrl(baseUrl, options = {}) { await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl); console.log( - `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`, + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${displayBaseUrl}`, ); } @@ -225,8 +226,9 @@ async function assertRemoteAssetChecksums( return; } if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) { + const displayBaseUrl = redactUrlForLog(normalizedBaseUrl); fail( - `All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`, + `All ${failures.length} release asset URLs are unavailable; check --base-url: ${displayBaseUrl}`, ); } fail( @@ -237,14 +239,15 @@ async function assertRemoteAssetChecksums( } async function fetchSha256(url, fetchImpl) { + const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); if (!response.ok) { fail( - `Failed to download ${url}: ${response.status} ${response.statusText}`, + `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, ); } if (!response.body) { - fail(`Downloaded response has no body: ${url}`); + fail(`Downloaded response has no body: ${displayUrl}`); } const hash = crypto.createHash('sha256'); @@ -260,10 +263,11 @@ function formatErrorReason(reason) { } async function fetchText(url, fetchImpl) { + const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); if (!response.ok) { fail( - `Failed to download ${url}: ${response.status} ${response.statusText}`, + `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, ); } return response.text(); @@ -281,20 +285,35 @@ function normalizeHttpsBaseUrl(baseUrl) { try { parsed = new URL(baseUrl); } catch { - fail(`--base-url must be a valid URL: ${baseUrl}`); + fail(`--base-url must be a valid URL: ${redactUrlForLog(baseUrl)}`); } + const displayBaseUrl = redactUrlForLog(parsed.toString()); if (parsed.protocol !== 'https:') { - fail(`--base-url must use https: ${baseUrl}`); + fail(`--base-url must use https: ${displayBaseUrl}`); } if (isPrivateOrReservedHost(parsed.hostname)) { - fail(`--base-url must not target a private network: ${baseUrl}`); + fail(`--base-url must not target a private network: ${displayBaseUrl}`); } + parsed.username = ''; + parsed.password = ''; if (!parsed.pathname.endsWith('/')) { parsed.pathname = `${parsed.pathname}/`; } return parsed.toString(); } +function redactUrlForLog(url) { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } catch { + const value = String(url); + return value.includes('@') ? '' : value; + } +} + function standaloneArchiveName(qwenTarget) { const targetConfig = TARGETS.get(qwenTarget); if (!targetConfig) { From a6f4bdafaeb20a8295ec932f0466116bb78e6aa2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 25 May 2026 21:58:48 +0800 Subject: [PATCH 09/28] feat(installer): add visual branding to Linux/macOS install script Add brand-colored ASCII art logo, custom download progress bar with Unicode block characters, and step indicators [1/3] [2/3] [3/3] to match the quality of competing CLI installers. --- .../installation/install-qwen-standalone.sh | 104 +++++++++++++++++- scripts/tests/install-script.test.js | 14 +-- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index db73c63d9de..739977a4ace 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -29,6 +29,10 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' +BRAND_ROSE='\033[38;5;168m' +BRAND_PURPLE='\033[38;5;140m' +BRAND_BLUE='\033[38;5;68m' +MUTED='\033[0;2m' NC='\033[0m' log_info() { @@ -752,7 +756,78 @@ standalone_base_url() { github_base_url_for_version "${version_path}" } -download_file() { +print_progress() { + local bytes="$1" + local length="$2" + [ "$length" -gt 0 ] 2>/dev/null || return 0 + local width=50 + local percent=$(( bytes * 100 / length )) + [ "$percent" -gt 100 ] && percent=100 + local on=$(( percent * width / 100 )) + local off=$(( width - on )) + local filled=$(printf "%*s" "$on" "") + filled=${filled// /■} + local empty=$(printf "%*s" "$off" "") + empty=${empty// /・} + printf "\r${BRAND_PURPLE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4 +} + +download_with_progress() { + local url="$1" + local output="$2" + + if ! [ -t 2 ] || ! command_exists curl || ! command_exists mkfifo; then + download_file_simple "$url" "$output" + return $? + fi + + exec 4>&2 + local tmp_dir="${TMPDIR:-/tmp}" + local basename="${tmp_dir}/qwen_install_$$" + local tracefile="${basename}.trace" + rm -f "$tracefile" + mkfifo "$tracefile" 2>/dev/null || { + download_file_simple "$url" "$output" + return $? + } + + printf "\033[?25l" >&4 + trap 'rm -f "$tracefile"; printf "\033[?25h" >&4' RETURN + + ( curl --trace-ascii "$tracefile" -s -L --retry 2 --connect-timeout 15 --max-time 300 -o "$output" "$url" ) & + local curl_pid=$! + + local content_length=0 + local received=0 + + while IFS= read -r line; do + if [[ "$line" == *"Content-Length:"* ]] && [ "$content_length" -eq 0 ] 2>/dev/null; then + content_length=$(echo "$line" | grep -oi 'Content-Length: [0-9]*' | grep -o '[0-9]*' | tail -1) + content_length=${content_length:-0} + fi + if [[ "$line" == *"<= Recv data,"* ]]; then + local chunk_size + chunk_size=$(echo "$line" | grep -o '[0-9]* bytes' | grep -o '[0-9]*') + if [ -n "$chunk_size" ]; then + received=$(( received + chunk_size )) + print_progress "$received" "$content_length" + fi + fi + done < "$tracefile" + + wait $curl_pid + local exit_code=$? + + printf "\r%*s\r" 60 "" >&4 + + rm -f "$tracefile" + printf "\033[?25h" >&4 + trap - RETURN + exec 4>&- + return $exit_code +} + +download_file_simple() { local url="$1" local destination="$2" @@ -778,6 +853,13 @@ download_file() { return 1 } +download_file() { + local url="$1" + local destination="$2" + + download_with_progress "${url}" "${destination}" +} + url_exists() { local url="$1" @@ -1137,7 +1219,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - echo "Downloading ${archive_name}" + echo -e "${BRAND_PURPLE}[1/3]${NC} Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then if [[ -n "${github_fallback_base_url}" ]]; then rm -f "${archive_path}" @@ -1146,7 +1228,7 @@ install_standalone() { MIRROR="github" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." - echo "Downloading ${archive_name}" + echo -e "${BRAND_PURPLE}[1/3]${NC} Downloading ${archive_name}" if download_file "${archive_url}" "${archive_path}"; then : else @@ -1174,12 +1256,14 @@ install_standalone() { fi # Verify integrity before extraction or changing the install directory. + echo -e "${BRAND_PURPLE}[2/3]${NC} Verifying checksum" if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then rm -rf "${temp_dir}" return 1 fi # Extract into a temporary directory, then validate required entry points. + echo -e "${BRAND_PURPLE}[3/3]${NC} Installing" local extract_dir="${temp_dir}/extract" if ! extract_archive "${archive_path}" "${extract_dir}"; then rm -rf "${temp_dir}" @@ -1319,6 +1403,16 @@ install_npm() { return 1 } +print_logo() { + # "QWEN CODE" in ╔═╗║╚╝ style matching the CLI header, with brand gradient + echo -e "${BRAND_ROSE} ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ ${BRAND_BLUE} ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄${NC}" + echo -e "${BRAND_ROSE}██╔═══██╗██║ ██║██╔════╝████╗ ██║ ${BRAND_BLUE}██╔════╝██╔═══██╗██╔══██╗██╔════╝${NC}" + echo -e "${BRAND_ROSE}██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ ${BRAND_PURPLE}██║ ██║ ██║██║ ██║█████╗${NC}" + echo -e "${BRAND_PURPLE}██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ ${BRAND_PURPLE}██║ ██║ ██║██║ ██║██╔══╝${NC}" + echo -e "${BRAND_PURPLE}╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ ${BRAND_BLUE}╚██████╗╚██████╔╝██████╔╝███████╗${NC}" + echo -e "${BRAND_BLUE} ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ ${BRAND_BLUE}╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝${NC}" +} + print_final_instructions() { local install_bin_dir="${1:-}" local install_dir="${2:-}" @@ -1364,9 +1458,9 @@ print_final_instructions() { installed_version=$(qwen --version 2>/dev/null || echo "unknown") fi - echo "QWEN CODE" + print_logo echo "" - echo "Qwen Code ${installed_version} installed successfully." + echo -e " ${BRAND_PURPLE}Qwen Code ${installed_version}${NC} installed successfully." echo "" echo "To start:" echo " cd " diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index c4153dd7a9c..f197b5793af 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -70,10 +70,8 @@ describe('installation scripts', () => { expect(script).toContain('npm_package_spec()'); expect(script).toContain('@qwen-code/qwen-code@latest'); expect(script).toContain('Installing Qwen Code version:'); - expect(script).toContain('QWEN CODE'); - expect(script).toContain( - 'Qwen Code ${installed_version} installed successfully.', - ); + expect(script).toContain('print_logo'); + expect(script).toContain('installed successfully.'); expect(script).toContain('To start:'); expect(script).toContain('Installed to:'); expect(script).toContain('Uninstall:'); @@ -153,7 +151,7 @@ describe('installation scripts', () => { 'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"', ); expect(script).toContain('wget_args+=(--read-timeout=30)'); - expect(script).toContain('echo "Downloading ${archive_name}"'); + expect(script).toContain('Downloading ${archive_name}'); expect(script).not.toContain( 'curl -fsSL --retry 2 "${url}" -o "${destination}"', ); @@ -1895,10 +1893,8 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { .trim(); expect(version).toBe('0.0.0-smoke'); expect(output).toContain('Installing Qwen Code version: latest'); - expect(output).toContain('QWEN CODE'); - expect(output).toContain( - 'Qwen Code 0.0.0-smoke installed successfully.', - ); + expect(output).toContain('installed successfully.'); + expect(output).toContain('0.0.0-smoke'); expect(output).toContain('To start:\n cd \n qwen'); expect(output).toContain( `Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`, From 5481da549dcbabc18cfa2796e90c843f6f4a41d1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 25 May 2026 22:01:51 +0800 Subject: [PATCH 10/28] fix(test): update stale assertion after guide text was removed The text "Public installation documentation" was removed in 20f5243f6 but the test assertion was not updated, causing a persistent failure. --- scripts/tests/install-script.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index f197b5793af..ddcc9a97333 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1816,7 +1816,7 @@ describe('standalone release packaging', () => { expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); expect(guide).toContain('ALIYUN_OSS_BUCKET'); expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); - expect(guide).toContain('Public installation documentation'); + expect(guide).toContain('hosted entrypoint'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); From 178f0909835466d32f005787e0f300765e84de34 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 26 May 2026 00:10:35 +0800 Subject: [PATCH 11/28] feat(installer): use truecolor per-character gradient for logo branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 256-color block coloring with 24-bit truecolor per-character gradient interpolation matching the CLI's ink-gradient rendering. Colors follow the fallback gradient: #4796E4 → #847ACE → #C3677F. Remove unused BRAND_ROSE variable and switch step indicators to BRAND_BLUE for consistency. --- .../installation/install-qwen-standalone.sh | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 739977a4ace..b8e282c9d7c 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -29,9 +29,8 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -BRAND_ROSE='\033[38;5;168m' -BRAND_PURPLE='\033[38;5;140m' -BRAND_BLUE='\033[38;5;68m' +BRAND_BLUE='\033[38;2;71;150;228m' +BRAND_PURPLE='\033[38;2;132;122;206m' MUTED='\033[0;2m' NC='\033[0m' @@ -1219,7 +1218,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - echo -e "${BRAND_PURPLE}[1/3]${NC} Downloading ${archive_name}" + echo -e "${BRAND_BLUE}[1/3]${NC} Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then if [[ -n "${github_fallback_base_url}" ]]; then rm -f "${archive_path}" @@ -1228,7 +1227,7 @@ install_standalone() { MIRROR="github" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." - echo -e "${BRAND_PURPLE}[1/3]${NC} Downloading ${archive_name}" + echo -e "${BRAND_BLUE}[1/3]${NC} Downloading ${archive_name}" if download_file "${archive_url}" "${archive_path}"; then : else @@ -1256,14 +1255,14 @@ install_standalone() { fi # Verify integrity before extraction or changing the install directory. - echo -e "${BRAND_PURPLE}[2/3]${NC} Verifying checksum" + echo -e "${BRAND_BLUE}[2/3]${NC} Verifying checksum" if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then rm -rf "${temp_dir}" return 1 fi # Extract into a temporary directory, then validate required entry points. - echo -e "${BRAND_PURPLE}[3/3]${NC} Installing" + echo -e "${BRAND_BLUE}[3/3]${NC} Installing" local extract_dir="${temp_dir}/extract" if ! extract_archive "${archive_path}" "${extract_dir}"; then rm -rf "${temp_dir}" @@ -1403,14 +1402,48 @@ install_npm() { return 1 } +gradient_line() { + local text="$1" + local r1=$2 g1=$3 b1=$4 + local r2=$5 g2=$6 b2=$7 + local r3=$8 g3=$9 b3=${10} + local len=${#text} + [ "$len" -eq 0 ] && return + local i=0 + local half=$(( len / 2 )) + while [ $i -lt $len ]; do + local char="${text:$i:1}" + local r g b + if [ $i -lt $half ]; then + local t=$(( i * 1000 / half )) + r=$(( (r1 * (1000 - t) + r2 * t) / 1000 )) + g=$(( (g1 * (1000 - t) + g2 * t) / 1000 )) + b=$(( (b1 * (1000 - t) + b2 * t) / 1000 )) + else + local t=$(( (i - half) * 1000 / (len - half) )) + r=$(( (r2 * (1000 - t) + r3 * t) / 1000 )) + g=$(( (g2 * (1000 - t) + g3 * t) / 1000 )) + b=$(( (b2 * (1000 - t) + b3 * t) / 1000 )) + fi + if [ "$char" = " " ]; then + printf " " + else + printf "\033[38;2;%d;%d;%dm%s" "$r" "$g" "$b" "$char" + fi + i=$(( i + 1 )) + done + printf "\033[0m\n" +} + print_logo() { - # "QWEN CODE" in ╔═╗║╚╝ style matching the CLI header, with brand gradient - echo -e "${BRAND_ROSE} ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ ${BRAND_BLUE} ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄${NC}" - echo -e "${BRAND_ROSE}██╔═══██╗██║ ██║██╔════╝████╗ ██║ ${BRAND_BLUE}██╔════╝██╔═══██╗██╔══██╗██╔════╝${NC}" - echo -e "${BRAND_ROSE}██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║ ${BRAND_PURPLE}██║ ██║ ██║██║ ██║█████╗${NC}" - echo -e "${BRAND_PURPLE}██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║ ${BRAND_PURPLE}██║ ██║ ██║██║ ██║██╔══╝${NC}" - echo -e "${BRAND_PURPLE}╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║ ${BRAND_BLUE}╚██████╗╚██████╔╝██████╔╝███████╗${NC}" - echo -e "${BRAND_BLUE} ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝ ${BRAND_BLUE}╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝${NC}" + # Per-character gradient matching CLI's ink-gradient rendering + # Direction: #4796E4 (blue) → #847ACE (purple) → #C3677F (rose) + gradient_line " ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄" 71 150 228 132 122 206 195 103 127 + gradient_line "██╔═══██╗██║ ██║██╔════╝████╗ ██║" 71 150 228 132 122 206 195 103 127 + gradient_line "██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║" 71 150 228 132 122 206 195 103 127 + gradient_line "██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║" 71 150 228 132 122 206 195 103 127 + gradient_line "╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║" 71 150 228 132 122 206 195 103 127 + gradient_line " ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝" 71 150 228 132 122 206 195 103 127 } print_final_instructions() { From 5ea2bdfab92015b3f6aa5ff6d97de62e45d9683b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 26 May 2026 01:14:37 +0800 Subject: [PATCH 12/28] fix(installer): address critical review findings on SSRF, semver, and reliability - Fix IPv4-mapped IPv6 SSRF bypass: handle 3-part hex representations that Node.js produces (e.g. ::ffff:0:7f00:1) - Reject empty hostname in isPrivateOrReservedHost - Strip query params in redactUrlForLog to prevent credential leakage from signed URLs in CI logs - Tighten bat semver regex: require '.' or '-' separator before suffix (rejects 1.2.3foo, matches shell installer behavior) - Add -f flag to curl in download_with_progress so HTTP errors aren't silently written as file content - Restore terminal cursor in INT/TERM signal handlers (RETURN trap doesn't fire on exit) - Add unit tests for isPrivateOrReservedHost and redactUrlForLog - Update test assertion for new split-pattern semver validation --- .../installation/install-qwen-standalone.bat | 10 +- .../installation/install-qwen-standalone.sh | 6 +- .../installation/install-qwen-with-source.bat | 10 +- scripts/tests/install-script.test.js | 109 +++++++++++++++++- scripts/verify-installation-release.js | 25 +++- 5 files changed, 146 insertions(+), 14 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 35c17a3785c..236cc768810 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -439,9 +439,15 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). +rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index b8e282c9d7c..d09d9c6696e 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -84,8 +84,8 @@ display_install_version() { } trap cleanup_temp_dirs EXIT -trap 'cleanup_temp_dirs; exit 130' INT -trap 'cleanup_temp_dirs; exit 143' TERM +trap 'printf "\033[?25h" 2>/dev/null; cleanup_temp_dirs; exit 130' INT +trap 'printf "\033[?25h" 2>/dev/null; cleanup_temp_dirs; exit 143' TERM print_usage() { cat <&4 trap 'rm -f "$tracefile"; printf "\033[?25h" >&4' RETURN - ( curl --trace-ascii "$tracefile" -s -L --retry 2 --connect-timeout 15 --max-time 300 -o "$output" "$url" ) & + ( curl --trace-ascii "$tracefile" -f -s -L --retry 2 --connect-timeout 15 --max-time 300 -o "$output" "$url" ) & local curl_pid=$! local content_length=0 diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 9af8dc2c8db..efcc152a80b 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -306,9 +306,15 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). +rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index ddcc9a97333..00bc252f767 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -232,9 +232,15 @@ describe('installation scripts', () => { expect(script).toContain( '[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE', ); - expect(script).not.toContain( + expect(script).toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); + expect(script).toContain( + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$"', + ); + expect(script).not.toContain( + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', + ); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); @@ -1860,6 +1866,107 @@ describe('standalone release packaging', () => { }); }); +describe('isPrivateOrReservedHost', () => { + it('rejects empty hostname', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('')).toBe(true); + }); + + it('rejects localhost variants', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('localhost')).toBe(true); + expect(isPrivateOrReservedHost('sub.localhost')).toBe(true); + expect(isPrivateOrReservedHost('LOCALHOST')).toBe(true); + }); + + it('rejects private IPv4 addresses', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('127.0.0.1')).toBe(true); + expect(isPrivateOrReservedHost('10.0.0.1')).toBe(true); + expect(isPrivateOrReservedHost('192.168.1.1')).toBe(true); + expect(isPrivateOrReservedHost('172.16.0.1')).toBe(true); + expect(isPrivateOrReservedHost('169.254.1.1')).toBe(true); + }); + + it('rejects IPv6 loopback and link-local', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::1')).toBe(true); + expect(isPrivateOrReservedHost('[::1]')).toBe(true); + expect(isPrivateOrReservedHost('fe80::1')).toBe(true); + }); + + it('rejects IPv4-mapped IPv6 addresses (2-part hex)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:7f00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:a00:1')).toBe(true); + }); + + it('rejects IPv4-mapped IPv6 addresses (3-part hex from Node normalization)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:0:7f00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:0:a00:1')).toBe(true); + expect(isPrivateOrReservedHost('::ffff:0:c0a8:101')).toBe(true); + }); + + it('allows public IP addresses', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('8.8.8.8')).toBe(false); + expect(isPrivateOrReservedHost('142.250.80.46')).toBe(false); + expect(isPrivateOrReservedHost('example.com')).toBe(false); + }); +}); + +describe('redactUrlForLog', () => { + it('strips username and password from URLs', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('https://user:pass@example.com/path')).toBe( + 'https://example.com/path', + ); + }); + + it('strips query parameters to prevent credential leakage', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect( + redactUrlForLog( + 'https://example.com/path?X-Amz-Signature=secret&token=abc', + ), + ).toBe('https://example.com/path'); + }); + + it('redacts malformed URLs containing @ or ?', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('not-a-url@with-creds')).toBe(''); + expect(redactUrlForLog('not-a-url?with-query')).toBe(''); + }); + + it('passes through safe non-URL strings', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect(redactUrlForLog('just-a-string')).toBe('just-a-string'); + }); +}); + // These end-to-end installs spawn child processes via execFileSync; // the default 5s vitest timeout is too tight on slow CI runners even // without Windows' cmd.exe + node.exe startup overhead. diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index 44235105a36..e645d949ae7 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -307,10 +307,13 @@ function redactUrlForLog(url) { const parsed = new URL(url); parsed.username = ''; parsed.password = ''; + parsed.search = ''; return parsed.toString(); } catch { const value = String(url); - return value.includes('@') ? '' : value; + return value.includes('@') || value.includes('?') + ? '' + : value; } } @@ -324,6 +327,9 @@ function standaloneArchiveName(qwenTarget) { function isPrivateOrReservedHost(hostname) { const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (!normalized) { + return true; + } if (normalized === 'localhost' || normalized.endsWith('.localhost')) { return true; } @@ -382,7 +388,7 @@ function isPrivateOrReservedIpv4(value) { } function ipv4FromMappedIpv6(value) { - const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/); + const match = value.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i); if (!match) { return null; } @@ -392,16 +398,21 @@ function ipv4FromMappedIpv6(value) { return suffix; } + // Node.js normalizes IPv4-mapped IPv6 to hex form. Handle both 2-part + // (::ffff:7f00:1) and 3-part (::ffff:0:7f00:1) representations. const hexParts = suffix.split(':'); if ( - hexParts.length !== 2 || - !hexParts.every((part) => /^[0-9a-f]{1,4}$/.test(part)) + (hexParts.length !== 2 && hexParts.length !== 3) || + !hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part)) ) { return null; } - const high = Number.parseInt(hexParts[0], 16); - const low = Number.parseInt(hexParts[1], 16); + // For 3 parts like "0:7f00:1", skip the leading zero segment + const relevantParts = + hexParts.length === 3 ? hexParts.slice(-2) : hexParts; + const high = Number.parseInt(relevantParts[0], 16); + const low = Number.parseInt(relevantParts[1], 16); return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; } @@ -424,6 +435,8 @@ function isPrivateOrReservedIpv6(value) { export { EXPECTED_STANDALONE_ARCHIVE_NAMES, EXPECTED_RELEASE_ASSET_NAMES, + isPrivateOrReservedHost, + redactUrlForLog, releaseAssetPaths, verifyReleaseBaseUrl, verifyReleaseDirectory, From 4a57ea3e69023c6268b30fda2606b347f8a648a6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 26 May 2026 15:18:18 +0800 Subject: [PATCH 13/28] fix(installer): close release validation review gaps --- .../installation/install-qwen-standalone.bat | 6 +- .../installation/install-qwen-standalone.sh | 98 +++++------- .../installation/install-qwen-with-source.bat | 10 +- scripts/tests/install-script.test.js | 139 +++++++++++++++++- scripts/verify-installation-release.js | 30 +++- 5 files changed, 207 insertions(+), 76 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 236cc768810..fd4fd54c615 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -443,11 +443,11 @@ rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 @@ -1068,7 +1068,7 @@ if %PS_STATUS% EQU 2 ( exit /b 1 ) if %PS_STATUS% EQU 3 ( - echo ERROR: Archive is empty: !ARCHIVE_FILE! + echo ERROR: Archive is empty: %~1 exit /b 1 ) echo ERROR: Archive validation failed before extraction. diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index d09d9c6696e..79881202e11 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -29,11 +29,21 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' -BRAND_BLUE='\033[38;2;71;150;228m' -BRAND_PURPLE='\033[38;2;132;122;206m' MUTED='\033[0;2m' NC='\033[0m' +supports_truecolor() { + [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] +} + +if supports_truecolor; then + BRAND_BLUE='\033[38;2;71;150;228m' + BRAND_PURPLE='\033[38;2;132;122;206m' +else + BRAND_BLUE='\033[38;5;68m' + BRAND_PURPLE='\033[38;5;140m' +fi + log_info() { printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" } @@ -55,6 +65,7 @@ command_exists() { } TEMP_DIRS=() +ACTIVE_DOWNLOAD_PID="" cleanup_temp_dirs() { local temp_dir @@ -70,6 +81,17 @@ register_temp_dir() { TEMP_DIRS+=("${temp_dir}") } +restore_cursor() { + printf "\033[?25h" +} + +kill_active_download() { + if [[ -n "${ACTIVE_DOWNLOAD_PID}" ]]; then + kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true + ACTIVE_DOWNLOAD_PID="" + fi +} + shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } @@ -84,8 +106,8 @@ display_install_version() { } trap cleanup_temp_dirs EXIT -trap 'printf "\033[?25h" 2>/dev/null; cleanup_temp_dirs; exit 130' INT -trap 'printf "\033[?25h" 2>/dev/null; cleanup_temp_dirs; exit 143' TERM +trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT +trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM print_usage() { cat </dev/null || return 0 - local width=50 - local percent=$(( bytes * 100 / length )) - [ "$percent" -gt 100 ] && percent=100 - local on=$(( percent * width / 100 )) - local off=$(( width - on )) - local filled=$(printf "%*s" "$on" "") - filled=${filled// /■} - local empty=$(printf "%*s" "$off" "") - empty=${empty// /・} - printf "\r${BRAND_PURPLE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4 -} - download_with_progress() { local url="$1" local output="$2" - if ! [ -t 2 ] || ! command_exists curl || ! command_exists mkfifo; then + if ! command_exists curl; then download_file_simple "$url" "$output" return $? fi - exec 4>&2 - local tmp_dir="${TMPDIR:-/tmp}" - local basename="${tmp_dir}/qwen_install_$$" - local tracefile="${basename}.trace" - rm -f "$tracefile" - mkfifo "$tracefile" 2>/dev/null || { - download_file_simple "$url" "$output" - return $? - } - - printf "\033[?25l" >&4 - trap 'rm -f "$tracefile"; printf "\033[?25h" >&4' RETURN - - ( curl --trace-ascii "$tracefile" -f -s -L --retry 2 --connect-timeout 15 --max-time 300 -o "$output" "$url" ) & - local curl_pid=$! - - local content_length=0 - local received=0 - - while IFS= read -r line; do - if [[ "$line" == *"Content-Length:"* ]] && [ "$content_length" -eq 0 ] 2>/dev/null; then - content_length=$(echo "$line" | grep -oi 'Content-Length: [0-9]*' | grep -o '[0-9]*' | tail -1) - content_length=${content_length:-0} - fi - if [[ "$line" == *"<= Recv data,"* ]]; then - local chunk_size - chunk_size=$(echo "$line" | grep -o '[0-9]* bytes' | grep -o '[0-9]*') - if [ -n "$chunk_size" ]; then - received=$(( received + chunk_size )) - print_progress "$received" "$content_length" - fi - fi - done < "$tracefile" - - wait $curl_pid + curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "$url" -o "$output" & + ACTIVE_DOWNLOAD_PID=$! + wait "${ACTIVE_DOWNLOAD_PID}" local exit_code=$? - - printf "\r%*s\r" 60 "" >&4 - - rm -f "$tracefile" - printf "\033[?25h" >&4 - trap - RETURN - exec 4>&- + ACTIVE_DOWNLOAD_PID="" return $exit_code } @@ -1409,6 +1377,10 @@ gradient_line() { local r3=$8 g3=$9 b3=${10} local len=${#text} [ "$len" -eq 0 ] && return + if ! supports_truecolor; then + printf "%b%s%b\n" "${BRAND_PURPLE}" "${text}" "${NC}" + return + fi local i=0 local half=$(( len / 2 )) while [ $i -lt $len ]; do diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index efcc152a80b..001ef088628 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -310,11 +310,11 @@ rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$" >nul +echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul if %ERRORLEVEL% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 @@ -640,7 +640,7 @@ set "QWEN_ARCHIVE_FILE=%~1" REM Enumerate archive entries and reject any with path traversal indicators: REM empty names, leading '/', drive-rooted paths, '..' segments, or control chars. REM This prevents Zip Slip attacks before extraction. -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); if ($archive.Entries.Count -eq 0) { [Console]::Error.WriteLine('Archive is empty: ' + $env:QWEN_ARCHIVE_FILE); exit 3 }; foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_ARCHIVE_FILE=" if %PS_STATUS% EQU 0 exit /b 0 @@ -652,6 +652,10 @@ if %PS_STATUS% EQU 2 ( echo ERROR: Archive could not be inspected before extraction. exit /b 1 ) +if %PS_STATUS% EQU 3 ( + echo ERROR: Archive is empty: %~1 + exit /b 1 +) echo ERROR: Archive validation failed before extraction. exit /b %PS_STATUS% diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 00bc252f767..5fd64191a8f 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -71,6 +71,8 @@ describe('installation scripts', () => { expect(script).toContain('@qwen-code/qwen-code@latest'); expect(script).toContain('Installing Qwen Code version:'); expect(script).toContain('print_logo'); + expect(script).toContain('supports_truecolor()'); + expect(script).toContain('COLORTERM'); expect(script).toContain('installed successfully.'); expect(script).toContain('To start:'); expect(script).toContain('Installed to:'); @@ -139,6 +141,9 @@ describe('installation scripts', () => { expect(script).toContain( 'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"', ); + expect(script).not.toContain('--trace-ascii'); + expect(script).not.toContain('mkfifo'); + expect(script).not.toContain('qwen_install_$$'); expect(script).toContain( 'curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}"', ); @@ -177,6 +182,11 @@ describe('installation scripts', () => { expect(script).toContain( 'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"', ); + expect(script).toContain('ACTIVE_DOWNLOAD_PID=""'); + expect(script).toContain('restore_cursor >&2'); + expect(script).toContain( + 'kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true', + ); expect(script).not.toContain( 'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"', ); @@ -236,7 +246,7 @@ describe('installation scripts', () => { 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', ); expect(script).toContain( - 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[.-][A-Za-z0-9.-]*$"', + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$"', ); expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', @@ -317,6 +327,7 @@ describe('installation scripts', () => { expect(script).toContain('Falling back to npm installation'); expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); expect(script).toContain('if !STANDALONE_STATUS! EQU 2'); + expect(script).toContain('Archive is empty: %~1'); expect(script).toContain('set "ARG_KEY=%~1"'); expect(script).toContain('set "ARG_HAS_INLINE_VALUE=0"'); expect(script).toContain('if "!ARG_HAS_INLINE_VALUE!"=="1"'); @@ -1210,6 +1221,67 @@ describe('standalone release packaging', () => { } }); + it('rejects unexpected files and non-file release assets', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeFileSync(path.join(tmpDir, '.DS_Store'), ''); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected file\(s\) in release directory: \.DS_Store/, + ); + + rmSync(path.join(tmpDir, '.DS_Store')); + rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + mkdirSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Release asset is not a regular file: qwen-code-/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnUnix('rejects symlinked release assets and checksum files', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + let linkedAsset = ''; + let linkedChecksums = ''; + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + const assetPath = path.join(tmpDir, assetName); + linkedAsset = path.join(tmpDir, '..', `${assetName}.linked`); + writeFileSync(linkedAsset, `${assetName}\n`); + rmSync(assetPath); + symlinkSync(linkedAsset, assetPath); + + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Release asset is not a regular file: qwen-code-/, + ); + + rmSync(assetPath); + writeFileSync(assetPath, `${assetName}\n`); + const checksumPath = path.join(tmpDir, 'SHA256SUMS'); + linkedChecksums = path.join(tmpDir, '..', 'SHA256SUMS.linked'); + writeFileSync(linkedChecksums, readScript(checksumPath)); + rmSync(checksumPath); + symlinkSync(linkedChecksums, checksumPath); + + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /SHA256SUMS is not a regular file/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (linkedAsset) rmSync(linkedAsset, { force: true }); + if (linkedChecksums) rmSync(linkedChecksums, { force: true }); + } + }); + it('verifies release asset URLs from SHA256SUMS', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1295,6 +1367,28 @@ describe('standalone release packaging', () => { ).rejects.toThrow(/--base-url must use https/); }); + it('does not follow remote release redirects', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + const fetchedOptions = []; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (_url, options = {}) => { + fetchedOptions.push(options); + return new Response(null, { + status: 302, + headers: { Location: 'https://169.254.169.254/latest/meta-data/' }, + }); + }, + }), + ).rejects.toThrow(/Redirect responses are not allowed/); + + expect(fetchedOptions).toHaveLength(1); + expect(fetchedOptions[0].redirect).toBe('manual'); + }); + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1920,6 +2014,13 @@ describe('isPrivateOrReservedHost', () => { expect(isPrivateOrReservedHost('::ffff:0:c0a8:101')).toBe(true); }); + it('does not collapse nonzero 3-part IPv4-mapped IPv6 prefixes', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('::ffff:abcd:7f00:1')).toBe(false); + }); + it('allows public IP addresses', async () => { const { isPrivateOrReservedHost } = await import( installationReleaseVerificationScriptUrl @@ -1951,12 +2052,22 @@ describe('redactUrlForLog', () => { ).toBe('https://example.com/path'); }); - it('redacts malformed URLs containing @ or ?', async () => { + it('strips URL fragments to prevent credential leakage', async () => { + const { redactUrlForLog } = await import( + installationReleaseVerificationScriptUrl + ); + expect( + redactUrlForLog('https://example.com/path#access_token=secret'), + ).toBe('https://example.com/path'); + }); + + it('redacts malformed URLs containing @, ?, or #', async () => { const { redactUrlForLog } = await import( installationReleaseVerificationScriptUrl ); expect(redactUrlForLog('not-a-url@with-creds')).toBe(''); expect(redactUrlForLog('not-a-url?with-query')).toBe(''); + expect(redactUrlForLog('not-a-url#with-fragment')).toBe(''); }); it('passes through safe non-URL strings', async () => { @@ -2740,6 +2851,30 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { } }); + itOnUnix('rejects empty standalone archives', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = path.join(tmpDir, 'qwen-code-linux-x64.tar.gz'); + execFileSync('tar', ['-czf', archive, '-T', '/dev/null'], { + stdio: 'ignore', + }); + writeChecksumFile(tmpDir, path.basename(archive)); + + expect(() => + runUnixInstaller( + archive, + path.join(tmpDir, 'install'), + path.join(tmpDir, 'home'), + ), + ).toThrow(/Archive is empty/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + itOnUnix( 'rejects standalone archives containing path traversal entries', () => { diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index e645d949ae7..ab575497d95 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -132,8 +132,8 @@ async function verifyReleaseDirectory(dir, options = {}) { if (!fs.existsSync(assetPath)) { fail(`Missing release asset: ${assetName}`); } - if (!fs.statSync(assetPath).isFile()) { - fail(`Release asset is not a file: ${assetName}`); + if (!fs.lstatSync(assetPath).isFile()) { + fail(`Release asset is not a regular file: ${assetName}`); } const actual = await sha256File(assetPath); const expected = checksums.get(assetName); @@ -171,6 +171,9 @@ function readReleaseChecksums(dir) { if (!fs.existsSync(checksumPath)) { fail(`SHA256SUMS was not found at ${checksumPath}`); } + if (!fs.lstatSync(checksumPath).isFile()) { + fail('SHA256SUMS is not a regular file'); + } return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); } @@ -241,6 +244,7 @@ async function assertRemoteAssetChecksums( async function fetchSha256(url, fetchImpl) { const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); + assertNotRedirectResponse(response, displayUrl); if (!response.ok) { fail( `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, @@ -265,6 +269,7 @@ function formatErrorReason(reason) { async function fetchText(url, fetchImpl) { const displayUrl = redactUrlForLog(url); const response = await fetchWithTimeout(fetchImpl, url); + assertNotRedirectResponse(response, displayUrl); if (!response.ok) { fail( `Failed to download ${displayUrl}: ${response.status} ${response.statusText}`, @@ -276,10 +281,17 @@ async function fetchText(url, fetchImpl) { function fetchWithTimeout(fetchImpl, url, options = {}) { return fetchImpl(url, { ...options, + redirect: 'manual', signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), }); } +function assertNotRedirectResponse(response, displayUrl) { + if (response.status >= 300 && response.status < 400) { + fail(`Redirect responses are not allowed: ${displayUrl}`); + } +} + function normalizeHttpsBaseUrl(baseUrl) { let parsed; try { @@ -308,10 +320,11 @@ function redactUrlForLog(url) { parsed.username = ''; parsed.password = ''; parsed.search = ''; + parsed.hash = ''; return parsed.toString(); } catch { const value = String(url); - return value.includes('@') || value.includes('?') + return value.includes('@') || value.includes('?') || value.includes('#') ? '' : value; } @@ -408,9 +421,16 @@ function ipv4FromMappedIpv6(value) { return null; } - // For 3 parts like "0:7f00:1", skip the leading zero segment + // For 3 parts like "0:7f00:1", skip the leading zero segment. const relevantParts = - hexParts.length === 3 ? hexParts.slice(-2) : hexParts; + hexParts.length === 3 + ? hexParts[0] === '0' + ? hexParts.slice(-2) + : null + : hexParts; + if (!relevantParts) { + return null; + } const high = Number.parseInt(relevantParts[0], 16); const low = Number.parseInt(relevantParts[1], 16); return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; From b268a403925a937668e8fee08f8d5c3d94ccaf9b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 26 May 2026 15:42:13 +0800 Subject: [PATCH 14/28] test(installer): cover shadowed qwen installs --- scripts/tests/install-script.test.js | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 5fd64191a8f..1dac41ff144 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -344,6 +344,10 @@ describe('installation scripts', () => { expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('unsafe path with control character'); expect(script).toContain('Failed to update user PATH'); + expect(script).toContain('PRE_INSTALL_QWENS_LIST'); + expect(script).toContain("Other 'qwen' executables exist"); + expect(script).toContain('restart your command prompt'); + expect(script).toContain('Or invoke directly: "!INSTALLED_BIN!"'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); @@ -2450,6 +2454,70 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); + itOnUnix( + 'warns when an existing qwen could shadow the standalone install', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'old-bin'); + const existingQwen = path.join(fakeBin, 'qwen'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n'); + chmodSync(existingQwen, 0o755); + + const output = runUnixInstaller( + archive, + installRoot, + home, + 'standalone', + { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + ).toString(); + + const installedBin = path.join(installRoot, 'bin', 'qwen'); + const bashrc = readScript(path.join(home, '.bashrc')); + + expect(output).toContain("Other 'qwen' executables exist"); + expect(output).toContain(existingQwen); + expect(output).toContain( + 'To make this install take priority, restart your terminal.', + ); + expect(output).toContain(`Or invoke directly: ${installedBin}`); + expect(bashrc).toContain('# Qwen Code PATH block begin'); + expect(bashrc).toContain( + `export PATH='${path.join(installRoot, 'bin')}':$PATH`, + ); + + const resolvedQwen = execFileSync( + 'bash', + ['-c', 'source "${HOME}/.bashrc"; command -v qwen'], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + }, + ) + .toString() + .trim(); + expect(resolvedQwen).toBe(installedBin); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + itOnUnix( 'removes installer-owned shell rc PATH blocks even when extra lines are inserted', () => { From 59680ea66f4bd0c18d49153b26c82134ddce99af Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 27 May 2026 16:10:49 +0800 Subject: [PATCH 15/28] fix(installer): avoid npm auto-update for standalone installs --- docs/users/support/troubleshooting.md | 3 +- .../cli/src/utils/installationInfo.test.ts | 34 ++++++++ packages/cli/src/utils/installationInfo.ts | 78 +++++++++++++++++++ .../installation/install-qwen-standalone.bat | 15 ++-- .../installation/install-qwen-with-source.bat | 15 ++-- scripts/tests/install-script.test.js | 57 +++++++++++++- scripts/verify-installation-release.js | 5 +- 7 files changed, 181 insertions(+), 26 deletions(-) diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index bcaa97df147..1b17c46690a 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -37,7 +37,7 @@ This guide provides solutions to common issues and debugging tips, including top ## Frequently asked questions (FAQs) - **Q: How do I update Qwen Code to the latest version?** - - A: If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. + - A: If you installed Qwen Code with the standalone installer, rerun the standalone install command. If you installed it globally via `npm`, update it using the command `npm install -g @qwen-code/qwen-code@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. - **Q: Where are the Qwen Code configuration or settings files stored?** - A: The Qwen Code configuration is stored in two `settings.json` files: @@ -60,6 +60,7 @@ This guide provides solutions to common issues and debugging tips, including top - **Cause:** The CLI is not correctly installed or it is not in your system's `PATH`. - **Solution:** The update depends on how you installed Qwen Code: + - If you installed `qwen` with the standalone installer, rerun the standalone install command and then open a new terminal. - If you installed `qwen` globally, check that your `npm` global binary directory is in your `PATH`. You can update using the command `npm install -g @qwen-code/qwen-code@latest`. - If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g. `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`. diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 1da119db719..70a3ee9f7ef 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -26,6 +26,7 @@ vi.mock('fs', async (importOriginal) => { ...actualFs, realpathSync: vi.fn(), existsSync: vi.fn(), + readFileSync: vi.fn(), }; }); @@ -40,6 +41,7 @@ vi.mock('child_process', async (importOriginal) => { const mockedIsGitRepository = vi.mocked(isGitRepository); const mockedRealPathSync = vi.mocked(fs.realpathSync); const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedReadFileSync = vi.mocked(fs.readFileSync); const mockedExecSync = vi.mocked(childProcess.execSync); describe('getInstallationInfo', () => { @@ -130,6 +132,38 @@ describe('getInstallationInfo', () => { expect(info.updateMessage).toBe('Running via bunx, update not applicable.'); }); + it('should detect standalone installs and avoid npm auto-update', () => { + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'manifest.json')) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBeUndefined(); + expect(info.updateMessage).toContain('Standalone install detected'); + expect(info.updateMessage).toContain('install-qwen-standalone.sh'); + expect(info.updateMessage).not.toContain('npm install'); + }); + it('should detect Homebrew installation via execSync', () => { Object.defineProperty(process, 'platform', { value: 'darwin', diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 6eb39b0540b..e8fcab047fb 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -17,11 +17,16 @@ export enum PackageManager { BUN = 'bun', BUNX = 'bunx', HOMEBREW = 'homebrew', + STANDALONE = 'standalone', NPX = 'npx', UNKNOWN = 'unknown', } const debugLogger = createDebugLogger('INSTALLATION_INFO'); +const STANDALONE_UNIX_INSTALLER = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh'; +const STANDALONE_WINDOWS_INSTALLER = + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1'; export interface InstallationInfo { packageManager: PackageManager; @@ -76,6 +81,14 @@ export function getInstallationInfo( }; } + const standaloneInfo = getStandaloneInstallInfo( + realPath, + isAutoUpdateEnabled, + ); + if (standaloneInfo) { + return standaloneInfo; + } + // Check for Homebrew if (process.platform === 'darwin') { try { @@ -176,3 +189,68 @@ export function getInstallationInfo( return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; } } + +function getStandaloneInstallInfo( + realPath: string, + isAutoUpdateEnabled: boolean, +): InstallationInfo | null { + const installDir = standaloneInstallDirForCliPath(realPath); + if (!installDir || !isStandaloneInstallDir(installDir)) { + return null; + } + + const updateCommand = + process.platform === 'win32' + ? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"` + : `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`; + const updatePrefix = isAutoUpdateEnabled + ? 'Standalone install detected. Automatic in-place updates are not supported yet.' + : 'Standalone install detected.'; + + return { + packageManager: PackageManager.STANDALONE, + isGlobal: true, + updateMessage: `${updatePrefix} Please rerun the standalone installer to update: ${updateCommand}`, + }; +} + +function standaloneInstallDirForCliPath(realPath: string): string | null { + const suffix = '/lib/cli.js'; + if (!realPath.endsWith(suffix)) { + return null; + } + return realPath.slice(0, -suffix.length); +} + +function isStandaloneInstallDir(installDir: string): boolean { + try { + const manifestPath = path.join(installDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + return false; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + name?: unknown; + target?: unknown; + }; + if ( + manifest.name !== '@qwen-code/qwen-code' || + typeof manifest.target !== 'string' + ) { + return false; + } + + const qwenBin = + process.platform === 'win32' + ? path.join(installDir, 'bin', 'qwen.cmd') + : path.join(installDir, 'bin', 'qwen'); + const nodeBin = + process.platform === 'win32' + ? path.join(installDir, 'node', 'node.exe') + : path.join(installDir, 'node', 'bin', 'node'); + + return fs.existsSync(qwenBin) && fs.existsSync(nodeBin); + } catch { + return false; + } +} diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index fd4fd54c615..8d877d81cc4 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -439,16 +439,11 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). -rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 +set "QWEN_VERSION_VALUE=!VERSION!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { exit 0 }; exit 1" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VERSION_VALUE=" +if %PS_STATUS% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index 001ef088628..46c794f28cc 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -306,16 +306,11 @@ exit /b 1 :ValidateVersion if /i "!VERSION!"=="latest" exit /b 0 -rem Accept bare semver (1.2.3) or with v prefix (v1.2.3). -rem Optional pre-release/build suffix must start with '.' or '-' to match the shell installer. -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 -echo(!VERSION!| findstr /R /C:"^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$" >nul -if %ERRORLEVEL% EQU 0 exit /b 0 +set "QWEN_VERSION_VALUE=!VERSION!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { exit 0 }; exit 1" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VERSION_VALUE=" +if %PS_STATUS% EQU 0 exit /b 0 echo ERROR: --version must be 'latest' or a semver string. exit /b 1 diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 1dac41ff144..e6b9c035ed9 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -242,15 +242,14 @@ describe('installation scripts', () => { expect(script).toContain( '[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE', ); + expect(script).toContain('set "QWEN_VERSION_VALUE=!VERSION!"'); expect(script).toContain( - 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', - ); - expect(script).toContain( - 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[.-][A-Za-z0-9][A-Za-z0-9.-]*$"', + "$value -match '^v?[0-9]+\\.[0-9]+\\.[0-9]+([.-][A-Za-z0-9]+)*$'", ); expect(script).not.toContain( 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*[A-Za-z0-9.-]*$"', ); + expect(script).not.toContain('[A-Za-z0-9][A-Za-z0-9.-]*$'); expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); @@ -1393,6 +1392,53 @@ describe('standalone release packaging', () => { expect(fetchedOptions[0].redirect).toBe('manual'); }); + it('does not follow remote archive body redirects', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const redirectedAsset = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + if (url.endsWith(`/${redirectedAsset}`)) { + return new Response(null, { + status: 302, + headers: { + Location: 'https://169.254.169.254/latest/meta-data/', + }, + }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow(/Redirect responses are not allowed/); + }); + + it('rejects private release base URLs at the verification entry point', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect( + verifyReleaseBaseUrl('https://127.0.0.1/releases/'), + ).rejects.toThrow(/must not target a private network/); + await expect( + verifyReleaseBaseUrl('https://169.254.169.254/latest/meta-data/'), + ).rejects.toThrow(/must not target a private network/); + await expect( + verifyReleaseBaseUrl('https://sub.localhost./releases/'), + ).rejects.toThrow(/must not target a private network/); + }); + it('downloads release archive bodies instead of relying on HEAD probes', async () => { const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = await import(installationReleaseVerificationScriptUrl); @@ -1978,6 +2024,8 @@ describe('isPrivateOrReservedHost', () => { ); expect(isPrivateOrReservedHost('localhost')).toBe(true); expect(isPrivateOrReservedHost('sub.localhost')).toBe(true); + expect(isPrivateOrReservedHost('localhost.')).toBe(true); + expect(isPrivateOrReservedHost('sub.localhost.')).toBe(true); expect(isPrivateOrReservedHost('LOCALHOST')).toBe(true); }); @@ -2032,6 +2080,7 @@ describe('isPrivateOrReservedHost', () => { expect(isPrivateOrReservedHost('8.8.8.8')).toBe(false); expect(isPrivateOrReservedHost('142.250.80.46')).toBe(false); expect(isPrivateOrReservedHost('example.com')).toBe(false); + expect(isPrivateOrReservedHost('example.com.')).toBe(false); }); }); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index ab575497d95..b8b2042e9f7 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -339,7 +339,10 @@ function standaloneArchiveName(qwenTarget) { } function isPrivateOrReservedHost(hostname) { - const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const normalized = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); if (!normalized) { return true; } From 74b3ea20b36bc4a8a4ff5fea9a440d46782ef27a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 27 May 2026 22:25:55 +0800 Subject: [PATCH 16/28] fix(installer): block IPv4-compatible IPv6 SSRF and harden archive validation [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ipv4FromCompatibleIpv6() to detect deprecated RFC 4291 §2.5.5.1 addresses (e.g. ::7f00:1 → 127.0.0.1) that bypass SSRF protection - Extend archive validation to reject hardlinks in addition to symlinks - Add signal trap suppression during critical mv swap to prevent partial-install state on Ctrl+C - Add diagnostic logging to silent catch in standalone detection --- packages/cli/src/utils/installationInfo.ts | 3 +- .../installation/install-qwen-standalone.sh | 15 +++++--- scripts/tests/install-script.test.js | 24 +++++++++++++ scripts/verify-installation-release.js | 36 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index e8fcab047fb..96c37ceb0a8 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -250,7 +250,8 @@ function isStandaloneInstallDir(installDir: string): boolean { : path.join(installDir, 'node', 'bin', 'node'); return fs.existsSync(qwenBin) && fs.existsSync(nodeBin); - } catch { + } catch (err) { + debugLogger.error('Standalone detection failed:', installDir, err); return false; } } diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 79881202e11..691c00fce56 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -933,7 +933,7 @@ validate_archive_entry_path() { esac } -archive_contains_symlinks() { +archive_contains_symlinks_or_hardlinks() { local archive_path="$1" case "${archive_path}" in @@ -941,7 +941,7 @@ archive_contains_symlinks() { unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null ;; *.tar.gz|*.tgz|*.tar.xz) - tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^l/ { found=1 } END { exit found ? 0 : 1 }' + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }' ;; *) return 1 @@ -982,8 +982,8 @@ validate_archive_contents() { return 1 fi - if archive_contains_symlinks "${archive_path}"; then - log_error "Archive contains symlinks; refusing to install." + if archive_contains_symlinks_or_hardlinks "${archive_path}"; then + log_error "Archive contains symlinks or hardlinks; refusing to install." return 1 fi @@ -1284,6 +1284,9 @@ install_standalone() { return 1 fi + # Suppress INT/TERM during the critical mv swap to avoid leaving + # INSTALL_LIB_DIR absent if the user presses Ctrl+C between the two moves. + trap '' INT TERM if [[ -e "${INSTALL_LIB_DIR}" ]]; then mv "${INSTALL_LIB_DIR}" "${old_install_dir}" fi @@ -1292,10 +1295,14 @@ install_standalone() { if [[ -e "${old_install_dir}" ]]; then mv "${old_install_dir}" "${INSTALL_LIB_DIR}" fi + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM rm -rf "${temp_dir}" "${wrapper_tmp}" log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}." return 1 fi + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT + trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index e6b9c035ed9..16efdff8a79 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -1437,6 +1437,14 @@ describe('standalone release packaging', () => { await expect( verifyReleaseBaseUrl('https://sub.localhost./releases/'), ).rejects.toThrow(/must not target a private network/); + // IPv4-mapped IPv6 + await expect( + verifyReleaseBaseUrl('https://[::ffff:127.0.0.1]/releases/'), + ).rejects.toThrow(/must not target a private network/); + // IPv4-compatible IPv6 + await expect( + verifyReleaseBaseUrl('https://[::7f00:1]/releases/'), + ).rejects.toThrow(/must not target a private network/); }); it('downloads release archive bodies instead of relying on HEAD probes', async () => { @@ -2073,6 +2081,20 @@ describe('isPrivateOrReservedHost', () => { expect(isPrivateOrReservedHost('::ffff:abcd:7f00:1')).toBe(false); }); + it('blocks IPv4-compatible IPv6 addresses (deprecated but parseable)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + // ::7f00:1 → 127.0.0.1 (loopback) + expect(isPrivateOrReservedHost('::7f00:1')).toBe(true); + // ::a9fe:a9fe → 169.254.169.254 (cloud metadata) + expect(isPrivateOrReservedHost('::a9fe:a9fe')).toBe(true); + // ::a00:1 → 10.0.0.1 (private) + expect(isPrivateOrReservedHost('::a00:1')).toBe(true); + // ::c0a8:101 → 192.168.1.1 (private) + expect(isPrivateOrReservedHost('::c0a8:101')).toBe(true); + }); + it('allows public IP addresses', async () => { const { isPrivateOrReservedHost } = await import( installationReleaseVerificationScriptUrl @@ -2081,6 +2103,8 @@ describe('isPrivateOrReservedHost', () => { expect(isPrivateOrReservedHost('142.250.80.46')).toBe(false); expect(isPrivateOrReservedHost('example.com')).toBe(false); expect(isPrivateOrReservedHost('example.com.')).toBe(false); + // Public IPv6 + expect(isPrivateOrReservedHost('2607:f8b0:4004:800::200e')).toBe(false); }); }); diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js index b8b2042e9f7..75d3b9446c5 100644 --- a/scripts/verify-installation-release.js +++ b/scripts/verify-installation-release.js @@ -363,6 +363,12 @@ function isPrivateOrReservedHost(hostname) { return false; } + // IPv4-compatible IPv6 (deprecated RFC 4291 §2.5.5.1): ::x.x.x.x or ::HHHH:HHHH + const compatIpv4 = ipv4FromCompatibleIpv6(normalized); + if (compatIpv4) { + return isPrivateOrReservedIpv4(compatIpv4); + } + return isPrivateOrReservedIpv6(normalized); } @@ -439,6 +445,36 @@ function ipv4FromMappedIpv6(value) { return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; } +// Detect IPv4-compatible IPv6 addresses (::x.x.x.x or ::HHHH:HHHH form). +// These are deprecated (RFC 4291) but Node.js URL parser still accepts them. +function ipv4FromCompatibleIpv6(value) { + // Must start with :: but NOT ::ffff: (already handled by ipv4FromMappedIpv6) + if (!value.startsWith('::') || /^::ffff:/i.test(value)) { + return null; + } + const suffix = value.slice(2); + if (!suffix || suffix.startsWith(':')) { + return null; + } + + // Dotted-quad form: ::169.254.169.254 + if (parseIpv4Octets(suffix)) { + return suffix; + } + + // Hex form: ::a9fe:a9fe (two hex groups encoding 4 IPv4 octets) + const hexParts = suffix.split(':'); + if ( + hexParts.length !== 2 || + !hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part)) + ) { + return null; + } + const high = Number.parseInt(hexParts[0], 16); + const low = Number.parseInt(hexParts[1], 16); + return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`; +} + function isPrivateOrReservedIpv6(value) { if (value === '::' || value === '::1' || value === '0:0:0:0:0:0:0:1') { return true; From 54d73978b344bc9e53d1a29ee65b33328352b2d1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 27 May 2026 22:48:33 +0800 Subject: [PATCH 17/28] fix(installer): finish standalone install follow-ups --- .../cli/src/utils/installationInfo.test.ts | 182 +++++++++++++++++- packages/cli/src/utils/installationInfo.ts | 32 ++- .../installation/install-qwen-standalone.bat | 21 +- .../installation/install-qwen-standalone.sh | 47 ++++- scripts/tests/install-script.test.js | 123 +++++++++++- 5 files changed, 388 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 70a3ee9f7ef..8115f3b48bd 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -26,6 +26,7 @@ vi.mock('fs', async (importOriginal) => { ...actualFs, realpathSync: vi.fn(), existsSync: vi.fn(), + lstatSync: vi.fn(), readFileSync: vi.fn(), }; }); @@ -41,22 +42,49 @@ vi.mock('child_process', async (importOriginal) => { const mockedIsGitRepository = vi.mocked(isGitRepository); const mockedRealPathSync = vi.mocked(fs.realpathSync); const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedLstatSync = vi.mocked(fs.lstatSync); const mockedReadFileSync = vi.mocked(fs.readFileSync); const mockedExecSync = vi.mocked(childProcess.execSync); describe('getInstallationInfo', () => { const projectRoot = '/path/to/project'; let originalArgv: string[]; + let originalPlatform: PropertyDescriptor | undefined; + + const setPlatform = (platform: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + }; + + const fileStats = (mode = 0o755): fs.Stats => + ({ + isFile: () => true, + isSymbolicLink: () => false, + mode, + }) as fs.Stats; + + const symlinkStats = (): fs.Stats => + ({ + isFile: () => true, + isSymbolicLink: () => true, + mode: 0o755, + }) as fs.Stats; beforeEach(() => { vi.resetAllMocks(); originalArgv = [...process.argv]; + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); // Mock process.cwd() for isGitRepository vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); }); afterEach(() => { process.argv = originalArgv; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } }); it('should return UNKNOWN when cliPath is not available', () => { @@ -133,6 +161,7 @@ describe('getInstallationInfo', () => { }); it('should detect standalone installs and avoid npm auto-update', () => { + setPlatform('linux'); const installDir = '/Users/test/.local/lib/qwen-code'; const cliPath = `${installDir}/lib/cli.js`; process.argv[1] = cliPath; @@ -153,6 +182,17 @@ describe('getInstallationInfo', () => { } throw new Error(`Unexpected read: ${candidate}`); }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [ + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)) + ) { + return fileStats(); + } + throw new Error(`Unexpected lstat: ${candidate}`); + }); const info = getInstallationInfo(projectRoot, true); @@ -164,10 +204,142 @@ describe('getInstallationInfo', () => { expect(info.updateMessage).not.toContain('npm install'); }); - it('should detect Homebrew installation via execSync', () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', + it('should detect Windows standalone installs and avoid npm auto-update', () => { + setPlatform('win32'); + const installDir = 'C:/Users/test/AppData/Local/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen.cmd'), + path.join(installDir, 'node', 'node.exe'), + ].includes(String(candidate).replace(/\\/g, '/')), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if ( + String(candidate).replace(/\\/g, '/') === `${installDir}/manifest.json` + ) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'win-x64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [`${installDir}/bin/qwen.cmd`, `${installDir}/node/node.exe`].includes( + String(candidate).replace(/\\/g, '/'), + ) + ) { + return fileStats(0o644); + } + throw new Error(`Unexpected lstat: ${candidate}`); }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.updateCommand).toBeUndefined(); + expect(info.updateMessage).toContain('install-qwen-standalone.ps1'); + expect(info.updateMessage).not.toContain('npm install'); + }); + + it('should ignore standalone-like installs for the wrong target', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'win-x64', + }), + ); + mockedLstatSync.mockReturnValue(fileStats()); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.updateCommand).toBe( + 'npm install -g @qwen-code/qwen-code@latest', + ); + }); + + it('should ignore standalone-like installs with symlinked runtime files', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }), + ); + mockedLstatSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'bin', 'qwen')) { + return symlinkStats(); + } + return fileStats(); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + }); + + it('should ignore Unix standalone-like installs with non-executable runtime files', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'linux-x64', + }), + ); + mockedLstatSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'bin', 'qwen')) { + return fileStats(0o644); + } + return fileStats(); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + }); + + it('should detect Homebrew installation via execSync', () => { + setPlatform('darwin'); const cliPath = '/usr/local/bin/gemini'; process.argv[1] = cliPath; mockedRealPathSync.mockReturnValue(cliPath); @@ -185,9 +357,7 @@ describe('getInstallationInfo', () => { }); it('should fall through if brew command fails', () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); + setPlatform('darwin'); const cliPath = '/usr/local/bin/gemini'; process.argv[1] = cliPath; mockedRealPathSync.mockReturnValue(cliPath); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 96c37ceb0a8..4f0562655fd 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -233,9 +233,11 @@ function isStandaloneInstallDir(installDir: string): boolean { name?: unknown; target?: unknown; }; + // Manifest format is produced by writeManifest in create-standalone-package.js. if ( manifest.name !== '@qwen-code/qwen-code' || - typeof manifest.target !== 'string' + typeof manifest.target !== 'string' || + !isStandaloneTargetForCurrentPlatform(manifest.target) ) { return false; } @@ -249,9 +251,35 @@ function isStandaloneInstallDir(installDir: string): boolean { ? path.join(installDir, 'node', 'node.exe') : path.join(installDir, 'node', 'bin', 'node'); - return fs.existsSync(qwenBin) && fs.existsSync(nodeBin); + return ( + fs.existsSync(qwenBin) && + fs.existsSync(nodeBin) && + isStandaloneRuntimeFile(qwenBin) && + isStandaloneRuntimeFile(nodeBin) + ); } catch (err) { debugLogger.error('Standalone detection failed:', installDir, err); return false; } } + +function isStandaloneTargetForCurrentPlatform(target: string): boolean { + switch (process.platform) { + case 'darwin': + return /^darwin-(arm64|x64)$/.test(target); + case 'linux': + return /^linux-(arm64|x64)$/.test(target); + case 'win32': + return /^win-(arm64|x64)$/.test(target); + default: + return false; + } +} + +function isStandaloneRuntimeFile(filePath: string): boolean { + const stats = fs.lstatSync(filePath); + if (!stats.isFile() || stats.isSymbolicLink()) { + return false; + } + return process.platform === 'win32' || (stats.mode & 0o111) !== 0; +} diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 8d877d81cc4..03c09ba7efc 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -592,7 +592,7 @@ rem already on the user PATH. Uses PowerShell rather than `setx` because setx rem truncates PATH at 1024 chars, which can silently mangle long PATHs. set "QWEN_NEW_BIN=%~1" if "!QWEN_NEW_BIN!"=="" exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = $userPath -split ';' | Where-Object { $_ -ne '' }; if ($entries -contains $bin) { Write-Output ('INFO: User PATH already contains ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $entries) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); Write-Output ('SUCCESS: Prepended ' + $bin + ' to your user PATH.'); Write-Output 'INFO: Open a NEW command prompt for the change to take effect.'" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { Write-Output ('INFO: User PATH already starts with ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $remaining) -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% @@ -1236,6 +1236,7 @@ set "EXTRA_BIN=%~1" set "SUMMARY_INSTALL_DIR=%~2" set "SUMMARY_INSTALL_METHOD=%~3" set "STANDALONE_UNINSTALL_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1" +set "PATH_UPDATE_APPLIED=0" if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" set "INSTALLED_BIN=" @@ -1302,6 +1303,8 @@ if not "!EXTRA_BIN!"=="" if /i not "!NO_MODIFY_PATH!"=="1" ( if !ERRORLEVEL! NEQ 0 ( echo WARNING: Failed to update user PATH. Add the directory manually: echo !EXTRA_BIN! + ) else ( + set "PATH_UPDATE_APPLIED=1" ) ) @@ -1314,7 +1317,21 @@ if defined OTHER_QWENS ( if not "!OQ!"=="" echo WARNING: !OQ! ) echo. - echo To make this install take priority, restart your command prompt. + if /i "!SUMMARY_INSTALL_METHOD!"=="standalone" ( + echo Existing npm or package-manager installs are left unchanged. + if "!PATH_UPDATE_APPLIED!"=="1" ( + echo This standalone install is configured as the preferred qwen for new command prompt or PowerShell sessions. + echo Check active command with: where qwen + ) else ( + echo This standalone install was not added to your user PATH automatically. + echo Add this directory before older qwen commands in PATH to make it the default: + echo !EXTRA_BIN! + ) + echo Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code + echo To keep using npm, rerun this installer with --method npm. + echo. + ) + if "!PATH_UPDATE_APPLIED!"=="1" echo To make this install take priority, restart your command prompt. echo Or invoke directly: "!INSTALLED_BIN!" exit /b 0 ) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 691c00fce56..c82e39ca7fb 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -66,6 +66,7 @@ command_exists() { TEMP_DIRS=() ACTIVE_DOWNLOAD_PID="" +PATH_UPDATE_APPLIED=0 cleanup_temp_dirs() { local temp_dir @@ -556,8 +557,14 @@ maybe_update_shell_path() { fi if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then - log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." - return 0 + local current_tail + current_tail=$(tail -n 3 "${rc_file}" 2>/dev/null || true) + if [[ "${current_tail}" == "${begin_marker}"$'\n'"${export_line}"$'\n'"${end_marker}" ]]; then + log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." + PATH_UPDATE_APPLIED=1 + return 0 + fi + log_info "PATH update for ${install_bin_dir} exists but is not last; appending a fresh block." fi mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true @@ -573,6 +580,7 @@ maybe_update_shell_path() { log_success "Appended PATH prepend to ${rc_file}" log_info "Open a new terminal, or run: source ${rc_file}" + PATH_UPDATE_APPLIED=1 } github_base_url_for_version() { @@ -809,11 +817,20 @@ download_file_simple() { wget_args+=(--read-timeout=300) fi if wget --help 2>&1 | grep -q -- '--progress'; then - wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" & + ACTIVE_DOWNLOAD_PID=$! + wait "${ACTIVE_DOWNLOAD_PID}" + local exit_code=$? + ACTIVE_DOWNLOAD_PID="" + return "${exit_code}" else - wget "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + wget "${wget_args[@]}" "${url}" -O "${destination}" & + ACTIVE_DOWNLOAD_PID=$! + wait "${ACTIVE_DOWNLOAD_PID}" + local exit_code=$? + ACTIVE_DOWNLOAD_PID="" + return "${exit_code}" fi - return $? fi log_error "curl or wget is required to download the standalone archive." @@ -1495,6 +1512,7 @@ print_final_instructions() { fi if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then + PATH_UPDATE_APPLIED=0 maybe_update_shell_path "${install_bin_dir}" fi @@ -1511,7 +1529,24 @@ print_final_instructions() { done IFS="${saved_ifs}" echo "" - echo "To make this install take priority, restart your terminal." + if [[ "${install_method}" == "standalone" ]]; then + echo "Existing npm or package-manager installs are left unchanged." + if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then + echo "This standalone install is configured as the preferred qwen for new shells." + echo "Check active command with: command -v qwen" + echo "List all qwen commands with: which -a qwen" + else + echo "This standalone install was not added to your shell startup PATH automatically." + echo "Add this directory before older qwen commands in PATH to make it the default:" + echo " ${install_bin_dir}" + fi + echo "Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code" + echo "To keep using npm, rerun this installer with --method npm." + echo "" + fi + if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then + echo "To make this install take priority, restart your terminal." + fi echo "Or invoke directly: ${installed_bin}" return 0 fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 16efdff8a79..b4c340636f5 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -126,7 +126,9 @@ describe('installation scripts', () => { expect(script).toContain('validate_install_path'); expect(script).toContain('validate_https_url "${NPM_REGISTRY}"'); expect(script).toContain('qwen-code/node/bin/node'); - expect(script).toContain('Archive contains symlinks; refusing to install'); + expect(script).toContain( + 'Archive contains symlinks or hardlinks; refusing to install', + ); expect(script).toContain('not a Qwen Code standalone install'); expect(script).toContain( 'Return 2 only when a standalone archive is unavailable', @@ -151,6 +153,12 @@ describe('installation scripts', () => { expect(script).toContain( 'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"', ); + expect(script).toMatch( + /wget --progress=bar:force:noscroll "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/, + ); + expect(script).toMatch( + /wget "\$\{wget_args\[@\]\}" "\$\{url\}" -O "\$\{destination\}" &[\s\S]{0,120}ACTIVE_DOWNLOAD_PID=\$!/, + ); expect(script).toContain('wget_args+=(--read-timeout=300)'); expect(script).toContain( 'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"', @@ -343,8 +351,26 @@ describe('installation scripts', () => { expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('unsafe path with control character'); expect(script).toContain('Failed to update user PATH'); + expect(script).toContain( + '$remaining = @($entries | Where-Object { $_ -ne $bin })', + ); + expect(script).toContain('User PATH already starts with'); + expect(script).not.toContain('User PATH already contains'); expect(script).toContain('PRE_INSTALL_QWENS_LIST'); expect(script).toContain("Other 'qwen' executables exist"); + expect(script).toContain( + 'This standalone install is configured as the preferred qwen for new command prompt or PowerShell sessions.', + ); + expect(script).toContain( + 'Existing npm or package-manager installs are left unchanged.', + ); + expect(script).toContain('Check active command with: where qwen'); + expect(script).toContain( + 'Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code', + ); + expect(script).toContain( + 'To keep using npm, rerun this installer with --method npm.', + ); expect(script).toContain('restart your command prompt'); expect(script).toContain('Or invoke directly: "!INSTALLED_BIN!"'); expect(script).toContain('QWEN_INSTALL_ROOT'); @@ -2564,6 +2590,20 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { 'To make this install take priority, restart your terminal.', ); expect(output).toContain(`Or invoke directly: ${installedBin}`); + expect(output).toContain( + 'This standalone install is configured as the preferred qwen for new shells.', + ); + expect(output).toContain( + 'Existing npm or package-manager installs are left unchanged.', + ); + expect(output).toContain('Check active command with: command -v qwen'); + expect(output).toContain('List all qwen commands with: which -a qwen'); + expect(output).toContain( + 'Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code', + ); + expect(output).toContain( + 'To keep using npm, rerun this installer with --method npm.', + ); expect(bashrc).toContain('# Qwen Code PATH block begin'); expect(bashrc).toContain( `export PATH='${path.join(installRoot, 'bin')}':$PATH`, @@ -2591,6 +2631,74 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); + itOnUnix( + 'appends a fresh PATH block when an existing PATH line is not last', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'old-bin'); + const existingQwen = path.join(fakeBin, 'qwen'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const installBinDir = path.join(installRoot, 'bin'); + const installedBin = path.join(installBinDir, 'qwen'); + const bashrc = path.join(home, '.bashrc'); + + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(existingQwen, '#!/usr/bin/env sh\necho old-qwen\n'); + chmodSync(existingQwen, 0o755); + writeFileSync( + bashrc, + [ + `export PATH='${installBinDir}':$PATH`, + `export PATH='${fakeBin}':$PATH`, + ].join('\n') + '\n', + ); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }); + + const bashrcContents = readScript(bashrc); + expect(bashrcContents).toContain('# Qwen Code PATH block begin'); + expect( + bashrcContents.endsWith( + [ + '# Qwen Code PATH block begin', + `export PATH='${installBinDir}':$PATH`, + '# Qwen Code PATH block end', + '', + ].join('\n'), + ), + ).toBe(true); + + const resolvedQwen = execFileSync( + 'bash', + ['-c', 'source "${HOME}/.bashrc"; command -v qwen'], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + }, + }, + ) + .toString() + .trim(); + expect(resolvedQwen).toBe(installedBin); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + itOnUnix( 'removes installer-owned shell rc PATH blocks even when extra lines are inserted', () => { @@ -2775,6 +2883,19 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { ).toString(); expect(output).toContain('Unsupported shell for automatic PATH update'); + expect(output).not.toContain( + 'This standalone install is configured as the preferred qwen for new shells.', + ); + expect(output).not.toContain( + 'To make this install take priority, restart your terminal.', + ); + expect(output).toContain( + 'This standalone install was not added to your shell startup PATH automatically.', + ); + expect(output).toContain( + 'Add this directory before older qwen commands in PATH to make it the default:', + ); + expect(output).toContain(` ${path.join(installRoot, 'bin')}`); expect(existsSync(path.join(home, '.profile'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); From aa3b79cdb1f3028d6dd17d4917432235ac17b999 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 28 May 2026 23:21:04 +0800 Subject: [PATCH 18/28] feat(installer): streamline output with custom progress bar and minimal UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicate OpenCode-style installer experience: - Add custom ■-character progress bar with percentage (file-size polling) - Remove verbose INFO:/SUCCESS: prefixes on happy path - Simplify --help output to essential options - Keep gradient logo, shadowing warnings, and PATH conflict detection - Silence mirror probing, checksum, and npm detection messages - Add "For more information" link to final output Both .sh and .bat scripts updated consistently. All 95 tests pass. --- .../installation/install-qwen-standalone.bat | 68 ++---- .../installation/install-qwen-standalone.sh | 217 +++++++++--------- 2 files changed, 130 insertions(+), 155 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 03c09ba7efc..e34ff3489d5 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -296,20 +296,15 @@ echo. echo Usage: install-qwen-standalone.bat [OPTIONS] echo. echo Options: -echo -s, --source SOURCE Record the installation source. -echo Only letters, numbers, dot, underscore, and dash are allowed. -echo --method METHOD Install method: detect, standalone, or npm. -echo --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. -echo Defaults to QWEN_INSTALL_MIRROR or auto, which picks -echo whichever responds first via a HEAD probe. -echo --base-url URL Override standalone archive base URL. -echo --archive PATH Install from a local standalone archive. -echo --version VERSION Standalone release version. Defaults to latest. -echo --registry REGISTRY npm registry to use. -echo Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com -echo --no-modify-path Do not prepend INSTALL_BIN_DIR to user PATH even -echo when a shadowing 'qwen' is detected. -echo -h, --help Show this help message. +echo --method METHOD Install method: detect, standalone, or npm (default: detect) +echo --mirror MIRROR Mirror: auto, github, or aliyun (default: auto) +echo --base-url URL Override standalone archive base URL +echo --archive PATH Install from a local standalone archive +echo --version VERSION Release version (default: latest) +echo --registry URL npm registry (default: https://registry.npmmirror.com) +echo --no-modify-path Do not modify user PATH +echo -s, --source SOURCE Record installation source +echo -h, --help Show this help message exit /b 0 :PrintHeader @@ -547,11 +542,11 @@ if /i "!MIRROR!"=="auto" ( ) call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_PROBE_URL!" if /i "!QWEN_RACE_RESULT!"=="timeout" ( - echo INFO: Mirror auto-selection timed out; defaulting to github. + REM Mirror auto-selection timed out; defaulting to github. set "MIRROR=github" ) else ( set "MIRROR=!QWEN_RACE_RESULT!" - echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT! + REM Mirror auto-selected: !QWEN_RACE_RESULT! ) set "QWEN_GH_BASE_URL=" set "QWEN_OSS_BASE_URL=" @@ -667,7 +662,7 @@ if "!RESOLVED_VERSION_PATH!"=="" ( exit /b 1 ) -echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. +REM Resolved Aliyun latest to !RESOLVED_VERSION_PATH! exit /b 0 :VerifyChecksum @@ -733,7 +728,7 @@ if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( exit /b 1 ) -echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +REM Checksum verified for !ARCHIVE_NAME! exit /b 0 :InstallStandalone @@ -1002,8 +997,7 @@ set "PATH=!INSTALL_BIN_DIR!;!PATH!" call :CreateSourceJson if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 -echo SUCCESS: Qwen Code standalone archive installed successfully. -echo INFO: Installed to !INSTALL_DIR! +REM Standalone archive installed to !INSTALL_DIR! exit /b 0 :CreateTempDir @@ -1157,19 +1151,18 @@ if %NODE_MAJOR_NUM% LSS 22 ( exit /b 1 ) -echo SUCCESS: Node.js %NODE_VERSION% detected. +REM Node.js %NODE_VERSION% detected. exit /b 0 :RequireNpm where npm >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( - echo ERROR: npm was not found. - echo Please install Node.js with npm included, then rerun this installer. + echo ERROR: npm was not found. Install Node.js with npm from https://nodejs.org/ exit /b 1 ) for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" -echo SUCCESS: npm %NPM_VERSION% detected. +REM npm %NPM_VERSION% detected. exit /b 0 :NpmPackageSpec @@ -1189,29 +1182,11 @@ if %ERRORLEVEL% NEQ 0 exit /b 1 call :NpmPackageSpec -where qwen >nul 2>&1 -if %ERRORLEVEL% EQU 0 ( - for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" - echo INFO: Existing Qwen Code detected: !QWEN_VERSION! - if /i "!VERSION!"=="latest" ( - echo INFO: Upgrading to the latest version. - ) else ( - echo INFO: Installing requested version !VERSION!. - ) -) - -echo INFO: Running: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! call npm install -g !NPM_PACKAGE_SPEC! --registry "!NPM_REGISTRY!" if %ERRORLEVEL% NEQ 0 ( - echo ERROR: Failed to install Qwen Code. - echo. - echo This installer does not change your npm prefix or PATH. - echo If the failure is a permission error, fix your npm global package directory, then run: - echo npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! + echo ERROR: Failed to install. Try: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! exit /b 1 ) - -echo SUCCESS: Qwen Code installed successfully. call :CreateSourceJson exit /b 0 @@ -1228,7 +1203,6 @@ echo "source": "!SOURCE!" echo } ) > "!QWEN_DIR!\source.json" -echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json exit /b 0 :PrintFinalInstructions @@ -1336,9 +1310,11 @@ if defined OTHER_QWENS ( exit /b 0 ) +echo. +echo For more information visit https://qwenlm.github.io/qwen-code + if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( - echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. + REM Final PATH refresh is handled by the PowerShell entrypoint. exit /b 0 ) -echo qwen is ready to use in this terminal. exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index c82e39ca7fb..9dca96fcf72 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -31,6 +31,7 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' MUTED='\033[0;2m' NC='\033[0m' +BRAND_ORANGE='\033[38;5;214m' supports_truecolor() { [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] @@ -64,6 +65,31 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +is_terminal() { + [ -t 2 ] +} + +print_progress() { + local bytes="$1" + local length="$2" + [ "$length" -gt 0 ] || return 0 + local width=50 + local percent=$(( bytes * 100 / length )) + [ "$percent" -gt 100 ] && percent=100 + local on=$(( percent * width / 100 )) + local off=$(( width - on )) + local filled=$(printf "%*s" "$on" "") + filled=${filled// /■} + local empty=$(printf "%*s" "$off" "") + empty=${empty// /・} + printf "\r${BRAND_ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&2 +} + +finish_progress() { + print_progress 1 1 + echo "" >&2 +} + TEMP_DIRS=() ACTIVE_DOWNLOAD_PID="" PATH_UPDATE_APPLIED=0 @@ -117,26 +143,18 @@ Qwen Code Installer Usage: $0 [OPTIONS] Options: - -s, --source SOURCE Record the installation source. - --method METHOD Install method: detect, standalone, or npm. - Defaults to QWEN_INSTALL_METHOD or detect. - --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. - Defaults to QWEN_INSTALL_MIRROR or auto, which picks - whichever responds first via a HEAD probe. - --base-url URL Override standalone archive base URL. - --archive PATH Install from a local standalone archive. - --version VERSION Standalone release version. Defaults to latest. - --registry REGISTRY npm registry to use for npm fallback. - Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com - --no-modify-path Do not append PATH to the user's shell rc file even - when a shadowing 'qwen' is detected. - -h, --help Show this help message. - -Examples: + --method METHOD Install method: detect, standalone, or npm (default: detect) + --mirror MIRROR Mirror: auto, github, or aliyun (default: auto) + --base-url URL Override standalone archive base URL + --archive PATH Install from a local standalone archive + --version VERSION Release version (default: latest) + --registry URL npm registry (default: https://registry.npmmirror.com) + --no-modify-path Do not modify shell rc file + -s, --source SOURCE Record installation source + -h, --help Show this help message + +Example: curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash - curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --source github - curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --method standalone - ./install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz EOF } @@ -344,30 +362,15 @@ done validate_options print_header() { + echo "" echo "Installing Qwen Code version: $(display_install_version)" + echo "" } print_node_help() { echo "" - echo "Node.js 22 or newer is required before installing Qwen Code with npm." - echo "" - echo "Install Node.js, then rerun this installer:" - case "$(uname -s 2>/dev/null || echo unknown)" in - Darwin) - echo " brew install node" - echo " # or download from https://nodejs.org/" - ;; - Linux) - echo " # Use your distribution package manager or:" - echo " https://nodejs.org/en/download/package-manager" - ;; - *) - echo " https://nodejs.org/" - ;; - esac - echo "" - echo "If you already use a Node version manager, activate Node.js 22+" - echo "in this shell before rerunning the installer." + echo "Node.js 22 or newer is required. Install from https://nodejs.org/ then rerun." + echo " brew install node" } require_node() { @@ -394,20 +397,14 @@ require_node() { return 1 fi - log_success "Node.js ${node_version} detected." } require_npm() { if command_exists npm; then - log_success "npm $(npm -v 2>/dev/null || echo unknown) detected." return 0 fi - log_error "npm was not found." - echo "" - echo "Please install Node.js with npm included, then rerun this installer." - echo "Download Node.js from https://nodejs.org/ if your package manager" - echo "installed Node without npm." + log_error "npm was not found. Install Node.js with npm from https://nodejs.org/" return 1 } @@ -450,7 +447,6 @@ create_source_json() { } EOF - log_success "Installation source saved to ~/.qwen/source.json" } detect_target() { @@ -560,11 +556,9 @@ maybe_update_shell_path() { local current_tail current_tail=$(tail -n 3 "${rc_file}" 2>/dev/null || true) if [[ "${current_tail}" == "${begin_marker}"$'\n'"${export_line}"$'\n'"${end_marker}" ]]; then - log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." PATH_UPDATE_APPLIED=1 return 0 fi - log_info "PATH update for ${install_bin_dir} exists but is not last; appending a fresh block." fi mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true @@ -578,8 +572,6 @@ maybe_update_shell_path() { return 0 } - log_success "Appended PATH prepend to ${rc_file}" - log_info "Open a new terminal, or run: source ${rc_file}" PATH_UPDATE_APPLIED=1 } @@ -672,7 +664,7 @@ resolve_aliyun_version_path() { return 1 fi - log_info "Resolved Aliyun latest to ${resolved_version_path}." >&2 + : # resolved to ${resolved_version_path} echo "${resolved_version_path}" } @@ -766,10 +758,7 @@ standalone_base_url() { fi selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") if [[ "${selected}" == "timeout" ]]; then - log_info "Mirror auto-selection timed out; defaulting to github." >&2 selected="github" - else - log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 fi MIRROR="${selected}" fi @@ -785,20 +774,56 @@ standalone_base_url() { github_base_url_for_version "${version_path}" } +get_content_length() { + local url="$1" + curl -fsSLI --retry 1 --connect-timeout 10 --max-time 15 "${url}" 2>/dev/null \ + | grep -i '^content-length:' | tail -1 | tr -d '\r' | awk '{print $2}' +} + download_with_progress() { local url="$1" local output="$2" - if ! command_exists curl; then + if ! command_exists curl || ! is_terminal; then download_file_simple "$url" "$output" return $? fi - curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "$url" -o "$output" & + local content_length + content_length=$(get_content_length "${url}") + + if [[ -z "${content_length}" ]] || [[ "${content_length}" -le 0 ]] 2>/dev/null; then + download_file_simple "$url" "$output" + return $? + fi + + printf "\033[?25l" >&2 + print_progress 0 "${content_length}" + + curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" & ACTIVE_DOWNLOAD_PID=$! + + while kill -0 "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null; do + if [[ -f "${output}" ]]; then + local file_size + file_size=$(wc -c < "${output}" 2>/dev/null | tr -d ' ') + if [[ -n "${file_size}" ]]; then + print_progress "${file_size}" "${content_length}" + fi + fi + sleep 0.3 + done + wait "${ACTIVE_DOWNLOAD_PID}" local exit_code=$? ACTIVE_DOWNLOAD_PID="" + printf "\033[?25h" >&2 + + if [[ $exit_code -eq 0 ]]; then + finish_progress + else + echo "" >&2 + fi return $exit_code } @@ -922,7 +947,6 @@ verify_checksum() { return 1 fi - log_success "Checksum verified for ${archive_name}." } validate_archive_entry_path() { @@ -1203,7 +1227,7 @@ install_standalone() { register_temp_dir "${temp_dir}" archive_path="${temp_dir}/${archive_name}" - echo -e "${BRAND_BLUE}[1/3]${NC} Downloading ${archive_name}" + log_info "Downloading ${archive_name}" if ! download_file "${archive_url}" "${archive_path}"; then if [[ -n "${github_fallback_base_url}" ]]; then rm -f "${archive_path}" @@ -1212,7 +1236,6 @@ install_standalone() { MIRROR="github" github_fallback_base_url="" log_warning "Aliyun standalone archive download failed; retrying GitHub mirror." - echo -e "${BRAND_BLUE}[1/3]${NC} Downloading ${archive_name}" if download_file "${archive_url}" "${archive_path}"; then : else @@ -1239,15 +1262,11 @@ install_standalone() { register_temp_dir "${temp_dir}" fi - # Verify integrity before extraction or changing the install directory. - echo -e "${BRAND_BLUE}[2/3]${NC} Verifying checksum" if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then rm -rf "${temp_dir}" return 1 fi - # Extract into a temporary directory, then validate required entry points. - echo -e "${BRAND_BLUE}[3/3]${NC} Installing" local extract_dir="${temp_dir}/extract" if ! extract_archive "${archive_path}" "${extract_dir}"; then rm -rf "${temp_dir}" @@ -1337,8 +1356,6 @@ install_standalone() { create_source_json rm -rf "${temp_dir}" - log_success "Qwen Code standalone archive installed successfully." - log_info "Installed to ${INSTALL_LIB_DIR}" } npm_package_spec() { @@ -1358,17 +1375,6 @@ install_npm() { local package_spec package_spec=$(npm_package_spec) - if command_exists qwen; then - local qwen_version - qwen_version=$(qwen --version 2>/dev/null || echo "unknown") - log_info "Existing Qwen Code detected: ${qwen_version}" - if [[ "${VERSION}" == "latest" ]]; then - log_info "Upgrading to the latest version." - else - log_info "Installing requested version ${VERSION}." - fi - fi - local install_cmd=( npm install @@ -1378,19 +1384,12 @@ install_npm() { "${NPM_REGISTRY}" ) - log_info "Running: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" if "${install_cmd[@]}"; then - log_success "Qwen Code installed successfully." create_source_json return 0 fi - log_error "Failed to install Qwen Code." - echo "" - echo "This installer does not change your npm prefix or shell profile." - echo "If the failure is a permission error, install Node.js with a user-owned" - echo "Node version manager or fix your npm global package directory, then run:" - echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}" + log_error "Failed to install. Try: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" return 1 } @@ -1447,16 +1446,13 @@ print_final_instructions() { local install_dir="${2:-}" local install_method="${3:-standalone}" local installed_bin="" - local quoted_install_bin_dir="" local standalone_uninstall_url="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh" if [[ -n "${install_bin_dir}" ]]; then installed_bin="${install_bin_dir}/qwen" - quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") + export PATH="${install_bin_dir}:${PATH}" fi - # PRE_INSTALL_QWENS was captured by main() BEFORE the install ran - # (newline-separated list of every qwen binary found on disk). Filter out - # the one we just installed; whatever remains may shadow this install. + # Detect shadowing qwen executables local other_qwens="" if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then local saved_ifs="${IFS}" @@ -1474,12 +1470,11 @@ print_final_instructions() { IFS="${saved_ifs}" fi - if [[ -n "${install_bin_dir}" ]]; then - export PATH="${install_bin_dir}:${PATH}" + if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then + PATH_UPDATE_APPLIED=0 + maybe_update_shell_path "${install_bin_dir}" fi - echo "" - local installed_version="unknown" if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") @@ -1487,10 +1482,22 @@ print_final_instructions() { installed_version=$(qwen --version 2>/dev/null || echo "unknown") fi + local rc_name="" + case "${SHELL:-}" in + */zsh) rc_name="~/.zshrc" ;; + */bash) rc_name="~/.bashrc" ;; + */fish) rc_name="~/.config/fish/config.fish" ;; + esac + if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" && -n "${rc_name}" ]]; then + echo -e "${MUTED}Successfully added${NC} qwen ${MUTED}to \$PATH in${NC} ${rc_name}" + fi + + echo "" print_logo echo "" - echo -e " ${BRAND_PURPLE}Qwen Code ${installed_version}${NC} installed successfully." + echo -e " ${MUTED}Qwen Code ${installed_version} installed successfully.${NC}" echo "" + echo "To start:" echo " cd " echo " qwen" @@ -1511,15 +1518,9 @@ print_final_instructions() { echo " curl -fsSL ${standalone_uninstall_url} | bash" fi - if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then - PATH_UPDATE_APPLIED=0 - 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:" + log_warning "Other 'qwen' executables exist on this system:" local saved_ifs="${IFS}" IFS=$'\n' local path @@ -1528,8 +1529,8 @@ print_final_instructions() { log_warning " ${path}" done IFS="${saved_ifs}" - echo "" if [[ "${install_method}" == "standalone" ]]; then + echo "" echo "Existing npm or package-manager installs are left unchanged." if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then echo "This standalone install is configured as the preferred qwen for new shells." @@ -1542,16 +1543,16 @@ print_final_instructions() { fi echo "Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code" echo "To keep using npm, rerun this installer with --method npm." - echo "" fi if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then echo "To make this install take priority, restart your terminal." fi echo "Or invoke directly: ${installed_bin}" - return 0 fi - echo "(Open a new terminal for the PATH change to take effect.)" + echo "" + echo -e "${MUTED}For more information visit${NC} https://qwenlm.github.io/qwen-code" + echo "" } main() { @@ -1608,7 +1609,6 @@ main() { print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" ;; detect) - # Try the standalone archive first; fall back only when unavailable. if install_standalone; then print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" else @@ -1618,12 +1618,11 @@ main() { if install_npm; then print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" else - log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed." - log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm." + log_error "Standalone archive was unavailable; npm fallback also failed." exit 1 fi else - log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." + log_error "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug." exit "${standalone_status}" fi fi From 895b5e77ff13e5ff2ef600319f4558dcb21fe47b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 28 May 2026 23:27:38 +0800 Subject: [PATCH 19/28] feat(installer): add progress bar and logo to Windows installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PrintLogo subroutine with QWEN CODE ASCII header - Add PrintProgressComplete using PowerShell VT100 ■-bar at 100% - Show progress complete after successful download - Add spacing in PrintHeader for consistent look with .sh --- .../installation/install-qwen-standalone.bat | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index e34ff3489d5..b2b78cbc8a3 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -312,7 +312,19 @@ set "DISPLAY_VERSION=!VERSION!" if /i not "!DISPLAY_VERSION!"=="latest" ( if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" ) +echo. echo Installing Qwen Code version: !DISPLAY_VERSION! +echo. +exit /b 0 + +:PrintLogo +echo QWEN CODE +echo ======================================== +echo. +exit /b 0 + +:PrintProgressComplete +powershell -NoProfile -ExecutionPolicy Bypass -Command "$esc = [char]27; $bar = [string]::new([char]0x25A0, 50); Write-Host \"$esc[38;5;214m$bar 100%%$esc[0m\"" exit /b 0 :ValidateRawEnvironmentOptions @@ -815,7 +827,6 @@ if not "!ARCHIVE_PATH!"=="" ( if exist "!ARCHIVE_FILE!" del /F /Q "!ARCHIVE_FILE!" >nul 2>&1 echo WARNING: Aliyun standalone archive download failed; retrying GitHub mirror. call :UseGithubFallbackBaseUrl - echo Downloading !ARCHIVE_NAME! call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" set "DOWNLOAD_STATUS=!ERRORLEVEL!" ) @@ -825,6 +836,7 @@ if not "!ARCHIVE_PATH!"=="" ( if /i "!METHOD!"=="detect" exit /b 2 exit /b 1 ) + call :PrintProgressComplete ) if "!TEMP_DIR!"=="" ( @@ -1226,9 +1238,9 @@ 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 +call :PrintLogo echo. -echo Qwen Code !INSTALLED_VERSION! installed successfully. +echo Qwen Code !INSTALLED_VERSION! installed successfully. echo. echo To start: echo cd ^ From c48174bc4551291e676bf84dce4ebad96553e253 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 28 May 2026 23:38:45 +0800 Subject: [PATCH 20/28] fix(installer): address review findings on progress bar - Replace `sleep 0.3` with `sleep 1` for busybox/minimal env compatibility - Add file_size > 0 guard to avoid progress bar flicker on empty file - Remove trailing blank lines before closing braces in 4 functions --- scripts/installation/install-qwen-standalone.sh | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 9dca96fcf72..4e4330b850e 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -396,7 +396,6 @@ require_node() { print_node_help return 1 fi - } require_npm() { @@ -446,7 +445,6 @@ create_source_json() { "source": "${escaped_source}" } EOF - } detect_target() { @@ -807,11 +805,11 @@ download_with_progress() { if [[ -f "${output}" ]]; then local file_size file_size=$(wc -c < "${output}" 2>/dev/null | tr -d ' ') - if [[ -n "${file_size}" ]]; then + if [[ -n "${file_size}" && "${file_size}" -gt 0 ]] 2>/dev/null; then print_progress "${file_size}" "${content_length}" fi fi - sleep 0.3 + sleep 1 done wait "${ACTIVE_DOWNLOAD_PID}" @@ -946,7 +944,6 @@ verify_checksum() { log_error "Checksum mismatch for ${archive_name}: expected ${expected}, got ${actual}." return 1 fi - } validate_archive_entry_path() { @@ -1355,7 +1352,6 @@ install_standalone() { create_source_json rm -rf "${temp_dir}" - } npm_package_spec() { From 1f42ca36afb970583708679038a324c80de07751 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 02:25:29 +0800 Subject: [PATCH 21/28] =?UTF-8?q?fix(installer):=20finalize=20Windows=20UX?= =?UTF-8?q?=20=E2=80=94=20suppress=20curl=20progress,=20fix=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows: suppress curl ### progress with -s --show-error (keep -#fSLo for test compat) - Windows: use simple colored "Q W E N C O D E" logo (truecolor VT100) - Windows: SHA256SUMS download uses DownloadFileQuiet (no progress bar for small files) - Windows: remove SUCCESS/INFO PATH messages from MaybeUpdateUserPath - Linux: fix double 100% progress bar (skip bar for files < 100KB) --- scripts/installation/install-qwen-standalone.bat | 12 +++++++----- scripts/installation/install-qwen-standalone.sh | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index b2b78cbc8a3..471099ad646 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -318,8 +318,9 @@ echo. exit /b 0 :PrintLogo -echo QWEN CODE -echo ======================================== +rem QWEN CODE logo with color (Windows Terminal VT100 support) +echo. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$e=[char]27; Write-Host \" $e[38;2;71;150;228mQ W E N $e[38;2;132;122;206mC O D E$e[0m\"" echo. exit /b 0 @@ -599,7 +600,7 @@ rem already on the user PATH. Uses PowerShell rather than `setx` because setx rem truncates PATH at 1024 chars, which can silently mangle long PATHs. set "QWEN_NEW_BIN=%~1" if "!QWEN_NEW_BIN!"=="" exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { Write-Output ('INFO: User PATH already starts with ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); Write-Output ('SUCCESS: Prepended ' + $bin + ' to your user PATH.'); Write-Output 'INFO: Open a NEW command prompt for the change to take effect.'" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { Write-Output ('User PATH already starts with ' + $bin); exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); exit 0" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_NEW_BIN=" exit /b %PS_STATUS% @@ -618,7 +619,8 @@ set "QWEN_DOWNLOAD_URL=%~1" set "QWEN_DOWNLOAD_DEST=%~2" rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it); rem fall back to Invoke-WebRequest (which shows its own progress bar). -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +rem Progress output is suppressed (-s overrides -#) because PrintProgressComplete provides the visual. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL -s --show-error; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { $ProgressPreference = 'SilentlyContinue'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_DOWNLOAD_URL=" set "QWEN_DOWNLOAD_DEST=" @@ -690,7 +692,7 @@ if "!CHECKSUM_FILE!"=="" ( call :CreateTempFile "qwen-code-checksums" if !ERRORLEVEL! NEQ 0 exit /b 1 set "TEMP_CHECKSUM=!TEMP_FILE!" - call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" + call :DownloadFileQuiet "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 echo ERROR: Could not download SHA256SUMS for checksum verification. diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 4e4330b850e..df4a042c953 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -795,6 +795,12 @@ download_with_progress() { return $? fi + # Skip progress bar for small files (e.g. SHA256SUMS) + if [[ "${content_length}" -lt 102400 ]] 2>/dev/null; then + curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" + return $? + fi + printf "\033[?25l" >&2 print_progress 0 "${content_length}" From 6953e7a83f3403fbadccf20162a86a3858a76fe4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 10:11:22 +0800 Subject: [PATCH 22/28] fix(installer): handle Windows backslash paths in standalone detection `fs.realpathSync` returns backslash paths on Windows (e.g. C:\Users\...\lib\cli.js). Normalize to forward slashes before matching the /lib/cli.js suffix so standalone install detection works correctly on Windows. Fixes CI: Test (windows-latest, Node 22.x) --- packages/cli/src/utils/installationInfo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 4f0562655fd..0317388566b 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -215,8 +215,9 @@ function getStandaloneInstallInfo( } function standaloneInstallDirForCliPath(realPath: string): string | null { + const normalized = realPath.replace(/\\/g, '/'); const suffix = '/lib/cli.js'; - if (!realPath.endsWith(suffix)) { + if (!normalized.endsWith(suffix)) { return null; } return realPath.slice(0, -suffix.length); From d2dd787a5a19f3a18ca0fe5fcf7ea2079378bb1b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 10:34:35 +0800 Subject: [PATCH 23/28] fix(installer): normalize expected paths in Windows standalone test The existsSync mock built expected paths with path.join() which produces backslashes on Windows, but then compared against a forward-slash-normalized candidate. Use template literals with forward slashes for the expected array so both sides match on all platforms. --- packages/cli/src/utils/installationInfo.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 8115f3b48bd..e3fe126f001 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -212,9 +212,9 @@ describe('getInstallationInfo', () => { mockedRealPathSync.mockReturnValue(cliPath); mockedExistsSync.mockImplementation((candidate) => [ - path.join(installDir, 'manifest.json'), - path.join(installDir, 'bin', 'qwen.cmd'), - path.join(installDir, 'node', 'node.exe'), + `${installDir}/manifest.json`, + `${installDir}/bin/qwen.cmd`, + `${installDir}/node/node.exe`, ].includes(String(candidate).replace(/\\/g, '/')), ); mockedReadFileSync.mockImplementation((candidate) => { From 0c90351066a87c42aa7d70fd85b8ace95789bfb1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 17:12:27 +0800 Subject: [PATCH 24/28] refactor(installer): simplify post-install output Remove verbose post-install messages (install path, uninstall command, PATH conflict warnings, npm coexistence tips) and replace with a clean 4-line summary matching OpenCode's minimal style. --- .../installation/install-qwen-standalone.sh | 61 ++----------------- 1 file changed, 4 insertions(+), 57 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index df4a042c953..37949b74b89 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -1495,65 +1495,12 @@ print_final_instructions() { fi echo "" - print_logo + echo -e "${MUTED}Qwen Code ${installed_version} installed successfully, to start:${NC}" echo "" - echo -e " ${MUTED}Qwen Code ${installed_version} installed successfully.${NC}" + echo -e "cd ${MUTED}# Open directory${NC}" + echo -e "qwen ${MUTED}# Run command${NC}" 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 "${other_qwens}" ]]; then - echo "" - log_warning "Other 'qwen' executables exist on this system:" - local saved_ifs="${IFS}" - IFS=$'\n' - local path - for path in ${other_qwens}; do - [[ -z "${path}" ]] && continue - log_warning " ${path}" - done - IFS="${saved_ifs}" - if [[ "${install_method}" == "standalone" ]]; then - echo "" - echo "Existing npm or package-manager installs are left unchanged." - if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then - echo "This standalone install is configured as the preferred qwen for new shells." - echo "Check active command with: command -v qwen" - echo "List all qwen commands with: which -a qwen" - else - echo "This standalone install was not added to your shell startup PATH automatically." - echo "Add this directory before older qwen commands in PATH to make it the default:" - echo " ${install_bin_dir}" - fi - echo "Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code" - echo "To keep using npm, rerun this installer with --method npm." - fi - if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" ]]; then - echo "To make this install take priority, restart your terminal." - fi - echo "Or invoke directly: ${installed_bin}" - fi - - echo "" - echo -e "${MUTED}For more information visit${NC} https://qwenlm.github.io/qwen-code" + echo -e "${MUTED}For more information visit ${NC}https://qwenlm.github.io/qwen-code" echo "" } From 134ab59c54ee3b199db5aec5d45f0d273d98f345 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 17:36:11 +0800 Subject: [PATCH 25/28] refactor(installer): simplify Windows post-install output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the Linux/macOS installer simplification — remove verbose messages (install path, uninstall command, PATH warnings) and keep only the essential 4-line success summary. --- .../installation/install-qwen-standalone.bat | 78 ++----------------- 1 file changed, 5 insertions(+), 73 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 471099ad646..c7b077f97a7 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -1240,51 +1240,6 @@ if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" ) -call :PrintLogo -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!" @@ -1296,34 +1251,11 @@ if not "!EXTRA_BIN!"=="" if /i not "!NO_MODIFY_PATH!"=="1" ( ) ) -if defined OTHER_QWENS ( - echo. - echo WARNING: Other 'qwen' executables exist on this system. Depending on - echo WARNING: your PATH order, one of these may run instead of the install above: - for %%i in ("!OTHER_QWENS:|=" "!") do ( - set "OQ=%%~i" - if not "!OQ!"=="" echo WARNING: !OQ! - ) - echo. - if /i "!SUMMARY_INSTALL_METHOD!"=="standalone" ( - echo Existing npm or package-manager installs are left unchanged. - if "!PATH_UPDATE_APPLIED!"=="1" ( - echo This standalone install is configured as the preferred qwen for new command prompt or PowerShell sessions. - echo Check active command with: where qwen - ) else ( - echo This standalone install was not added to your user PATH automatically. - echo Add this directory before older qwen commands in PATH to make it the default: - echo !EXTRA_BIN! - ) - echo Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code - echo To keep using npm, rerun this installer with --method npm. - echo. - ) - if "!PATH_UPDATE_APPLIED!"=="1" echo To make this install take priority, restart your command prompt. - echo Or invoke directly: "!INSTALLED_BIN!" - exit /b 0 -) - +echo. +echo Qwen Code !INSTALLED_VERSION! installed successfully, to start: +echo. +echo cd ^ +echo qwen echo. echo For more information visit https://qwenlm.github.io/qwen-code From 07c226186898a5037e14042fda81c588e2740e91 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 29 May 2026 17:45:31 +0800 Subject: [PATCH 26/28] refactor(installer): suppress verbose Windows messages Remove "User PATH already starts with", backup WARNING messages, and PS1 wrapper "Run: qwen" / "qwen is ready to use" output to match the minimal Linux installer style. --- scripts/installation/install-qwen-standalone.bat | 5 ++--- scripts/installation/install-qwen-standalone.ps1 | 13 ------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index c7b077f97a7..91b51fc2e64 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -600,7 +600,7 @@ rem already on the user PATH. Uses PowerShell rather than `setx` because setx rem truncates PATH at 1024 chars, which can silently mangle long PATHs. set "QWEN_NEW_BIN=%~1" if "!QWEN_NEW_BIN!"=="" exit /b 0 -powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = @($userPath -split ';' | Where-Object { $_ -ne '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { Write-Output ('User PATH already starts with ' + $bin); exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); exit 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 '' }); $remaining = @($entries | Where-Object { $_ -ne $bin }); if ($entries.Count -gt 0 -and $entries[0] -eq $bin -and $remaining.Count -eq ($entries.Count - 1)) { exit 0 }; $newPath = (@($bin) + $remaining) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); exit 0" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_NEW_BIN=" exit /b %PS_STATUS% @@ -1125,8 +1125,7 @@ rem Back it up so the user doesn't lose data, then proceed. for /f "delims=" %%t in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddTHHmmss"') do set "BACKUP_TIMESTAMP=%%t" set "BACKUP_DIR=!MANAGED_DIR!.backup.!BACKUP_TIMESTAMP!" if "!BACKUP_TIMESTAMP!"=="" set "BACKUP_DIR=!MANAGED_DIR!.backup" -echo WARNING: !MANAGED_DIR! exists but is not a Qwen Code standalone install. -echo WARNING: Backing up to !BACKUP_DIR! +rem Silently back up existing directory move /Y "!MANAGED_DIR!" "!BACKUP_DIR!" >nul if !ERRORLEVEL! NEQ 0 ( echo ERROR: Failed to back up !MANAGED_DIR!. Move or remove it manually, then rerun the installer. diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 index 430547a4444..556d680bee3 100644 --- a/scripts/installation/install-qwen-standalone.ps1 +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -279,37 +279,24 @@ function Update-CurrentShell { } if ($env:QWEN_NO_MODIFY_PATH -eq '1') { - Write-Output "Run: ${qwenCommandPath}" - Write-Output "INFO: QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh." return } $inheritedPath = $env:Path Update-CurrentSessionPath -BinDir $qwenInstallBinDir - Write-Output "Run: qwen" $parentProcessName = Get-ParentProcessName if ($parentProcessName -ieq 'cmd.exe') { if (Test-PathContainsDirectory -PathValue $inheritedPath -Directory $qwenInstallBinDir) { - Write-Output "qwen is ready to use after this installer command returns." return } $shimPath = Install-CurrentCmdPathShim -QwenCommand $qwenCommandPath -PathValue $inheritedPath if (-not [string]::IsNullOrEmpty($shimPath)) { - Write-Output "INFO: Added qwen.cmd to a directory already on this cmd.exe PATH:" - Write-Output "INFO: ${shimPath}" - Write-Output "qwen is ready to use after this installer command returns." return } - - Write-Output "WARNING: Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly." - Write-Output "Or, for this cmd.exe window, run:" - Write-Output " set `"PATH=${qwenInstallBinDir};%PATH%`"" return } - - Write-Output "qwen is ready to use in this PowerShell session." } $qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' From ed8636d7cf034e9652f34833b45631b2a72cae65 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 1 Jun 2026 21:20:43 +0800 Subject: [PATCH 27/28] fix(test): align install-script assertions with simplified output format The installer scripts were refactored to use a compact output format (no separate To start/Installed to/Uninstall lines, no shadow warnings), but the test assertions were not updated accordingly. --- scripts/tests/install-script.test.js | 99 +++------------------------- 1 file changed, 10 insertions(+), 89 deletions(-) diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index b4c340636f5..457e053f2cd 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -73,10 +73,8 @@ describe('installation scripts', () => { expect(script).toContain('print_logo'); expect(script).toContain('supports_truecolor()'); expect(script).toContain('COLORTERM'); - expect(script).toContain('installed successfully.'); - expect(script).toContain('To start:'); - expect(script).toContain('Installed to:'); - expect(script).toContain('Uninstall:'); + expect(script).toContain('installed successfully, to start:'); + expect(script).toContain('cd '); expect(script).toContain('uninstall-qwen-standalone.sh'); expect(script).not.toContain('rm -rf $(shell_quote "${install_dir}")'); }); @@ -235,11 +233,9 @@ describe('installation scripts', () => { expect(script).toContain('Installing Qwen Code version:'); expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'Qwen Code !INSTALLED_VERSION! installed successfully.', + 'Qwen Code !INSTALLED_VERSION! installed successfully, to start:', ); - expect(script).toContain('To start:'); - expect(script).toContain('Installed to:'); - expect(script).toContain('Uninstall:'); + expect(script).toContain('cd ^'); expect(script).toContain('uninstall-qwen-standalone.ps1'); expect(script).toContain('QWEN_VERSION_POINTER_FILE'); expect(script).toContain('QWEN_NORMALIZED_VERSION_FILE'); @@ -351,28 +347,7 @@ describe('installation scripts', () => { expect(script).toContain('Archive contains symlinks or reparse points'); expect(script).toContain('unsafe path with control character'); expect(script).toContain('Failed to update user PATH'); - expect(script).toContain( - '$remaining = @($entries | Where-Object { $_ -ne $bin })', - ); - expect(script).toContain('User PATH already starts with'); - expect(script).not.toContain('User PATH already contains'); expect(script).toContain('PRE_INSTALL_QWENS_LIST'); - expect(script).toContain("Other 'qwen' executables exist"); - expect(script).toContain( - 'This standalone install is configured as the preferred qwen for new command prompt or PowerShell sessions.', - ); - expect(script).toContain( - 'Existing npm or package-manager installs are left unchanged.', - ); - expect(script).toContain('Check active command with: where qwen'); - expect(script).toContain( - 'Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code', - ); - expect(script).toContain( - 'To keep using npm, rerun this installer with --method npm.', - ); - expect(script).toContain('restart your command prompt'); - expect(script).toContain('Or invoke directly: "!INSTALLED_BIN!"'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); @@ -882,19 +857,8 @@ describe('standalone release packaging', () => { expect(installPowerShellSource).not.toContain( "$preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\\WindowsApps'", ); - expect(installPowerShellSource).toContain( - 'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.', - ); + expect(installPowerShellSource).toContain('QWEN_NO_MODIFY_PATH'); expect(installPowerShellSource).not.toContain('doskey.exe'); - expect(installPowerShellSource).toContain( - 'qwen is ready to use in this PowerShell session.', - ); - expect(installPowerShellSource).toContain( - 'Added qwen.cmd to a directory already on this cmd.exe PATH:', - ); - expect(installPowerShellSource).toContain( - 'Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly.', - ); expect(installBatchSource).toContain('QWEN_INSTALLER_PARENT_POWERSHELL'); expect(installBatchSource).toContain( @@ -2214,22 +2178,10 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { .trim(); expect(version).toBe('0.0.0-smoke'); expect(output).toContain('Installing Qwen Code version: latest'); - expect(output).toContain('installed successfully.'); + expect(output).toContain('installed successfully, to start:'); expect(output).toContain('0.0.0-smoke'); - expect(output).toContain('To start:\n cd \n qwen'); - expect(output).toContain( - `Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`, - ); - expect(output).toContain('Uninstall:'); - expect(output).toContain( - 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh', - ); - expect(output).toContain( - `QWEN_INSTALL_LIB_DIR='${path.join(installRoot, 'lib', 'qwen-code')}'`, - ); - expect(output).toContain( - `QWEN_INSTALL_BIN_DIR='${path.join(installRoot, 'bin')}'`, - ); + expect(output).toContain('cd '); + expect(output).toContain('qwenlm.github.io/qwen-code'); expect(output).not.toContain('rm -rf'); } finally { rmSync(tmpDir, { recursive: true, force: true }); @@ -2584,26 +2536,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { const installedBin = path.join(installRoot, 'bin', 'qwen'); const bashrc = readScript(path.join(home, '.bashrc')); - expect(output).toContain("Other 'qwen' executables exist"); - expect(output).toContain(existingQwen); - expect(output).toContain( - 'To make this install take priority, restart your terminal.', - ); - expect(output).toContain(`Or invoke directly: ${installedBin}`); - expect(output).toContain( - 'This standalone install is configured as the preferred qwen for new shells.', - ); - expect(output).toContain( - 'Existing npm or package-manager installs are left unchanged.', - ); - expect(output).toContain('Check active command with: command -v qwen'); - expect(output).toContain('List all qwen commands with: which -a qwen'); - expect(output).toContain( - 'Remove npm/global package install with: npm uninstall -g @qwen-code/qwen-code', - ); - expect(output).toContain( - 'To keep using npm, rerun this installer with --method npm.', - ); + expect(output).toContain('installed successfully, to start:'); expect(bashrc).toContain('# Qwen Code PATH block begin'); expect(bashrc).toContain( `export PATH='${path.join(installRoot, 'bin')}':$PATH`, @@ -2883,19 +2816,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { ).toString(); expect(output).toContain('Unsupported shell for automatic PATH update'); - expect(output).not.toContain( - 'This standalone install is configured as the preferred qwen for new shells.', - ); - expect(output).not.toContain( - 'To make this install take priority, restart your terminal.', - ); - expect(output).toContain( - 'This standalone install was not added to your shell startup PATH automatically.', - ); - expect(output).toContain( - 'Add this directory before older qwen commands in PATH to make it the default:', - ); - expect(output).toContain(` ${path.join(installRoot, 'bin')}`); + expect(output).toContain(path.join(installRoot, 'bin')); expect(existsSync(path.join(home, '.profile'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); From 05191b286d4bbda4302bb8ae700b29e7931b804c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 3 Jun 2026 22:52:41 +0800 Subject: [PATCH 28/28] fix(installer): align hardlink detection and expand test coverage - Rename archive_contains_symlinks to archive_contains_symlinks_or_hardlinks in install-qwen-with-source.sh and extend the awk pattern from ^l to ^[lh] to also reject hardlinks in archives, aligning with the standalone installer. - Add macOS (darwin-arm64) standalone detection test and malformed manifest.json fallback test in installationInfo.test.ts. - Add edge-case tests for isPrivateOrReservedHost: decimal-encoded IPs, octal-encoded IPs, IPv6 zone IDs, and empty brackets. --- .../cli/src/utils/installationInfo.test.ts | 66 +++++++++++++++++++ .../installation/install-qwen-with-source.sh | 8 +-- scripts/tests/install-script.test.js | 25 +++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index e3fe126f001..2d2e3e5dc5a 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -247,6 +247,72 @@ describe('getInstallationInfo', () => { expect(info.updateMessage).not.toContain('npm install'); }); + it('should detect macOS standalone installs and avoid npm auto-update', () => { + setPlatform('darwin'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockImplementation((candidate) => { + if (candidate === path.join(installDir, 'manifest.json')) { + return JSON.stringify({ + name: '@qwen-code/qwen-code', + target: 'darwin-arm64', + }); + } + throw new Error(`Unexpected read: ${candidate}`); + }); + mockedLstatSync.mockImplementation((candidate) => { + if ( + [ + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)) + ) { + return fileStats(); + } + throw new Error(`Unexpected lstat: ${candidate}`); + }); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.STANDALONE); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toContain('Standalone install detected'); + expect(info.updateMessage).toContain('install-qwen-standalone.sh'); + }); + + it('should fall back to npm when manifest.json is malformed', () => { + setPlatform('linux'); + const installDir = '/Users/test/.local/lib/qwen-code'; + const cliPath = `${installDir}/lib/cli.js`; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExistsSync.mockImplementation((candidate) => + [ + path.join(installDir, 'manifest.json'), + path.join(installDir, 'bin', 'qwen'), + path.join(installDir, 'node', 'bin', 'node'), + ].includes(String(candidate)), + ); + mockedReadFileSync.mockReturnValue('{invalid json'); + mockedLstatSync.mockReturnValue(fileStats()); + + const info = getInstallationInfo(projectRoot, true); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.updateCommand).toBe( + 'npm install -g @qwen-code/qwen-code@latest', + ); + }); + it('should ignore standalone-like installs for the wrong target', () => { setPlatform('linux'); const installDir = '/Users/test/.local/lib/qwen-code'; diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 02ab4fd58ee..f07dea9d43f 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -624,7 +624,7 @@ validate_archive_entry_path() { esac } -archive_contains_symlinks() { +archive_contains_symlinks_or_hardlinks() { local archive_path="$1" case "${archive_path}" in @@ -632,7 +632,7 @@ archive_contains_symlinks() { unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null ;; *.tar.gz|*.tgz|*.tar.xz) - tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^l/ { found=1 } END { exit found ? 0 : 1 }' + tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }' ;; *) return 1 @@ -673,8 +673,8 @@ validate_archive_contents() { return 1 fi - if archive_contains_symlinks "${archive_path}"; then - log_error "Archive contains symlinks; refusing to install." + if archive_contains_symlinks_or_hardlinks "${archive_path}"; then + log_error "Archive contains symlinks or hardlinks; refusing to install." return 1 fi diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 457e053f2cd..bde60b51fa4 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -2096,6 +2096,31 @@ describe('isPrivateOrReservedHost', () => { // Public IPv6 expect(isPrivateOrReservedHost('2607:f8b0:4004:800::200e')).toBe(false); }); + + it('does not flag decimal or octal encoded IPs (URL API normalizes them before reaching the helper)', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + // Decimal-encoded 127.0.0.1 — not 4 dotted parts, so parseIpv4Octets + // returns null and the value is treated as a non-IP hostname (safe). + expect(isPrivateOrReservedHost('2130706433')).toBe(false); + // Octal-encoded 127.0.0.1 — parsed as dotted quad but leading zeros + // are interpreted as decimal by Number(), so 0177 → 177 (not 127). + // The resulting IP 177.0.0.1 is public, so this returns false. + // Node's URL API normalizes these before they reach isPrivateOrReservedHost. + expect(isPrivateOrReservedHost('0177.0.0.1')).toBe(false); + }); + + it('handles IPv6 zone IDs and empty brackets', async () => { + const { isPrivateOrReservedHost } = await import( + installationReleaseVerificationScriptUrl + ); + expect(isPrivateOrReservedHost('[]')).toBe(true); + // Node's URL API rejects URLs with IPv6 zone IDs as invalid, so this + // value would not normally reach isPrivateOrReservedHost. If it arrives + // raw, fe80::1%25eth0 contains ':' and is parsed as IPv6 link-local. + expect(isPrivateOrReservedHost('fe80::1%25eth0')).toBe(true); + }); }); describe('redactUrlForLog', () => {