From d59c9e7b77ae07b240e4fec0bf9434a088d1655f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 21 May 2026 11:57:10 +0800 Subject: [PATCH] feat(installer): add standalone hosted install and uninstall flow (#3828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(installer): add standalone archive installation * fix(installer): harden standalone archive installs * fix(installer): address standalone review findings * chore(installer): clarify review followups * fix(installer): stabilize standalone script checks * chore(installer): remove internal planning docs * chore(installer): simplify standalone release review fixes * test(installer): add Windows batch install smoke * test(installer): fix Windows batch smoke quoting * test(installer): preserve Windows cmd quotes * fix(installer): use robust Windows checksum hashing * ci: narrow installer debug matrix * fix(installer): address standalone review hardening * fix(installer): avoid Windows validation parse errors * fix(installer): simplify Windows option validation * fix(installer): harden standalone review fixes * feat(installer): publish release installer assets * fix(installer): address release asset review feedback * fix(installer): avoid prerelease installer asset links * test(installer): isolate standalone dist fixture * feat(installer): add hosted install release alias * chore: no changes - code review requested Agent-Logs-Url: https://github.com/QwenLM/qwen-code/sessions/38467aec-15b9-4b76-9139-0b2cfe40477a * fix(installer): pin versioned installer assets * fix: parallelize Node.js binary downloads in standalone release build Use Promise.all instead of sequential for...of+await for the 5 independent Node.js runtime downloads, reducing CI release build time by ~4-5x. Co-authored-by: Qwen-Coder * fix(installer): address release asset review followups * refactor(installer): share release CLI parsing * fix(installer): address release asset review followups - sh: reject CR/LF in archive entry names before the literal `..` glob so a `..\r` entry cannot bypass path validation. - bat: prefer Tls12+Tls13 in PowerShell helpers, fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. - bat: document the implicit `:ValidateOptions` dependency next to the qwen.cmd wrapper writer so loosening the validator stays a conscious choice. - build-standalone-release: surface the `xz-utils` host requirement for Linux Node downloads in `--help`. - release-script-utils: support `--key=value` form in `parseCliArgs`. - tests: cover the new CRLF message, TLS string, and `--key=value` parsing; register process-level signal/exit handlers in `ensureMinimalDist` so a crashed test still restores `dist/`. * fix(installer): unblock Windows CI for standalone install path Three CI failures and a few review followups in one pass. - ensureMinimalDist places its dist/ backup beside dist/ instead of under os.tmpdir(). On Windows GitHub runners the workspace lives on D: while os.tmpdir() is on C:, so renameSync raised EXDEV for every test that needed to swap dist/ in. - create-standalone-package.js and the matching test fixture build win-x64 zips with [IO.Compression.ZipFile]::CreateFromDirectory. Compress-Archive emits backslash entry names that the .bat installer's path-traversal guard then rejected, so every freshly built archive failed the standalone install path on Windows. - :ValidateArchiveContents normalizes entry separators to '/' before checking for '..', absolute paths, and drive prefixes - archives from any Windows zip tool still install while real traversal entries remain rejected. - createWindowsTraversalStandaloneArchive runs PowerShell via -File instead of a single -Command line; the joined-with-'; ' form had a function definition the runner's PowerShell refused to parse. Drive-by review followups: - replaceRequired uses replaceAll so a future duplicate placeholder cannot silently keep the trailing copy as 'latest'. - :ValidateOptions runs the unsafe-character check on SOURCE alongside the other variables. - build-installation-assets.js drops a dead INSTALLATION_ASSETS re-export; consumers already import from release-asset-config.js. - .gitignore covers the new sibling .qwen-dist-backup-* directory. * fix(installer): address release asset review findings * fix(installer): keep installer entrypoint hosted * fix(installer): reject stale hosted assets * fix(installer): refine hosted asset staging * fix(installer): tighten hosted default-version check, flag legacy URL - Replace the loose `latest` fragment check with per-format regex patterns in HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS so an unrelated occurrence of `latest` (comment, help text) cannot satisfy the staging guard. The patterns still tolerate whitespace variation, only the default-version assignment itself must be intact. - Add a "Hosted endpoint status" callout in INSTALLATION_GUIDE.md before the curl examples. The documented `--version` flow does not work against the OSS URL today because it currently serves the legacy NVM-based installer; the callout points users at a local checkout until the next release sync. - Tests: drop `latest` from the fragments equality assertion, add positive and negative regex coverage, add a failure-path case for sources whose default version is not `latest`, and pin the new guide markers so the callout cannot silently disappear. * feat(installer): verify installation release assets Adds `npm run verify:installation-release` and wires it into the release workflow after `Build Standalone Archives`, so a broken release directory fails CI before publishing. Local mode (`--dir PATH`) checks: - All five `qwen-code-{platform}.{ext}` standalone archives exist. - `SHA256SUMS` covers exactly those five — missing or unexpected entries fail. - Each archive's actual SHA256 matches its `SHA256SUMS` entry. Remote mode (`--base-url URL`) checks: - `SHA256SUMS` is downloadable, parseable, and contains exactly the expected archive entries. - Each archive URL is reachable via HEAD, with a 1-byte ranged GET fallback for hosts that disable HEAD. Hosted installer scripts (`install-qwen.sh` / `install-qwen.bat`) are intentionally out of scope here — they are served from the hosted endpoint prepared by `package:hosted-installation` (PR #3853), not from the GitHub Release surface this verifier targets. * fix(installer): tighten verifier base-url + clarify test helper 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. * fix(installer): address standalone review follow-ups * fix(installer): repair Windows installer tests * fix(release): tighten standalone asset checks * fix(installer): stabilize Windows managed install checks * test(installer): relax Windows installer timeout * fix(test): escape release asset regex * test(cli): avoid POSIX node path in relaunch test * fix(installer): align npm fallback node gate with engines * test(installer): allow Windows archive validation more time * fix(installer): remove stale node 20 installer references * docs(installer): clarify hosted endpoint sync requirement * refactor(installer): reuse standaloneArchiveName in release verifier The verify-installation-release script was duplicating the archive name derivation logic with a hardcoded ternary instead of reusing the standaloneArchiveName helper from build-standalone-release. Export the helper and import it so the extension mapping lives in one place. * fix(scripts): address release verifier review feedback * feat(installer): add standalone archive installer with multi-platform release workflow - Add standalone archive installer (bat/sh) that downloads platform binaries from GitHub/Aliyun without requiring Node.js or npm on the target machine - Add fork-friendly release-test workflow for manual GitHub Release creation covering all 5 platforms (darwin-arm64/x64, linux-arm64/x64, win-x64) - Add OSS upload/mirror tools for staging and release distribution - Update .gitignore to exclude generated build artifacts (release-staging/, hosted-staging/) - Fix Windows PowerShell test command in copy-release-to-latest tool * feat(installer): support QWEN_INSTALL_GITHUB_REPO env var for custom repo * chore(installer): exclude local-only staging tools from PR The tools/ directory contained personal staging-OSS upload helpers (upload-staging, upload-release-mirror, copy-release-to-latest, test-upload-one) that should not ship in the public PR. They reference a personal staging bucket and only exist to validate the installer end-to-end before production release. Removes them from git tracking via `git rm --cached` (files stay on disk for the author's local use) and adds /tools/ to root .gitignore so they cannot be re-added accidentally. No runtime / installer code change. Production CI on ubuntu-latest is unaffected. * fix(installer): enforce CRLF line endings for .bat files via gitattributes cmd.exe requires CRLF in batch scripts; the global eol=lf was causing every line to be misparsed on Windows, producing errors like 'QWEN_VALIDATE_METHOD=detect is not recognized as a command'. * fix(installer): store .bat files with CRLF in git blob for raw GitHub downloads GitHub raw file serving bypasses gitattributes eol conversion and serves blob bytes directly, so eol=crlf alone was not enough. Use -text to disable normalization and commit with actual CRLF so raw downloads work on Windows. * fix(installer): follow HTTP redirects in UrlExists and RaceMirrorHead probes GitHub release asset URLs return HTTP 302 to objects.githubusercontent.com. [Net.WebRequest] with HEAD does not auto-redirect by default, so the existence check and mirror-race probe both incorrectly reported the file as missing. Set AllowAutoRedirect=true on HttpWebRequest instances. * fix(installer): surface download errors and add MaximumRedirection 10 * feat(installer): add hosted install-qwen.ps1 shim for irm|iex one-liner The previous Windows quick-install one-liner used `Invoke-WebRequest -OutFile (Join-Path $env:TEMP 'install-qwen.bat'); & (Join-Path …)`. When pasted into a narrow terminal, line wrap could land on `-OutFile`, orphaning the parameter from its value and producing the "missing argument for OutFile" failure followed by a "file not found" when the second `&` ran. PowerShell's line continuation rules cannot resolve this for parameter-name-at-EOL. Add `install-qwen.ps1` as a thin hosted entrypoint that downloads `install-qwen.bat` into TEMP, runs it, and cleans up. Documented one-liner becomes the standard pattern used by bun, uv, scoop, deno, pnpm: powershell -ExecutionPolicy Bypass -c "irm /install-qwen.ps1 | iex" The `.bat` remains the source of truth for installer behavior; `.ps1` is just the modern hosted entrypoint. Version pinning via `$env:QWEN_INSTALL_VERSION` flows through unchanged. Stored with `*.ps1 -text` so CRLF survives both GitHub raw and OSS uploads, matching the existing `.bat` handling. * fix(installer): stage direct hosted install scripts * chore(installer): trim hosted release diff scope * chore(installer): narrow hosted release diff * feat(installer): restore hosted PowerShell entrypoint * chore(installer): stage standalone hosted entrypoints * fix(installer): address hosted installer review followups * fix(installer): stabilize Windows installer tests * fix(installer): make Windows option validation readable * feat(installer): wire Aliyun OSS sync, address review followups - Add Aliyun OSS sync steps to release workflow: package hosted assets, install pinned ossutil, configure credentials, upload versioned and latest paths, and verify upload via verify:installation-release plus curl probes against the hosted installer endpoint. - Document required production-release environment secrets and bucket variables in INSTALLATION_GUIDE.md. - Restructure hosted endpoint guidance to lead with the pre-sync warning, splitting "Run today" (local checkout) from "After the OSS sync" (hosted one-liners) so users no longer copy a one-liner that silently installs latest. - Distinguish mirror auto-selection timeout from successful selection in install-qwen-standalone.sh and install-qwen-standalone.bat: emit a "timed out; defaulting to github" log instead of pretending the HEAD probe picked github. - Support QWEN_INSTALLER_BAT_URL override (https only) in the PowerShell shim so staging mirrors can be exercised without forking the file. - Strip a leading UTF-8 BOM in verify-installation-release.js parseSha256Sums so BOM-prefixed SHA256SUMS reports a useful "Missing checksum entry" error instead of "Malformed SHA256SUMS line 1". - Add tests for verifier HEAD→Range fallback, partial-failure formatting, all-failure wording, and BOM tolerance. * ci(installer): add temporary OSS smoke test * fix(installer): make OSS release assets public-readable * chore(installer): remove temporary OSS smoke workflow * fix(installer): address hosted installer review gaps * feat(installer): refactor argument parsing and utility functions for release scripts * fix(installer): harden hosted release script checks * fix(installer): suppress PowerShell progress bar in hosted entrypoint shim Add $ProgressPreference = 'SilentlyContinue' to the .ps1 wrapper so Invoke-WebRequest downloads don't render a progress bar when invoked via the irm | iex one-liner. * fix(installer): suppress PowerShell progress bar in bat installer downloads Add $ProgressPreference = 'SilentlyContinue' to DownloadFile so the full-screen progress UI does not appear during archive downloads in interactive PowerShell sessions, consistent with the .ps1 shim. * fix(installer): use curl.exe -# progress bar in Windows downloads Prefer curl.exe with -# (hash-mark progress bar) for archive and installer downloads on Windows 10+. Falls back to Invoke-WebRequest (which shows its own progress bar) when curl.exe is unavailable. Matches the approach used by code-server (curl -#fL) and bun.sh (curl.exe -#SfLo). * fix(installer): suppress progress bars for small downloads and Expand-Archive - .ps1: replace curl.exe -# with silent mode, suppress Invoke-WebRequest progress bar; save/restore $global:ProgressPreference - .bat: add $ProgressPreference = 'SilentlyContinue' before Expand-Archive to prevent full-screen extraction progress UI - .sh: remove --progress-bar / --show-progress from download_file, always use silent curl/wget * fix(installer): auto-backup non-qwen directories and simplify output - ensure_managed_install_dir / :EnsureManagedInstallDir now back up non-qwen directories instead of refusing to install, so users upgrading from npm or old installers don't hit a hard error - Simplify header/footer output: remove banner bars, verbose INFO lines, and redundant "Installation completed!" message - Match bun.sh / code-server style: minimal, to the point * fix(installer): revert Expand-Archive progress suppression in bat The inline $ProgressPreference = 'SilentlyContinue' caused a cmd.exe parsing error ("此时不应有 >") on Chinese Windows. Revert to the original Expand-Archive invocation. * fix(installer): fix cmd.exe parsing error in backup fallback code The %s in the for /f fallback command string was interpreted as a variable reference by cmd.exe, causing "此时不应有 >" on Chinese Windows. Replace with a safe fallback and re-enable Expand-Archive progress suppression. * fix(installer): always persist install bin to user PATH Previously MaybeUpdateUserPath was only called when shadow qwen executables were detected. When no shadow was found, the PATH update was skipped entirely, leaving the user without qwen on PATH after restarting their terminal. Now always persist the bin directory to PATH (unless --no-modify-path is set), regardless of whether other qwen installations exist. * fix(installer): persist PATH to current terminal session on Windows Use the `endlocal & set` trick (same as bun/Rust installers) to export the install bin directory from the setlocal scope to the current cmd session. qwen is now usable immediately without restarting the terminal. * docs(installer): document cmd.exe one-liner for immediate PATH availability Add curl-based one-liner for cmd.exe users. Running the .bat directly in the current cmd session makes `qwen` available immediately via the `endlocal & set` trick. The `powershell -c "irm | iex"` path creates a child process so PATH changes cannot propagate to the parent. * feat(installer): make qwen usable immediately from PowerShell after install - .ps1: detect parent process, update current session PATH, and for cmd.exe parents emit a `set PATH=...` command - .bat: skip final instructions when called from PowerShell to avoid duplicate "Run: qwen" output * fix(installer): remove non-functional doskey approach for cmd parent doskey /exename from a child PowerShell process cannot modify the parent cmd.exe session. Replace with a simple set PATH=... command that the user can copy-paste. * fix(installer): make Windows standalone shim available in cmd * feat(installer): add standalone uninstall scripts * fix(uninstall): match shell-quoted paths when removing the wrapper The installer's write_unix_wrapper shell-quotes the binary path, so paths containing single quotes (or other shell metacharacters) appear as shell-quoted strings in the generated wrapper file. The uninstall script's literal grep -qF missed these, leaving the wrapper orphaned. Add shell_quote to the uninstall script and match against both the raw and shell-quoted forms before removing the wrapper. * fix(installer): update download commands to use progress indicators for curl and wget * fix(installer): resolve Aliyun latest via version pointer * fix(installer): cleanup mirror probe temp dirs * fix(installer): harden standalone release fallback * fix(installer): address standalone review feedback * style(installer): align standalone install output * fix(installer): print standalone uninstall commands * fix(installer): address release review follow-ups * fix(installer): harden Windows target detection * test(installer): stabilize Windows fake tool path * fix(installer): allow explicit Windows curl path * test(installer): use cmd fake curl on Windows * test(installer): cover Windows fake curl helper * test(installer): inject Windows arch overrides in cmd * test(cli): wait for prompt suggestion render * test(cli): revert prompt suggestion wait tweak * fix(installer): harden hosted release publishing * fix(installer): harden Windows latest pointer parsing * fix(installer): bound Windows download timeouts * fix(installer): bound hosted installer probes * fix(release): make ossutil download configurable * fix(installer): address hosted release review feedback * test(installer): keep dist backup on same filesystem * fix(installer): address remaining review feedback on PR #3828 - Remove REQUIRE_CHECKSUM dead code, always hard-fail on checksum issues - Add JSDoc to HOSTED_INSTALLER_BEHAVIOR_PATTERNS explaining its purpose - Add credential cleanup trap for ossutilconfig in release workflow - Add 3-attempt retry with exponential backoff for OSS uploads - Tighten findstr SOURCE regex to require leading letter * fix(release): correct OSS credentials lifetime and mirror probe fallback - release.yml: remove `trap EXIT` inside the Configure step; it deleted ${RUNNER_TEMP}/.ossutilconfig as soon as the configure shell exited, so every subsequent step (publish/sync/verify) lost the credentials. Move credential cleanup to a final `if: always()` step at the job tail. - install-qwen-standalone.sh: drop the predictable PID-based mktemp -d fallback in race_mirror_head; if mktemp fails, return "github" instead of using /tmp/qwen-mirror.$$ which a local attacker could pre-create to bias mirror selection. * fix(installer): address review feedback round 2 Workflow: - Move 'Publish Aliyun OSS Latest VERSION' to run after the hosted installer assets are uploaded and verified, so the latest/VERSION pointer only flips once every release artifact is in place. Previously a hosted-sync failure could leave the pointer ahead of the actual installer scripts. upload-aliyun-oss-assets.js: - Replace `spawnSync('sleep', ...)` retry backoff with an Atomics.wait-based cross-platform sleep so retries also work on Windows runners. install-qwen-standalone.bat: - :DetectTarget no longer emits TARGET=win-arm64 because RELEASE_TARGETS has no win-arm64 archive; ARM64 hosts now fall through to the unsupported-arch branch and (in detect mode) get the npm fallback instead of a 404. - Add QWEN_INSTALL_CURL_EXE to :ValidateRawEnvironmentOptions so this curl override is checked for shell metacharacters like every other knob. - Replace `call echo %%i>>...` with plain `echo %%i>>...` when capturing pre-install qwen.cmd paths; `call` triggered an extra parse pass that could interpret &/|/<,>/etc. inside a directory name as command separators. - Add `--retry 2` to curl.exe downloads (`:DownloadFile` / `:DownloadFileQuiet`) to match the shell installer. - Include expected vs actual hash in the checksum-mismatch error message. install-qwen-standalone.ps1: - Stage the downloaded installer at a cryptographically random temp path (`qwen-installer-.bat`) so a same-user attacker cannot pre-stage a malicious .bat at a predictable path and race the verify/execute window. - Atomically install the current-session cmd shim by writing to a sibling `.new` temp file then renaming, so a partial write cannot leave a half-written shim on PATH. - Add `--retry 2` to the curl.exe download path. - Include expected vs actual hash in the checksum-mismatch error message. install-qwen-standalone.sh: - Include expected vs actual hash in the checksum-mismatch error message. uninstall-qwen-standalone.ps1: - Accept `-Purge` and `-Help` parameters; previously every CLI flag was silently dropped, so users running with `-Purge` got no purge and no error. `-Purge` maps to `QWEN_UNINSTALL_PURGE=1`. uninstall-qwen-standalone.sh: - `remove_install_wrapper` additionally requires the wrapper file to start with a `#!` shebang before it deletes it; a user-authored script that just happens to mention the install path now stays untouched. verify-installation-release.js, build-hosted-installation-assets.js: - Include expected vs actual hash in the checksum-mismatch error messages. scripts/tests/install-script.test.js: - Update assertions for the new error wording, the curl `--retry 2` flag, the dropped ARM64 detection, and the new release-step ordering. * fix(installer): address review feedback round 3 Workflow: - Configure Aliyun OSS Credentials: write the ossutil config file directly with restricted umask instead of invoking `ossutil config -k `. Passing the access-key secret via argv made it visible in /proc//cmdline for the lifetime of that step; writing the INI file in-process keeps the secret out of the process table. upload-aliyun-oss-assets.js: - Upload assets in parallel with `Promise.all` + async `spawn` instead of a sequential `spawnSync` loop. Each asset keeps its own retry budget; failures are aggregated so one flaky upload does not mask a separate failure. - Replace the bespoke `Atomics.wait` retry sleep with `timers/promises#setTimeout` now that the loop is async. INSTALLATION_GUIDE.md: - Drop the misleading "instead of overwriting the global installation/ entrypoint objects" sentence; the workflow has always also refreshed the global versionless objects so curl|bash links keep resolving without a version segment. Document the rollback story instead. * test(installer): add parseUploadArgs unit tests and align verify derivation - scripts/tests/upload-aliyun-oss-assets.test.js: cover --help short-circuit, required-option validation (--bucket/--config/--prefix/empty assets), unknown options, missing option values, and trailing-slash prefix normalization. - scripts/verify-installation-release.js: switch the win-only zip branch from `startsWith('win-')` to the strict `=== 'win-x64'` check used by build-standalone-release.js, and add a comment recording that the two derivations must stay aligned. Without this the helpers would diverge the moment a non-x64 win target gets added. * test(installer): add uploadAssets integration tests with fake ossutil Add two integration tests that route a temp-directory ossutil shim onto PATH so uploadAssets actually spawns the real binary with the real cp argv: - happy-path test asserts the destination URI, `-c `, `--acl public-read`, and per-asset cp invocations land for both inputs. - failure-path test asserts non-zero ossutil exits surface as an aggregate `asset uploads failed` error after the retry budget runs out. * revert(installer): drop over-engineered ossutil/upload changes Roll back two changes from a1ef8697b/0a5d308c9 that were not justified by the actual threat model or release-pipeline needs: - .github/workflows/release.yml: restore the supported `ossutil config -k` invocation. The earlier switch to writing the .ossutilconfig INI file in-process was meant to keep the access-key out of /proc//cmdline, but GitHub-hosted runners are single-tenant ephemeral VMs where no other user can read that namespace. The benefit was theoretical; the cost was taking on a brittle dependency on ossutil's undocumented config format. - scripts/upload-aliyun-oss-assets.js: revert the uploadAssets parallel rewrite (Promise.all + spawn + setTimeout) back to the original sync spawnSync loop with retry. Release-time uploads of ~6 small files do not need parallelism, and the async refactor changed the public contract (sync→async) for no real wall-clock win. Kept from those commits: - The cleanup `if: always()` step that removes RUNNER_TEMP/.ossutilconfig at the end of the publish job. - The cross-platform sleepSync(ms) helper, since `spawnSync('sleep', ...)` still does not work on Windows runners. - The INSTALLATION_GUIDE.md doc fix. - All other round-2 fixes. Test assertions updated for the restored sync uploadAssets contract. * test(installer): cover Windows release script regressions * test(release): avoid Windows shim lookup in oss upload tests * test(installer): use stable fake Aliyun version on Windows * fix(installer): parse Aliyun latest version in batch * fix(installer): validate Aliyun latest version without findstr * fix(installer): normalize Aliyun latest version via PowerShell * fix(installer): avoid captured PowerShell output in batch latest parsing * fix(installer): normalize Aliyun latest pointer from file * test(installer): fix fake Windows curl output parsing * fix(installer): print checksum path on miss, gate hardcoded version pin in ps1 [skip ci] Address two narrow follow-ups from PR #3828 review: - build-hosted-installation-assets.js: add a HOSTED_INSTALLER_FORBIDDEN_PATTERNS guard for install-qwen-standalone.ps1. The ps1 shim has no VERSION variable of its own (it forwards @args to the .bat), so the existing default-version positive-match patterns don't apply. The new guard fails the build if a $env:QWEN_INSTALL_VERSION assignment or a --version flag prepended to the forwarded argument list ever lands in the shim. Patterns are line-anchored with /m so the documented usage examples in the header docstring stay valid. Two vitest cases cover the reject and allow paths. - install-qwen-standalone.sh / .bat: include the searched checksum-file path in the "SHA256SUMS not found" error. Operators triaging --archive failures could not tell from the prior message whether the fallback path (next to the archive) or the remote URL was being looked up. Existing test assertions updated to match the new wording. Local validation: npm run test:scripts -> 160 passed | 9 skipped (was 158 | 9). * fix: stamp release version in hosted installers and add Zip Slip protection [skip ci] 1. The hosted installation asset build now accepts --version and stamps it into the copied .sh/.bat installers so they default to the tagged release version instead of 'latest'. The release workflow passes the version. 2. install-qwen-with-source.bat now validates archive entries before calling Expand-Archive, rejecting paths with '..', leading '/', drive-rooted paths, empty names, or control characters — matching the protection already present in install-qwen-standalone.bat and the .sh installer. * fix(installer): add SOURCE to PowerShell unsafe-character validation [skip ci] The SOURCE variable is user-provided and used in path operations but was not included in the :ValidateOptions unsafe-character check. Add it alongside the other validated variables. * fix: correct copyright year 2025 -> 2026 in new files [skip ci] --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Shaojin Wen Co-authored-by: Qwen-Coder Co-authored-by: yiliang114 --- .gitattributes | 3 + .github/workflows/release.yml | 199 +- package.json | 2 + scripts/build-hosted-installation-assets.js | 350 +++ scripts/installation/INSTALLATION_GUIDE.md | 139 +- .../installation/install-qwen-standalone.bat | 1323 ++++++++ .../installation/install-qwen-standalone.ps1 | 415 +++ .../installation/install-qwen-standalone.sh | 1467 +++++++++ .../installation/install-qwen-with-source.bat | 29 +- .../uninstall-qwen-standalone.ps1 | 372 +++ .../installation/uninstall-qwen-standalone.sh | 312 ++ scripts/release-script-utils.js | 124 + scripts/tests/install-script.test.js | 2775 ++++++++++++++++- .../tests/upload-aliyun-oss-assets.test.js | 175 ++ scripts/upload-aliyun-oss-assets.js | 164 + scripts/verify-installation-release.js | 304 ++ 16 files changed, 8012 insertions(+), 141 deletions(-) create mode 100644 scripts/build-hosted-installation-assets.js create mode 100644 scripts/installation/install-qwen-standalone.bat create mode 100644 scripts/installation/install-qwen-standalone.ps1 create mode 100755 scripts/installation/install-qwen-standalone.sh create mode 100644 scripts/installation/uninstall-qwen-standalone.ps1 create mode 100755 scripts/installation/uninstall-qwen-standalone.sh create mode 100644 scripts/release-script-utils.js create mode 100644 scripts/tests/upload-aliyun-oss-assets.test.js create mode 100644 scripts/upload-aliyun-oss-assets.js create mode 100644 scripts/verify-installation-release.js diff --git a/.gitattributes b/.gitattributes index deab5ae88b..3c21e99713 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ *.bash eol=lf Makefile eol=lf +# Windows cmd.exe expects batch installers to be checked out with CRLF. +scripts/installation/install-qwen-standalone.bat text eol=crlf + # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. *.png binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59e7dac836..782c6afe92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -89,7 +89,7 @@ jobs: echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -153,13 +153,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -206,13 +206,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -247,13 +247,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -317,13 +317,13 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 + uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -386,6 +386,63 @@ jobs: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone' + - name: 'Verify Installation Release Assets' + run: 'npm run verify:installation-release -- --dir dist/standalone' + + - name: 'Package Hosted Installation Assets' + env: + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + run: 'npm run package:hosted-installation -- --out-dir dist/installation --version "${RELEASE_VERSION}"' + + - name: 'Install ossutil' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + OSSUTIL_URL: "${{ vars.OSSUTIL_URL || 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' }}" + OSSUTIL_SHA256: "${{ vars.OSSUTIL_SHA256 || 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' }}" + run: |- + set -euo pipefail + + tmp_dir="$(mktemp -d)" + curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}" -o "${tmp_dir}/ossutil.zip" + echo "${OSSUTIL_SHA256} ${tmp_dir}/ossutil.zip" | sha256sum -c - + unzip -q "${tmp_dir}/ossutil.zip" -d "${tmp_dir}" + + ossutil_path="$(find "${tmp_dir}" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)" + if [[ -z "${ossutil_path}" ]]; then + echo "::error::ossutil binary not found in downloaded archive" + exit 1 + fi + + chmod +x "${ossutil_path}" + mkdir -p "${HOME}/.local/bin" + install -m 0755 "${ossutil_path}" "${HOME}/.local/bin/ossutil" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + rm -rf "${tmp_dir}" + "${HOME}/.local/bin/ossutil" >/dev/null + + - name: 'Configure Aliyun OSS Credentials' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' + ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' + ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}" + run: |- + set -euo pipefail + + if [[ -z "${ALIYUN_OSS_ACCESS_KEY_ID}" || -z "${ALIYUN_OSS_ACCESS_KEY_SECRET}" ]]; then + echo "::error::Missing Aliyun OSS credentials. Set ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET in the production-release environment secrets." + exit 1 + fi + + ossutil config \ + -e "${ALIYUN_OSS_ENDPOINT}" \ + -i "${ALIYUN_OSS_ACCESS_KEY_ID}" \ + -k "${ALIYUN_OSS_ACCESS_KEY_SECRET}" \ + -L EN \ + -c "${RUNNER_TEMP}/.ossutilconfig" + - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- @@ -411,21 +468,139 @@ jobs: IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' run: |- + set -euo pipefail + PRERELEASE_FLAG="" if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then PRERELEASE_FLAG="--prerelease" fi + mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) + gh release create "${RELEASE_TAG}" \ dist/cli.js \ - dist/standalone/qwen-code-* \ - dist/standalone/SHA256SUMS \ + "${release_assets[@]}" \ --target "${RELEASE_BRANCH}" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "${PREVIOUS_RELEASE_TAG}" \ --generate-notes \ ${PRERELEASE_FLAG} + - name: 'Sync Release Assets to Aliyun OSS' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths) + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "releases/qwen-code/${RELEASE_TAG}" \ + "${release_assets[@]}" + + - name: 'Verify Aliyun OSS Release Assets' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' }} + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" + + - name: 'Sync Hosted Installation Assets to Aliyun OSS' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + hosted_assets=( + dist/installation/install-qwen-standalone.sh + dist/installation/install-qwen-standalone.ps1 + dist/installation/install-qwen-standalone.bat + dist/installation/uninstall-qwen-standalone.sh + dist/installation/uninstall-qwen-standalone.ps1 + dist/installation/SHA256SUMS + ) + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "installation/${RELEASE_TAG}" \ + "${hosted_assets[@]}" + node scripts/upload-aliyun-oss-assets.js \ + --bucket "${ALIYUN_OSS_BUCKET}" \ + --config "${RUNNER_TEMP}/.ossutilconfig" \ + --prefix "installation" \ + "${hosted_assets[@]}" + + - name: 'Verify Aliyun OSS Hosted Installation Assets' + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + hosted_tmp_dir="$(mktemp -d)" + trap 'rm -rf "${hosted_tmp_dir}"' EXIT + mkdir -p "${hosted_tmp_dir}/versioned" "${hosted_tmp_dir}/global" + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do + url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}" + global_url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" + curl -fsSL --connect-timeout 15 --max-time 300 "${url}" -o "${hosted_tmp_dir}/versioned/${asset}" + curl -fsSL --connect-timeout 15 --max-time 300 "${global_url}" -o "${hosted_tmp_dir}/global/${asset}" + done + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS" || true + exit 1 + } + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS" || { + echo "::error::Global hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS" || true + exit 1 + } + (cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS) + (cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS) + + - name: 'Publish Aliyun OSS Latest VERSION' + # Run last so the `latest/VERSION` pointer only flips after every + # release asset and hosted installer object has been uploaded and + # verified. If any earlier step fails, the pointer keeps referring + # to the previously-good release. + if: |- + ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' + run: |- + set -euo pipefail + + printf '%s\n' "${RELEASE_TAG}" > "${RUNNER_TEMP}/qwen-code-latest-version" + ossutil cp "${RUNNER_TEMP}/qwen-code-latest-version" "oss://${ALIYUN_OSS_BUCKET}/releases/qwen-code/latest/VERSION" -c "${RUNNER_TEMP}/.ossutilconfig" -f --acl public-read + + latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d '[:space:]')" + if [[ "${latest_version}" != "${RELEASE_TAG}" ]]; then + echo "::error::Aliyun latest VERSION points to ${latest_version}, expected ${RELEASE_TAG}" + exit 1 + fi + + - name: 'Cleanup Aliyun OSS Credentials' + if: |- + ${{ always() && needs.prepare.outputs.is_dry_run == 'false' }} + run: |- + rm -f "${RUNNER_TEMP}/.ossutilconfig" + - name: 'Create PR to merge release branch into main' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }} diff --git a/package.json b/package.json index e0106e61ff..349e6aa5cb 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,10 @@ "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", + "package:hosted-installation": "node scripts/build-hosted-installation-assets.js", "package:standalone": "node scripts/create-standalone-package.js", "package:standalone:release": "node scripts/build-standalone-release.js", + "verify:installation-release": "node scripts/verify-installation-release.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", diff --git a/scripts/build-hosted-installation-assets.js b/scripts/build-hosted-installation-assets.js new file mode 100644 index 0000000000..732e2f5548 --- /dev/null +++ b/scripts/build-hosted-installation-assets.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + fail, + isMainModule, + parseArgs, + parseSha256Sums, + sha256File, +} from './release-script-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const HOSTED_INSTALLATION_ASSETS = [ + { + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.sh'], + output: 'install-qwen-standalone.sh', + mode: 0o755, + }, + { + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.bat'], + output: 'install-qwen-standalone.bat', + lineEndings: 'crlf', + }, + { + sourcePath: ['scripts', 'installation', 'install-qwen-standalone.ps1'], + output: 'install-qwen-standalone.ps1', + }, + { + sourcePath: ['scripts', 'installation', 'uninstall-qwen-standalone.sh'], + output: 'uninstall-qwen-standalone.sh', + mode: 0o755, + }, + { + sourcePath: ['scripts', 'installation', 'uninstall-qwen-standalone.ps1'], + output: 'uninstall-qwen-standalone.ps1', + }, +]; +const HOSTED_INSTALLATION_ASSET_NAMES = HOSTED_INSTALLATION_ASSETS.map( + ({ output }) => output, +); +/** Regex guards that verify each installer script contains required behaviors. + * Build fails if a pattern is missing, preventing broken entrypoints from shipping. */ +const HOSTED_INSTALLER_BEHAVIOR_PATTERNS = { + 'install-qwen-standalone.sh': [ + { + name: 'QWEN_INSTALL_VERSION', + pattern: /QWEN_INSTALL_VERSION/, + }, + { + name: '--version parser', + pattern: /--version\)|--version=\*\)/, + }, + ], + 'install-qwen-standalone.bat': [ + { + name: 'QWEN_INSTALL_VERSION', + pattern: /QWEN_INSTALL_VERSION/, + }, + { + name: '--version parser', + pattern: /ARG_KEY!"=="--version"|"%~1"=="--version"/, + }, + ], + 'install-qwen-standalone.ps1': [ + { + name: 'argument forwarding', + pattern: /& \$qwenInstallerPath @args/, + }, + { + name: 'SHA256SUMS verification', + pattern: /SHA256SUMS/, + }, + { + name: 'bat checksum verification', + pattern: /Get-FileHash/, + }, + { + name: 'QWEN_INSTALL_VERSION documentation', + pattern: /QWEN_INSTALL_VERSION/, + }, + ], + 'uninstall-qwen-standalone.sh': [ + { + name: 'standalone directory guard', + pattern: /is_qwen_standalone_install_dir/, + }, + { + name: 'PATH cleanup', + pattern: /remove_shell_path_entry/, + }, + { + name: 'config preservation', + pattern: /QWEN_UNINSTALL_PURGE/, + }, + ], + 'uninstall-qwen-standalone.ps1': [ + { + name: 'standalone directory guard', + pattern: /Test-QwenStandaloneInstallDir/, + }, + { + name: 'PATH cleanup', + pattern: /Remove-UserPathEntry/, + }, + { + name: 'current cmd shim cleanup', + pattern: /Remove-CurrentCmdPathShim/, + }, + { + name: 'config preservation', + pattern: /QWEN_UNINSTALL_PURGE/, + }, + ], +}; +// Narrow regexes that pin the default-version assignment to `latest`. +// Substring matching alone would let the word "latest" leak in via comments +// or help text even when the actual default has been changed. The patterns +// allow whitespace flexibility but require the literal default value. +const HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS = { + 'install-qwen-standalone.sh': + /VERSION\s*=\s*"\$\{QWEN_INSTALL_VERSION:-latest\}"/, + 'install-qwen-standalone.bat': /set\s+"VERSION=latest"/, +}; +// install-qwen-standalone.ps1 is a shim that downloads the .bat and forwards +// `@args` unchanged, so it has no VERSION variable to default-pin. Guard the +// shim instead with forbidden-content patterns: any attempt to hardcode a +// specific version (either by assigning $env:QWEN_INSTALL_VERSION or by +// prepending --version to the forwarded argument list) fails the build. +// Patterns are matched per non-comment line (PowerShell line comments start +// with `#`) so the usage examples in the header docstring keep working. +const HOSTED_INSTALLER_FORBIDDEN_PATTERNS = { + 'install-qwen-standalone.ps1': [ + { + name: 'no hardcoded QWEN_INSTALL_VERSION assignment', + pattern: /^\s*\$env:QWEN_INSTALL_VERSION\s*=/m, + }, + { + name: 'no hardcoded --version prepended to forwarded args', + pattern: + /^\s*&\s+\$qwenInstallerPath\s+(?:'--version'|"--version"|--version)/m, + }, + ], +}; +// SHA256SUMS is allowed in an existing output directory because every staging +// run rewrites it from scratch after copying the hosted installer assets. +const HOSTED_INSTALLATION_OUTPUT_NAMES = new Set([ + ...HOSTED_INSTALLATION_ASSET_NAMES, + 'SHA256SUMS', +]); + +const ARG_DEFS = { + '--out-dir': { key: 'outDir', type: 'value' }, + '--version': { key: 'version', type: 'value' }, +}; + +if (isMainModule(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2), ARG_DEFS); + if (args.help) { + printUsage(); + return; + } + + const outDir = path.resolve( + args.outDir || path.join(rootDir, 'dist', 'installation'), + ); + await buildHostedInstallationAssets(outDir, { version: args.version }); +} + +function printUsage() { + console.log(`Usage: npm run package:hosted-installation -- [options] + +Stages hosted installer entrypoint assets for CDN/OSS upload. + +Options: + --out-dir PATH Output directory. Defaults to dist/installation. + --version VERSION Stamp the release version into copied installers so + they default to installing that version instead of + 'latest'. Should match the release tag (e.g. v1.2.3). + -h, --help Show this help message. +`); +} + +async function buildHostedInstallationAssets(outDir, options = {}) { + const root = options.root || rootDir; + const version = options.version || undefined; + fs.mkdirSync(outDir, { recursive: true }); + assertNoUnexpectedHostedFiles(outDir); + + for (const asset of HOSTED_INSTALLATION_ASSETS) { + const source = path.join(root, ...asset.sourcePath); + if (!fs.existsSync(source)) { + fail(`Hosted installer source asset not found: ${source}`); + } + assertHostedInstallerSource(source, asset.output); + + const destination = path.join(outDir, asset.output); + copyHostedInstallationAsset(source, destination, asset); + if (version) { + stampVersionInAsset(destination, asset.output, version); + } + if (asset.mode !== undefined) { + fs.chmodSync(destination, asset.mode); + } + } + + await writeHostedSha256Sums(outDir); + await assertHostedInstallationAssetChecksums(outDir); +} + +function assertNoUnexpectedHostedFiles(outDir) { + const unexpected = fs + .readdirSync(outDir) + .filter((entryName) => !HOSTED_INSTALLATION_OUTPUT_NAMES.has(entryName)) + .sort(); + + if (unexpected.length > 0) { + fail(`Unexpected hosted installer asset: ${unexpected.join(', ')}`); + } +} + +function copyHostedInstallationAsset(source, destination, asset) { + if (asset.lineEndings === 'crlf') { + const contents = fs.readFileSync(source, 'utf8'); + fs.writeFileSync(destination, contents.replace(/\r?\n/g, '\r\n')); + return; + } + + fs.copyFileSync(source, destination); +} + +/** + * Replaces the default 'latest' version in a copied installer asset with the + * given release version so the hosted installer installs the tagged version. + * + * @param {string} filePath - Path to the copied asset on disk. + * @param {string} assetName - Logical asset name (e.g. 'install-qwen-standalone.sh'). + * @param {string} version - Release version to stamp (e.g. 'v1.2.3' or '1.2.3'). + */ +function stampVersionInAsset(filePath, assetName, version) { + const replacements = { + 'install-qwen-standalone.sh': { + from: 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + to: `VERSION="\${QWEN_INSTALL_VERSION:-${version}}"`, + }, + 'install-qwen-standalone.bat': { + from: 'set "VERSION=latest"', + to: `set "VERSION=${version}"`, + }, + }; + + const replacement = replacements[assetName]; + if (!replacement) { + return; + } + + let contents = fs.readFileSync(filePath, 'utf8'); + if (!contents.includes(replacement.from)) { + fail( + `Cannot stamp version in ${assetName}: expected default version pattern not found`, + ); + } + contents = contents.replace(replacement.from, replacement.to); + fs.writeFileSync(filePath, contents); +} + +function assertHostedInstallerSource(source, output) { + const contents = fs.readFileSync(source, 'utf8'); + const missing = (HOSTED_INSTALLER_BEHAVIOR_PATTERNS[output] || []) + .filter(({ pattern }) => !pattern.test(contents)) + .map(({ name }) => name); + if (missing.length > 0) { + fail( + `${output} is missing hosted installer behavior: ${missing.join(', ')}`, + ); + } + + const defaultPattern = HOSTED_INSTALLER_DEFAULT_VERSION_PATTERNS[output]; + if (defaultPattern && !defaultPattern.test(contents)) { + fail( + `${output} default install version must be 'latest' for the hosted entrypoint`, + ); + } + + const forbidden = (HOSTED_INSTALLER_FORBIDDEN_PATTERNS[output] || []).filter( + ({ pattern }) => pattern.test(contents), + ); + if (forbidden.length > 0) { + fail( + `${output} must not contain: ${forbidden.map(({ name }) => name).join(', ')}`, + ); + } +} + +async function writeHostedSha256Sums(outDir) { + const lines = []; + const assets = [...HOSTED_INSTALLATION_ASSETS].sort((left, right) => + left.output.localeCompare(right.output), + ); + for (const { output } of assets) { + const hash = await sha256File(path.join(outDir, output)); + lines.push(`${hash} ${output}`); + } + fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +async function assertHostedInstallationAssetChecksums(outDir) { + const checksumsPath = path.join(outDir, 'SHA256SUMS'); + const checksums = parseSha256Sums(fs.readFileSync(checksumsPath, 'utf8')); + + for (const { output } of HOSTED_INSTALLATION_ASSETS) { + const expected = checksums.get(output); + if (!expected) { + fail(`Missing checksum entry for ${output}`); + } + + const actual = await sha256File(path.join(outDir, output)); + if (actual !== expected) { + fail( + `Checksum mismatch for ${output}: expected ${expected}, got ${actual}`, + ); + } + } +} + +export { + HOSTED_INSTALLATION_ASSETS, + HOSTED_INSTALLATION_ASSET_NAMES, + assertHostedInstallationAssetChecksums, + buildHostedInstallationAssets, +}; diff --git a/scripts/installation/INSTALLATION_GUIDE.md b/scripts/installation/INSTALLATION_GUIDE.md index 21a2b52f2b..e2d863e328 100644 --- a/scripts/installation/INSTALLATION_GUIDE.md +++ b/scripts/installation/INSTALLATION_GUIDE.md @@ -10,20 +10,23 @@ The installers are intentionally lightweight: - They try a standalone archive first by default. - They do not install Node.js, NVM, or any other Node version manager. -- They do not edit npm config or shell profiles. +- They do not edit npm config. Standalone installs may update the shell profile + or user PATH so the generated `qwen` shim is discoverable. - They do not start `qwen` automatically after installation. - They store source information in `~/.qwen/source.json` or `%USERPROFILE%\.qwen\source.json` when `--source` is provided. Standalone archives include a private Node.js runtime, so users do not need a -local Node.js installation on the standalone path. Node.js 20 or newer and npm +local Node.js installation on the standalone path. Node.js 22 or newer and npm are only required when the installer falls back to npm or when `--method npm` is used. ## Installation Scripts -- Linux/macOS: `install-qwen-with-source.sh` -- Windows: `install-qwen-with-source.bat` +- Linux/macOS: `install-qwen-standalone.sh` +- Windows: `install-qwen-standalone.ps1` +- Linux/macOS uninstall: `uninstall-qwen-standalone.sh` +- Windows uninstall: `uninstall-qwen-standalone.ps1` ## Release Artifacts @@ -36,6 +39,70 @@ GitHub releases publish these standalone archives: - `qwen-code-win-x64.zip` - `SHA256SUMS` +The new standalone-first installer scripts (`install-qwen-standalone.sh`, +`install-qwen-standalone.ps1`) are not republished per release. They are served +from a hosted installation endpoint and accept `--version` to pin a specific +standalone release. The `standalone` suffix intentionally avoids overwriting the +existing production `install-qwen.sh` / `install-qwen.bat` OSS objects during +the staged rollout. + +Public installation documentation intentionally continues to use the existing +production installer in this PR. Update README and other public quick-install +instructions in a follow-up after the standalone-suffixed hosted installers and +release archive sync have been validated in production. + +Hosted installer assets are staged separately from GitHub Release archives: + +- `install-qwen-standalone.sh` is the Linux/macOS hosted entrypoint. +- `install-qwen-standalone.ps1` is the Windows hosted entrypoint for `irm | iex`. +- `install-qwen-standalone.bat` is the Windows installer implementation used by + `install-qwen-standalone.ps1` and can also be downloaded and run directly. +- `uninstall-qwen-standalone.sh` removes Linux/macOS standalone installs. +- `uninstall-qwen-standalone.ps1` removes Windows standalone installs. + +The global standalone-suffixed OSS entrypoints are maintained under +`installation/install-qwen-standalone.sh`, +`installation/install-qwen-standalone.ps1`, +`installation/install-qwen-standalone.bat`, +`installation/uninstall-qwen-standalone.sh`, and +`installation/uninstall-qwen-standalone.ps1`. + +Build them with: + +```bash +npm run package:hosted-installation -- --out-dir dist/installation +``` + +The staged `install-qwen-standalone.sh`, `install-qwen-standalone.ps1`, +`install-qwen-standalone.bat`, `uninstall-qwen-standalone.sh`, and +`uninstall-qwen-standalone.ps1` files map to the standalone-suffixed hosted URLs +shown above. The staging command also writes `SHA256SUMS` for upload +verification. During a non-dry-run stable release, the publish workflow uploads +a byte-for-byte snapshot to `installation/vX.Y.Z/` for audit and rollback, and +also refreshes the global `installation/` entrypoint objects so `curl | bash` +links keep resolving without a version segment. The versioned snapshot lets you +roll back by repointing the global objects to a previous tag if a regression is +caught after publish. The hosted +installers intentionally default to `latest`; on Aliyun OSS this means reading +`releases/qwen-code/latest/VERSION` first, then downloading the matching +versioned release directory. Use `--version` or `QWEN_INSTALL_VERSION` to pin a +standalone release directly. + +Configure the `production-release` GitHub environment with these required +secrets before enabling OSS sync: + +- `ALIYUN_OSS_ACCESS_KEY_ID` +- `ALIYUN_OSS_ACCESS_KEY_SECRET` + +The workflow defaults to the production OSS bucket and Hangzhou endpoint. Set +these GitHub Actions variables only when the bucket, endpoint, or public base +URL changes: + +- `ALIYUN_OSS_BUCKET` (default: `qwen-code-assets`) +- `ALIYUN_OSS_ENDPOINT` (default: `https://oss-cn-hangzhou.aliyuncs.com`) +- `ALIYUN_OSS_PUBLIC_BASE_URL` (default: + `https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com`) + Archive layout: ```text @@ -62,13 +129,13 @@ The default method is `detect`: You can force a method: ```bash -bash install-qwen-with-source.sh --method standalone -bash install-qwen-with-source.sh --method npm +bash install-qwen-standalone.sh --method standalone +bash install-qwen-standalone.sh --method npm ``` ```bat -install-qwen-with-source.bat --method standalone -install-qwen-with-source.bat --method npm +install-qwen-standalone.bat --method standalone +install-qwen-standalone.bat --method npm ``` ## Optional Native Modules @@ -86,20 +153,20 @@ modules for the current machine. ```bash # Default: standalone archive with npm fallback -bash install-qwen-with-source.sh +bash install-qwen-standalone.sh # Record a source value -bash install-qwen-with-source.sh --source github +bash install-qwen-standalone.sh --source github # Use npm explicitly -bash install-qwen-with-source.sh --method npm --registry https://registry.npmjs.org +bash install-qwen-standalone.sh --method npm --registry https://registry.npmjs.org # Use the Aliyun standalone mirror -bash install-qwen-with-source.sh --mirror aliyun +bash install-qwen-standalone.sh --mirror aliyun # Install an offline archive # SHA256SUMS must be in the same directory. -bash install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz +bash install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz ``` Standalone installs to: @@ -110,24 +177,35 @@ Standalone installs to: Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_PARENT`, `QWEN_INSTALL_LIB_DIR`, or `QWEN_INSTALL_BIN_DIR` when needed. +Uninstall a standalone Linux/macOS install: + +```bash +curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh | bash +``` + +The uninstaller removes only the standalone runtime, generated `qwen` wrapper, +and installer-managed shell PATH block. It preserves `~/.qwen` by default. Set +`QWEN_UNINSTALL_PURGE=1` to remove `~/.qwen/source.json`; other config and auth +files are still preserved. + ## Windows Usage ```bat REM Default: standalone archive with npm fallback -install-qwen-with-source.bat +install-qwen-standalone.bat REM Record a source value -install-qwen-with-source.bat --source github +install-qwen-standalone.bat --source github REM Use npm explicitly -install-qwen-with-source.bat --method npm --registry https://registry.npmjs.org +install-qwen-standalone.bat --method npm --registry https://registry.npmjs.org REM Use the Aliyun standalone mirror -install-qwen-with-source.bat --mirror aliyun +install-qwen-standalone.bat --mirror aliyun REM Install an offline archive REM SHA256SUMS must be in the same directory. -install-qwen-with-source.bat --archive qwen-code-win-x64.zip +install-qwen-standalone.bat --archive qwen-code-win-x64.zip ``` Standalone installs to: @@ -140,6 +218,18 @@ Override with `QWEN_INSTALL_ROOT`, `QWEN_INSTALL_LIB_DIR`, or Restart the terminal if `qwen` is not immediately available on PATH. +Uninstall a standalone Windows install: + +```bat +powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex" +``` + +The uninstaller removes only the standalone runtime, generated `qwen.cmd` +wrapper, user PATH entry, and the current-session `cmd.exe` shim created by the +hosted PowerShell installer. It preserves `%USERPROFILE%\.qwen` by default. Set +`QWEN_UNINSTALL_PURGE=1` to remove `%USERPROFILE%\.qwen\source.json`; other +config and auth files are still preserved. + ## Mirrors and Overrides Options: @@ -165,9 +255,12 @@ Use `--base-url` for private mirrors. The URL must contain `qwen-code-` archives and `SHA256SUMS` in the same directory. Custom base URLs must use `https://`. -For Aliyun OSS/CDN, release publishing must upload byte-identical artifacts to -both the versioned directory, for example `v0.16.0/`, and the `latest/` -directory used by the default installer path. +For Aliyun OSS/CDN, release publishing uploads byte-identical artifacts to the +versioned directory, for example `releases/qwen-code/vX.Y.Z/`. Stable releases +also update the small `releases/qwen-code/latest/VERSION` pointer used by the +default installer path. The installer reads that pointer and then downloads the +versioned archive plus the versioned `SHA256SUMS`; nightly and preview releases +do not update the pointer. ## Supported Source Values @@ -199,7 +292,7 @@ unreadable source files are ignored. ## Manual Installation -If source tracking is not needed and Node.js 20 or newer is already available: +If source tracking is not needed and Node.js 22 or newer is already available: ```bash npm install -g @qwen-code/qwen-code@latest @@ -220,7 +313,7 @@ fails so that automation can detect the missing artifact. ### Node.js Missing or Too Old -This only blocks npm installation. Install or activate Node.js 20 or newer, then +This only blocks npm installation. Install or activate Node.js 22 or newer, then rerun the installer with `--method npm` or let `detect` fall back again. ### npm Missing diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat new file mode 100644 index 0000000000..94c224eda9 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.bat @@ -0,0 +1,1323 @@ +@echo off +REM Qwen Code Installation Script +REM Installs Qwen Code from a standalone archive when available, with npm fallback. +REM This script intentionally does not install Node.js or change npm config. + +setlocal enabledelayedexpansion + +call :ValidateRawEnvironmentOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +set "SOURCE=unknown" +set "METHOD=" +if defined QWEN_INSTALL_METHOD set "METHOD=!QWEN_INSTALL_METHOD!" +set "MIRROR=auto" +if defined QWEN_INSTALL_MIRROR set "MIRROR=!QWEN_INSTALL_MIRROR!" +set "NO_MODIFY_PATH=0" +if defined QWEN_NO_MODIFY_PATH set "NO_MODIFY_PATH=!QWEN_NO_MODIFY_PATH!" +set "BASE_URL=" +if defined QWEN_INSTALL_BASE_URL set "BASE_URL=!QWEN_INSTALL_BASE_URL!" +set "ARCHIVE_PATH=" +if defined QWEN_INSTALL_ARCHIVE set "ARCHIVE_PATH=!QWEN_INSTALL_ARCHIVE!" +set "VERSION=latest" +if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!" +set "NPM_REGISTRY=https://registry.npmmirror.com" +if defined QWEN_NPM_REGISTRY set "NPM_REGISTRY=!QWEN_NPM_REGISTRY!" +if defined LOCALAPPDATA ( + set "INSTALL_BASE=!LOCALAPPDATA!\qwen-code" +) else ( + set "INSTALL_BASE=!USERPROFILE!\AppData\Local\qwen-code" +) +if defined QWEN_INSTALL_ROOT set "INSTALL_BASE=!QWEN_INSTALL_ROOT!" +set "INSTALL_DIR=!INSTALL_BASE!\qwen-code" +if defined QWEN_INSTALL_LIB_DIR set "INSTALL_DIR=!QWEN_INSTALL_LIB_DIR!" +set "INSTALL_BIN_DIR=!INSTALL_BASE!\bin" +if defined QWEN_INSTALL_BIN_DIR set "INSTALL_BIN_DIR=!QWEN_INSTALL_BIN_DIR!" + +REM Parse flags before any network or filesystem work. +:parse_args +if "%~1"=="" goto end_parse +set "ARG_RAW=%~1" +set "ARG_KEY=%~1" +set "ARG_VALUE=" +set "ARG_HAS_INLINE_VALUE=0" +for /f "tokens=1,* delims==" %%A in ("%~1") do ( + set "ARG_KEY=%%~A" + set "ARG_VALUE=%%~B" +) +if not "!ARG_KEY!"=="!ARG_RAW!" set "ARG_HAS_INLINE_VALUE=1" +if /i "!ARG_KEY!"=="--source" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --source requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="-s" ( + if "%~2"=="" ( + echo ERROR: -s requires a value + exit /b 1 + ) + set "SOURCE=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--method" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --method requires a value + exit /b 1 + ) + set "METHOD=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--mirror" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --mirror requires a value + exit /b 1 + ) + set "MIRROR=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--base-url" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --base-url requires a value + exit /b 1 + ) + set "BASE_URL=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--archive" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --archive requires a value + exit /b 1 + ) + set "ARCHIVE_PATH=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--version" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --version requires a value + exit /b 1 + ) + set "VERSION=%~2" + shift + shift + goto parse_args +) +if /i "!ARG_KEY!"=="--registry" ( + if "!ARG_HAS_INLINE_VALUE!"=="1" ( + if "!ARG_VALUE!"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=!ARG_VALUE!" + shift + goto parse_args + ) + if "%~2"=="" ( + echo ERROR: --registry requires a value + exit /b 1 + ) + set "NPM_REGISTRY=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--no-modify-path" ( + set "NO_MODIFY_PATH=1" + shift + goto parse_args +) +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--help" goto usage + +echo ERROR: Unknown option. +echo. +goto usage_error + +:end_parse + +call :ValidateOptions +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :PrintHeader + +REM Discover all qwen executables on disk BEFORE we install. We can't +REM reliably simulate the user's PATH ordering, so enumerate well-known +REM per-tool bin directories plus everything `where qwen` returns. +call :CreateTempFile "qwen-pre-install" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "PRE_INSTALL_QWENS_FILE=!TEMP_FILE!" +rem Avoid `call echo` here: `call` triggers an extra parse pass on the +rem expanded path, so a directory containing &/|/<,>/etc. would be re-evaluated +rem as command separators. Plain `echo` writes the literal value. +for /f "delims=" %%i in ('where qwen 2^>nul') do echo %%i>>"!PRE_INSTALL_QWENS_FILE!" +for %%c in ( + "!USERPROFILE!\.opencode\bin\qwen.cmd" + "!APPDATA!\npm\qwen.cmd" + "!USERPROFILE!\.bun\bin\qwen.cmd" + "!LOCALAPPDATA!\bun\bin\qwen.cmd" + "!LOCALAPPDATA!\qwen-code\bin\qwen.cmd" +) do if exist %%c echo %%~c>>"!PRE_INSTALL_QWENS_FILE!" +for /f "delims=" %%i in ('npm prefix -g 2^>nul') do ( + if exist "%%i\qwen.cmd" echo %%i\qwen.cmd>>"!PRE_INSTALL_QWENS_FILE!" +) +set "PRE_INSTALL_QWENS_LIST=" +if exist "!PRE_INSTALL_QWENS_FILE!" ( + for /f "delims=" %%i in ('sort "!PRE_INSTALL_QWENS_FILE!" 2^>nul ^| findstr /v "^$"') do ( + if "!PRE_INSTALL_QWENS_LIST!"=="" ( + set "PRE_INSTALL_QWENS_LIST=%%i" + ) else ( + echo !PRE_INSTALL_QWENS_LIST! | findstr /i /c:"%%i" >nul 2>&1 + if errorlevel 1 set "PRE_INSTALL_QWENS_LIST=!PRE_INSTALL_QWENS_LIST!|%%i" + ) + ) + del /f /q "!PRE_INSTALL_QWENS_FILE!" >nul 2>&1 +) + +REM Dispatch after validation; detect falls back to npm only when unavailable. +if /i "!METHOD!"=="standalone" ( + call :InstallStandalone + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" + exit /b 0 +) + +if /i "!METHOD!"=="npm" ( + call :InstallNpm + if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! + call :PrintFinalInstructions "" "" "npm" + endlocal + exit /b 0 +) + +call :InstallStandalone +set "STANDALONE_STATUS=!ERRORLEVEL!" +if !STANDALONE_STATUS! EQU 0 ( + call :PrintFinalInstructions "!INSTALL_BIN_DIR!" "!INSTALL_DIR!" "standalone" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" + exit /b 0 +) + +if !STANDALONE_STATUS! EQU 2 ( + echo WARNING: Falling back to npm installation. + call :InstallNpm + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Standalone archive was unavailable before npm fallback; npm fallback also failed. + echo WARNING: Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm. + exit /b !ERRORLEVEL! + ) + call :PrintFinalInstructions "" "" "npm" + endlocal + exit /b 0 +) + +echo WARNING: Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure. +exit /b !STANDALONE_STATUS! + +:usage +call :PrintUsage +exit /b 0 + +:usage_error +call :PrintUsage +exit /b 1 + +:PrintUsage +echo Qwen Code Installer +echo. +echo Usage: install-qwen-standalone.bat [OPTIONS] +echo. +echo Options: +echo -s, --source SOURCE Record the installation source. +echo Only letters, numbers, dot, underscore, and dash are allowed. +echo --method METHOD Install method: detect, standalone, or npm. +echo --mirror MIRROR Standalone archive mirror: auto, github, or aliyun. +echo Defaults to QWEN_INSTALL_MIRROR or auto, which picks +echo whichever responds first via a HEAD probe. +echo --base-url URL Override standalone archive base URL. +echo --archive PATH Install from a local standalone archive. +echo --version VERSION Standalone release version. Defaults to latest. +echo --registry REGISTRY npm registry to use. +echo Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com +echo --no-modify-path Do not prepend INSTALL_BIN_DIR to user PATH even +echo when a shadowing 'qwen' is detected. +echo -h, --help Show this help message. +exit /b 0 + +:PrintHeader +set "DISPLAY_VERSION=!VERSION!" +if /i not "!DISPLAY_VERSION!"=="latest" ( + if /i "!DISPLAY_VERSION:~0,1!"=="v" set "DISPLAY_VERSION=!DISPLAY_VERSION:~1!" +) +echo Installing Qwen Code version: !DISPLAY_VERSION! +exit /b 0 + +:ValidateRawEnvironmentOptions +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); $rawNames = @('QWEN_INSTALL_METHOD','QWEN_INSTALL_MIRROR','QWEN_NO_MODIFY_PATH','QWEN_INSTALL_BASE_URL','QWEN_INSTALL_ARCHIVE','QWEN_INSTALL_VERSION','QWEN_NPM_REGISTRY','QWEN_INSTALL_ROOT','QWEN_INSTALL_LIB_DIR','QWEN_INSTALL_BIN_DIR','QWEN_INSTALL_GITHUB_REPO','QWEN_INSTALL_CURL_EXE'); foreach ($name in $rawNames) { $value = [Environment]::GetEnvironmentVariable($name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }; exit 0" +if %ERRORLEVEL% EQU 0 exit /b 0 +echo ERROR: installer options contain unsafe command characters. +exit /b 1 + +:ValidateOptions +if "!METHOD!"=="" set "METHOD=detect" + +set "QWEN_VALIDATE_METHOD=!METHOD!" +set "QWEN_VALIDATE_MIRROR=!MIRROR!" +set "QWEN_VALIDATE_BASE_URL=!BASE_URL!" +set "QWEN_VALIDATE_ARCHIVE_PATH=!ARCHIVE_PATH!" +set "QWEN_VALIDATE_VERSION=!VERSION!" +set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" +set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" +set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +call :CreateTempFile "qwen-validate-options" ".ps1" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=!TEMP_FILE!" +> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $names = @('METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo foreach ($name in $names) { +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name) +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo } +>> "!QWEN_VALIDATE_OPTIONS_SCRIPT!" echo exit 0 +powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!" +set "PS_STATUS=%ERRORLEVEL%" +del /F /Q "!QWEN_VALIDATE_OPTIONS_SCRIPT!" >nul 2>&1 +set "QWEN_VALIDATE_OPTIONS_SCRIPT=" +set "QWEN_VALIDATE_METHOD=" +set "QWEN_VALIDATE_MIRROR=" +set "QWEN_VALIDATE_BASE_URL=" +set "QWEN_VALIDATE_ARCHIVE_PATH=" +set "QWEN_VALIDATE_VERSION=" +set "QWEN_VALIDATE_NPM_REGISTRY=" +set "QWEN_VALIDATE_INSTALL_BASE=" +set "QWEN_VALIDATE_INSTALL_DIR=" +set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" +if %PS_STATUS% NEQ 0 ( + echo ERROR: installer options contain unsafe command characters. + exit /b 1 +) + +if "!INSTALL_BASE!"=="" ( + echo ERROR: QWEN_INSTALL_ROOT must not be empty. + exit /b 1 +) +if "!INSTALL_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_LIB_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BIN_DIR!"=="" ( + echo ERROR: QWEN_INSTALL_BIN_DIR must not be empty. + exit /b 1 +) +if "!INSTALL_BASE:~1,2!"==":\" goto validate_install_base_ok +if "!INSTALL_BASE:~1,2!"==":/" goto validate_install_base_ok +if "!INSTALL_BASE:~0,2!"=="\\" goto validate_install_base_ok +echo ERROR: QWEN_INSTALL_ROOT must be an absolute path. +exit /b 1 +:validate_install_base_ok +if "!INSTALL_DIR:~1,2!"==":\" goto validate_install_dir_ok +if "!INSTALL_DIR:~1,2!"==":/" goto validate_install_dir_ok +if "!INSTALL_DIR:~0,2!"=="\\" goto validate_install_dir_ok +echo ERROR: QWEN_INSTALL_LIB_DIR must be an absolute path. +exit /b 1 +:validate_install_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":\" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~1,2!"==":/" goto validate_install_bin_dir_ok +if "!INSTALL_BIN_DIR:~0,2!"=="\\" goto validate_install_bin_dir_ok +echo ERROR: QWEN_INSTALL_BIN_DIR must be an absolute path. +exit /b 1 +:validate_install_bin_dir_ok + +if /i "!METHOD!"=="detect" goto validate_method_ok +if /i "!METHOD!"=="standalone" goto validate_method_ok +if /i "!METHOD!"=="npm" goto validate_method_ok +echo ERROR: --method must be detect, standalone, or npm. +exit /b 1 + +:validate_method_ok +if /i "!MIRROR!"=="github" goto validate_mirror_ok +if /i "!MIRROR!"=="aliyun" goto validate_mirror_ok +if /i "!MIRROR!"=="auto" goto validate_mirror_ok +echo ERROR: --mirror must be auto, github, or aliyun. +exit /b 1 + +:validate_mirror_ok +call :ValidateHttpsUrlVar "BASE_URL" "--base-url" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry" +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateVersion +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateGithubRepo +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :ValidateSource +exit /b %ERRORLEVEL% + +:ValidateHttpsUrlVar +set "URL_VALUE=!%~1!" +set "URL_OPTION=%~2" +if "!URL_VALUE!"=="" exit /b 0 +if /i "!URL_VALUE:~0,8!"=="https://" exit /b 0 + +echo ERROR: !URL_OPTION! must start with https:// +exit /b 1 + +:ValidateVersion +if /i "!VERSION!"=="latest" exit /b 0 +set "QWEN_VERSION_VALUE=!VERSION!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_VERSION_VALUE; if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { exit 0 }; exit 1" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_VERSION_VALUE=" +if %PS_STATUS% EQU 0 exit /b 0 +echo ERROR: --version must be 'latest' or a semver string. +exit /b 1 + +:ValidateGithubRepo +if not defined QWEN_INSTALL_GITHUB_REPO exit /b 0 +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = $env:QWEN_INSTALL_GITHUB_REPO; if ($value -match '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { exit 0 }; exit 1" +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: QWEN_INSTALL_GITHUB_REPO must be in owner/repo format. +exit /b 1 + +:ValidateSource +if "!SOURCE!"=="unknown" exit /b 0 +echo(!SOURCE!| findstr /R /C:"^[A-Za-z][A-Za-z0-9._-]*$" >nul +if %ERRORLEVEL% EQU 0 exit /b 0 + +echo ERROR: --source may only contain letters, numbers, dot, underscore, or dash. +exit /b 1 + +:DetectTarget +set "TARGET=" +rem Keep :DetectTarget in sync with RELEASE_TARGETS in scripts/build-standalone-release.js. +rem RELEASE_TARGETS currently has no win-arm64 entry, so ARM64 falls through +rem to the unsupported-architecture branch and the caller can fall back to npm. +if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITECTURE!"=="X64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="AMD64" set "TARGET=win-x64" +if /i "!PROCESSOR_ARCHITEW6432!"=="X64" set "TARGET=win-x64" +if "!TARGET!"=="" ( + echo WARNING: Standalone archive is not available for this Windows architecture. + exit /b 1 +) +exit /b 0 + +:ReleaseVersionPath +if /i "!VERSION!"=="latest" ( + set "VERSION_PATH=latest" + exit /b 0 +) +set "VERSION_PATH=!VERSION!" +if /i "!VERSION_PATH:~0,1!"=="v" exit /b 0 +set "VERSION_PATH=v!VERSION_PATH!" +exit /b 0 + +:GithubBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_GH_BASE_URL +set "QWEN_GH_REPO=QwenLM/qwen-code" +if defined QWEN_INSTALL_GITHUB_REPO set "QWEN_GH_REPO=!QWEN_INSTALL_GITHUB_REPO!" +if /i "%~1"=="latest" ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/latest/download" +) else ( + set "QWEN_GH_BASE_URL=https://github.com/!QWEN_GH_REPO!/releases/download/%~1" +) +set "QWEN_GH_REPO=" +exit /b 0 + +:AliyunBaseUrlForVersion +rem args: %~1=version_path → sets QWEN_OSS_BASE_URL +set "QWEN_OSS_BASE_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/%~1" +exit /b 0 + +:AliyunLatestVersionUrl +set "QWEN_OSS_LATEST_VERSION_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" +exit /b 0 + +:RaceMirrorHead +rem args: %~1=timeout_seconds %~2=gh_url %~3=oss_url +rem Sets QWEN_RACE_RESULT to "aliyun", "github", or "timeout". Sequential +rem (OSS first, GH fallback) keeps the PowerShell snippet small; a true +rem parallel race adds a lot of escaping for marginal speedup since OSS HEAD +rem is sub-second when reachable. Caller decides what to do with "timeout" +rem (currently: log it and fall back to github). +set "QWEN_RACE_TIMEOUT=%~1" +set "QWEN_RACE_GH_URL=%~2" +set "QWEN_RACE_OSS_URL=%~3" +set "QWEN_RACE_RESULT=timeout" +for /f "delims=" %%r in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='SilentlyContinue'; $t=[int]$env:QWEN_RACE_TIMEOUT; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Probe($url) { try { $r = [Net.WebRequest]::Create($url); $r.Method = 'HEAD'; $r.Timeout = $t * 1000; if ($r -is [Net.HttpWebRequest]) { $r.AllowAutoRedirect = $true }; $resp = $r.GetResponse(); $resp.Close(); return $true } catch { return $false } }; if (Probe $env:QWEN_RACE_OSS_URL) { Write-Output 'aliyun'; exit 0 } elseif (Probe $env:QWEN_RACE_GH_URL) { Write-Output 'github'; exit 0 } else { Write-Output 'timeout'; exit 0 }"') do set "QWEN_RACE_RESULT=%%r" +set "QWEN_RACE_TIMEOUT=" +set "QWEN_RACE_GH_URL=" +set "QWEN_RACE_OSS_URL=" +exit /b 0 + +:StandaloneBaseUrl +set "STANDALONE_VERSION_PATH=" +if not "!BASE_URL!"=="" ( + set "STANDALONE_BASE_URL=!BASE_URL!" + exit /b 0 +) + +call :ReleaseVersionPath +set "STANDALONE_VERSION_PATH=!VERSION_PATH!" + +if /i "!MIRROR!"=="auto" ( + call :GithubBaseUrlForVersion "!VERSION_PATH!" + if /i "!VERSION_PATH!"=="latest" ( + call :AliyunLatestVersionUrl + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_LATEST_VERSION_URL!" + ) else ( + call :AliyunBaseUrlForVersion "!VERSION_PATH!" + set "QWEN_OSS_PROBE_URL=!QWEN_OSS_BASE_URL!/SHA256SUMS" + ) + call :RaceMirrorHead 2 "!QWEN_GH_BASE_URL!/SHA256SUMS" "!QWEN_OSS_PROBE_URL!" + if /i "!QWEN_RACE_RESULT!"=="timeout" ( + echo INFO: Mirror auto-selection timed out; defaulting to github. + set "MIRROR=github" + ) else ( + set "MIRROR=!QWEN_RACE_RESULT!" + echo INFO: Mirror auto-selected via HEAD probe: !QWEN_RACE_RESULT! + ) + set "QWEN_GH_BASE_URL=" + set "QWEN_OSS_BASE_URL=" + set "QWEN_OSS_LATEST_VERSION_URL=" + set "QWEN_OSS_PROBE_URL=" + set "QWEN_RACE_RESULT=" +) + +if /i "!MIRROR!"=="aliyun" ( + call :ResolveAliyunVersionPath "!VERSION_PATH!" + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "STANDALONE_VERSION_PATH=!RESOLVED_VERSION_PATH!" + call :AliyunBaseUrlForVersion "!RESOLVED_VERSION_PATH!" + set "STANDALONE_BASE_URL=!QWEN_OSS_BASE_URL!" + set "QWEN_OSS_BASE_URL=" + set "RESOLVED_VERSION_PATH=" + exit /b 0 +) + +call :GithubBaseUrlForVersion "!VERSION_PATH!" +set "STANDALONE_BASE_URL=!QWEN_GH_BASE_URL!" +set "QWEN_GH_BASE_URL=" +exit /b 0 + +:UseGithubFallbackBaseUrl +set "STANDALONE_BASE_URL=!GITHUB_FALLBACK_BASE_URL!" +set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" +set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" +set "GITHUB_FALLBACK_BASE_URL=" +set "MIRROR=github" +exit /b 0 + +:MaybeUpdateUserPath +rem args: %~1=install_bin_dir +rem Prepend the install dir to the user-level PATH (HKCU\Environment) via +rem [Environment]::SetEnvironmentVariable. Idempotent: skips if the dir is +rem already on the user PATH. Uses PowerShell rather than `setx` because setx +rem truncates PATH at 1024 chars, which can silently mangle long PATHs. +set "QWEN_NEW_BIN=%~1" +if "!QWEN_NEW_BIN!"=="" exit /b 0 +powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin = $env:QWEN_NEW_BIN; $userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrEmpty($userPath)) { $userPath = '' }; $entries = $userPath -split ';' | Where-Object { $_ -ne '' }; if ($entries -contains $bin) { Write-Output ('INFO: User PATH already contains ' + $bin + ' (skipping).'); exit 0 }; $newPath = (@($bin) + $entries) -join ';'; [Environment]::SetEnvironmentVariable('Path', $newPath, 'User'); Write-Output ('SUCCESS: Prepended ' + $bin + ' to your user PATH.'); Write-Output 'INFO: Open a NEW command prompt for the change to take effect.'" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_NEW_BIN=" +exit /b %PS_STATUS% + +:UrlExists +set "QWEN_CHECK_URL=%~1" +rem Prefer Tls12+Tls13; fall back to Tls12 alone on older .NET Framework where the Tls13 enum is missing. +rem AllowAutoRedirect=true is required for GitHub release asset URLs which return HTTP 302. +powershell -NoProfile -ExecutionPolicy Bypass -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; function Test-QwenUrl($method, $range) { try { $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL); $request.Timeout = 10000; $request.Method = $method; if ($range) { $request.Headers.Add('Range', 'bytes=0-0') }; if ($request -is [Net.HttpWebRequest]) { $request.ReadWriteTimeout = 30000; $request.AllowAutoRedirect = $true }; $response = $request.GetResponse(); $response.Close(); return $true } catch { return $false } }; if (Test-QwenUrl 'HEAD' $false) { exit 0 }; if (Test-QwenUrl 'GET' $true) { exit 0 }; exit 1" >nul 2>&1 +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_CHECK_URL=" +exit /b %PS_STATUS% + +:DownloadFile +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +rem Prefer curl.exe -# for a hash-mark progress bar (Windows 10+ includes it); +rem fall back to Invoke-WebRequest (which shows its own progress bar). +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:DownloadFileQuiet +set "QWEN_DOWNLOAD_URL=%~1" +set "QWEN_DOWNLOAD_DEST=%~2" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $curl = $env:QWEN_INSTALL_CURL_EXE; if ([string]::IsNullOrEmpty($curl)) { $cmd = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $cmd) { $curl = $cmd.Source } }; if (-not [string]::IsNullOrEmpty($curl)) { & $curl --connect-timeout 15 --max-time 300 --retry 2 -fsSLo $env:QWEN_DOWNLOAD_DEST $env:QWEN_DOWNLOAD_URL; if ($LASTEXITCODE -ne 0) { throw ('curl.exe download failed (exit code ' + $LASTEXITCODE + ')') }; exit 0 }; try { $ProgressPreference = 'SilentlyContinue'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }; Invoke-WebRequest -Uri $env:QWEN_DOWNLOAD_URL -OutFile $env:QWEN_DOWNLOAD_DEST -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300; exit 0 } catch { [Console]::Error.WriteLine('Download error: ' + $_.Exception.Message); exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_DOWNLOAD_URL=" +set "QWEN_DOWNLOAD_DEST=" +exit /b %PS_STATUS% + +:ResolveAliyunVersionPath +set "RESOLVED_VERSION_PATH=" +if /i not "%~1"=="latest" ( + set "RESOLVED_VERSION_PATH=%~1" + exit /b 0 +) + +call :AliyunLatestVersionUrl +call :CreateTempFile "qwen-code-latest-version" +if !ERRORLEVEL! NEQ 0 exit /b 1 +set "TEMP_VERSION_FILE=!TEMP_FILE!" + +call :DownloadFileQuiet "!QWEN_OSS_LATEST_VERSION_URL!" "!TEMP_VERSION_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 + set "TEMP_VERSION_FILE=" + set "QWEN_OSS_LATEST_VERSION_URL=" + echo WARNING: Failed to resolve Aliyun latest VERSION pointer. + exit /b 1 +) + +set "NORMALIZED_VERSION_FILE=!TEMP_VERSION_FILE!.normalized" +set "QWEN_VERSION_POINTER_FILE=!TEMP_VERSION_FILE!" +set "QWEN_NORMALIZED_VERSION_FILE=!NORMALIZED_VERSION_FILE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$value = [IO.File]::ReadAllText($env:QWEN_VERSION_POINTER_FILE).Trim(); $value = $value.Trim([char]0xfeff); if ($value -match '^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$') { if (-not $value.StartsWith('v')) { $value = 'v' + $value }; [IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE, $value, [Text.UTF8Encoding]::new($false)); exit 0 }; exit 1" +if !ERRORLEVEL! EQU 0 ( + for /f "usebackq delims=" %%V in ("!NORMALIZED_VERSION_FILE!") do if not defined RESOLVED_VERSION_PATH set "RESOLVED_VERSION_PATH=%%V" +) +set "QWEN_VERSION_POINTER_FILE=" +set "QWEN_NORMALIZED_VERSION_FILE=" +if exist "!NORMALIZED_VERSION_FILE!" del /F /Q "!NORMALIZED_VERSION_FILE!" >nul 2>&1 +set "NORMALIZED_VERSION_FILE=" +if exist "!TEMP_VERSION_FILE!" del /F /Q "!TEMP_VERSION_FILE!" >nul 2>&1 +set "TEMP_VERSION_FILE=" +set "QWEN_OSS_LATEST_VERSION_URL=" + +if "!RESOLVED_VERSION_PATH!"=="" ( + echo ERROR: Aliyun latest VERSION pointer is not a valid semver value. + exit /b 1 +) + +echo INFO: Resolved Aliyun latest to !RESOLVED_VERSION_PATH!. +exit /b 0 + +:VerifyChecksum +set "ARCHIVE_FILE=%~1" +set "CHECKSUM_SOURCE=%~2" +set "ARCHIVE_NAME=%~3" +set "CHECKSUM_FILE=!CHECKSUM_SOURCE!" +set "TEMP_CHECKSUM=" +if "!CHECKSUM_FILE!"=="" ( + for %%I in ("!ARCHIVE_FILE!") do set "CHECKSUM_FILE=%%~dpISHA256SUMS" +) else ( + if /i "!CHECKSUM_FILE:~0,8!"=="https://" ( + call :CreateTempFile "qwen-code-checksums" + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "TEMP_CHECKSUM=!TEMP_FILE!" + call :DownloadFile "!CHECKSUM_FILE!" "!TEMP_CHECKSUM!" + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_CHECKSUM!" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + echo ERROR: Could not download SHA256SUMS for checksum verification. + exit /b 1 + ) + set "CHECKSUM_FILE=!TEMP_CHECKSUM!" + ) +) + +if not exist "!CHECKSUM_FILE!" ( + echo ERROR: SHA256SUMS not found at !CHECKSUM_FILE!; cannot verify archive. + exit /b 1 +) + +set "EXPECTED_HASH=" +for /f "usebackq tokens=1,2" %%H in ("!CHECKSUM_FILE!") do ( + set "CHECKSUM_HASH=%%H" + set "CHECKSUM_NAME=%%I" + if "!CHECKSUM_NAME:~0,1!"=="*" set "CHECKSUM_NAME=!CHECKSUM_NAME:~1!" + if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!" ( + if "!EXPECTED_HASH!"=="" set "EXPECTED_HASH=!CHECKSUM_HASH!" + ) +) + +if "!EXPECTED_HASH!"=="" ( + if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + echo ERROR: Checksum entry for !ARCHIVE_NAME! not found. + exit /b 1 +) + +set "ACTUAL_HASH=" +set "QWEN_HASH_FILE=!ARCHIVE_FILE!" +for /f "delims=" %%H in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; (Get-FileHash -Algorithm SHA256 -LiteralPath $env:QWEN_HASH_FILE).Hash" 2^>nul') do ( + if "!ACTUAL_HASH!"=="" set "ACTUAL_HASH=%%H" +) +set "QWEN_HASH_FILE=" + +if not "!TEMP_CHECKSUM!"=="" del /F /Q "!TEMP_CHECKSUM!" >nul 2>&1 + +if "!ACTUAL_HASH!"=="" ( + echo ERROR: Could not calculate SHA-256 checksum for archive. + exit /b 1 +) + +if /i not "!EXPECTED_HASH!"=="!ACTUAL_HASH!" ( + echo ERROR: Checksum mismatch for !ARCHIVE_NAME!: expected !EXPECTED_HASH!, got !ACTUAL_HASH!. + exit /b 1 +) + +echo SUCCESS: Checksum verified for !ARCHIVE_NAME!. +exit /b 0 + +:InstallStandalone +set "TEMP_DIR=" +set "CHECKSUM_SOURCE=" + +REM Resolve the archive from a local file or from the configured release mirror. +if not "!ARCHIVE_PATH!"=="" ( + set "ARCHIVE_FILE=!ARCHIVE_PATH!" + for %%I in ("!ARCHIVE_FILE!") do set "ARCHIVE_NAME=%%~nxI" + if not exist "!ARCHIVE_FILE!" ( + echo ERROR: Standalone archive not found: !ARCHIVE_FILE! + exit /b 1 + ) +) else ( + call :DetectTarget + if !ERRORLEVEL! NEQ 0 exit /b 2 + + set "ARCHIVE_NAME=qwen-code-!TARGET!.zip" + set "REQUESTED_MIRROR=!MIRROR!" + set "REQUESTED_VERSION_PATH=" + set "GITHUB_FALLBACK_BASE_URL=" + if "!BASE_URL!"=="" if /i "!REQUESTED_MIRROR!"=="auto" ( + call :ReleaseVersionPath + set "REQUESTED_VERSION_PATH=!VERSION_PATH!" + call :GithubBaseUrlForVersion "!VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" + ) + + call :StandaloneBaseUrl + if !ERRORLEVEL! NEQ 0 ( + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!MIRROR!"=="aliyun" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + echo WARNING: Aliyun standalone release metadata unavailable; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) + ) + if not "!GITHUB_FALLBACK_BASE_URL!"=="" if /i "!REQUESTED_VERSION_PATH!"=="latest" if /i "!MIRROR!"=="aliyun" if not "!STANDALONE_VERSION_PATH!"=="" ( + call :GithubBaseUrlForVersion "!STANDALONE_VERSION_PATH!" + set "GITHUB_FALLBACK_BASE_URL=!QWEN_GH_BASE_URL!" + set "QWEN_GH_BASE_URL=" + ) + if /i "!STANDALONE_BASE_URL!"=="!GITHUB_FALLBACK_BASE_URL!" set "GITHUB_FALLBACK_BASE_URL=" + set "ARCHIVE_URL=!STANDALONE_BASE_URL!/!ARCHIVE_NAME!" + set "CHECKSUM_SOURCE=!STANDALONE_BASE_URL!/SHA256SUMS" + + if /i "!METHOD!"=="detect" ( + call :UrlExists "!ARCHIVE_URL!" + if !ERRORLEVEL! NEQ 0 ( + set "USE_GITHUB_FALLBACK=0" + if not "!GITHUB_FALLBACK_BASE_URL!"=="" set "USE_GITHUB_FALLBACK=1" + if "!USE_GITHUB_FALLBACK!"=="1" ( + set "GITHUB_ARCHIVE_URL=!GITHUB_FALLBACK_BASE_URL!/!ARCHIVE_NAME!" + call :UrlExists "!GITHUB_ARCHIVE_URL!" + if !ERRORLEVEL! EQU 0 ( + echo WARNING: Aliyun standalone archive not found; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + ) else ( + set "GITHUB_ARCHIVE_URL=" + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + set "GITHUB_ARCHIVE_URL=" + ) else ( + echo WARNING: Standalone archive not found: !ARCHIVE_NAME! + exit /b 2 + ) + ) + ) + + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 + set "ARCHIVE_FILE=!TEMP_DIR!\!ARCHIVE_NAME!" + + echo Downloading !ARCHIVE_NAME! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + set "DOWNLOAD_STATUS=!ERRORLEVEL!" + if not "!DOWNLOAD_STATUS!"=="0" if not "!GITHUB_FALLBACK_BASE_URL!"=="" ( + if exist "!ARCHIVE_FILE!" del /F /Q "!ARCHIVE_FILE!" >nul 2>&1 + echo WARNING: Aliyun standalone archive download failed; retrying GitHub mirror. + call :UseGithubFallbackBaseUrl + echo Downloading !ARCHIVE_NAME! + call :DownloadFile "!ARCHIVE_URL!" "!ARCHIVE_FILE!" + set "DOWNLOAD_STATUS=!ERRORLEVEL!" + ) + if not "!DOWNLOAD_STATUS!"=="0" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo WARNING: Failed to download standalone archive. + if /i "!METHOD!"=="detect" exit /b 2 + exit /b 1 + ) +) + +if "!TEMP_DIR!"=="" ( + call :CreateTempDir + if !ERRORLEVEL! NEQ 0 exit /b 1 +) + +REM Verify integrity before extraction or changing the install directory. +call :VerifyChecksum "!ARCHIVE_FILE!" "!CHECKSUM_SOURCE!" "!ARCHIVE_NAME!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Extract into a temporary directory, then validate required entry points. +set "EXTRACT_DIR=!TEMP_DIR!\extract" +call :EnsureDir "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" +set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_ARCHIVE_FILE=" +set "QWEN_EXTRACT_DIR=" +if !PS_STATUS! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to extract standalone archive. + exit /b 1 +) + +call :RejectArchiveLinks "!EXTRACT_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\bin\qwen.cmd" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\bin\qwen.cmd. + exit /b 1 +) + +if not exist "!EXTRACT_DIR!\qwen-code\node\node.exe" ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Archive does not contain qwen-code\node\node.exe. + exit /b 1 +) + +call :EnsureDir "!INSTALL_BASE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureDir "!INSTALL_BIN_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +for %%I in ("!INSTALL_DIR!") do set "INSTALL_PARENT=%%~dpI" +call :EnsureDir "!INSTALL_PARENT!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +REM Stage into .new and keep .old so failed upgrades can roll back. +set "NEW_INSTALL_DIR=!INSTALL_DIR!.new" +set "OLD_INSTALL_DIR=!INSTALL_DIR!.old" + +call :EnsureManagedInstallDir "!INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!NEW_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) +call :EnsureManagedInstallDir "!OLD_INSTALL_DIR!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +call :RestoreStaleInstallBackup +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + +if exist "!NEW_INSTALL_DIR!" ( + rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale staging directory: !NEW_INSTALL_DIR!. + exit /b 1 + ) +) +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to remove stale install backup: !OLD_INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!EXTRACT_DIR!\qwen-code" "!NEW_INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to stage standalone archive. + exit /b 1 +) + +if exist "!INSTALL_DIR!" ( + move /Y "!INSTALL_DIR!" "!OLD_INSTALL_DIR!" >nul + if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to back up existing install at !INSTALL_DIR!. + exit /b 1 + ) +) +move /Y "!NEW_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to install standalone archive to !INSTALL_DIR!. + exit /b 1 +) + +rem SAFETY: this writer expands !INSTALL_DIR! / !INSTALL_BIN_DIR! into a generated +rem .cmd file. :ValidateOptions must continue to reject delayed-expansion sentinels +rem (`!`) and other shell-metacharacters in those values; if that validator is ever +rem loosened, the wrapper write below becomes a command injection sink. +( +echo @echo off +echo call "!INSTALL_DIR!\bin\qwen.cmd" %%* +) > "!INSTALL_BIN_DIR!\qwen.cmd.new" +if !ERRORLEVEL! NEQ 0 ( + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) +move /Y "!INSTALL_BIN_DIR!\qwen.cmd.new" "!INSTALL_BIN_DIR!\qwen.cmd" >nul +if !ERRORLEVEL! NEQ 0 ( + if exist "!INSTALL_BIN_DIR!\qwen.cmd.new" del /F /Q "!INSTALL_BIN_DIR!\qwen.cmd.new" >nul 2>&1 + call :RemoveInstalledDirWithWarning + call :RestoreOldInstall + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + echo ERROR: Failed to create qwen wrapper in !INSTALL_BIN_DIR!. + exit /b 1 +) + +if exist "!OLD_INSTALL_DIR!" ( + rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 + if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove old install backup: !OLD_INSTALL_DIR! +) + +set "PATH=!INSTALL_BIN_DIR!;!PATH!" +call :CreateSourceJson +if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + +echo SUCCESS: Qwen Code standalone archive installed successfully. +echo INFO: Installed to !INSTALL_DIR! +exit /b 0 + +:CreateTempDir +set "TEMP_DIR=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = Join-Path $env:TEMP ('qwen-code-install-' + [IO.Path]::GetRandomFileName()); New-Item -ItemType Directory -Path $dir -ErrorAction Stop | Out-Null; [Console]::Write($dir)"`) do set "TEMP_DIR=%%I" +if "!TEMP_DIR!"=="" ( + echo ERROR: Failed to create a temporary directory. + exit /b 1 +) +exit /b 0 + +:CreateTempFile +set "TEMP_FILE=" +set "QWEN_TEMP_FILE_PREFIX=%~1" +set "QWEN_TEMP_FILE_EXTENSION=%~2" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $file = Join-Path $env:TEMP ($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION); New-Item -ItemType File -Path $file -ErrorAction Stop | Out-Null; [Console]::Write($file)"`) do set "TEMP_FILE=%%I" +set "QWEN_TEMP_FILE_PREFIX=" +set "QWEN_TEMP_FILE_EXTENSION=" +if "!TEMP_FILE!"=="" ( + echo ERROR: Failed to create a temporary file. + exit /b 1 +) +exit /b 0 + +:EnsureDir +set "REQUIRED_DIR=%~1" +set "QWEN_REQUIRED_DIR=!REQUIRED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $path = $env:QWEN_REQUIRED_DIR; if (Test-Path -LiteralPath $path -PathType Container) { exit 0 }; if (Test-Path -LiteralPath $path) { exit 2 }; New-Item -ItemType Directory -Path $path -Force | Out-Null; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_REQUIRED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 +if !PS_STATUS! EQU 2 ( + echo ERROR: Path exists but is not a directory: !REQUIRED_DIR! + exit /b 1 +) +echo ERROR: Failed to create directory: !REQUIRED_DIR! +exit /b 1 + +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +REM Normalize backslashes to forward slashes before checking. Some Windows +REM zip producers (including PowerShell's Compress-Archive) emit entries +REM with backslash separators even though the ZIP spec requires '/'. We +REM accept either separator and reject only entries that, after +REM normalization, are empty, absolute, drive-rooted, or contain a '..' +REM segment. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% EQU 0 exit /b 0 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + +:RemoveInstalledDirWithWarning +if not exist "!INSTALL_DIR!" exit /b 0 +rmdir /S /Q "!INSTALL_DIR!" >nul 2>&1 +if !ERRORLEVEL! NEQ 0 echo WARNING: Failed to remove failed install directory: !INSTALL_DIR! +exit /b 0 + +:RestoreOldInstall +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to restore previous install from !OLD_INSTALL_DIR! to !INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RestoreStaleInstallBackup +if exist "!INSTALL_DIR!" exit /b 0 +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +echo WARNING: Found previous install backup without an active install: !OLD_INSTALL_DIR! +echo WARNING: Restoring backup to !INSTALL_DIR! before continuing. +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo ERROR: Failed to restore previous install from !OLD_INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + +:RejectArchiveLinks +set "QWEN_EXTRACT_DIR=%~1" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_EXTRACT_DIR=" +if %PS_STATUS% NEQ 0 echo ERROR: Archive contains symlinks or reparse points; refusing to install. +exit /b %PS_STATUS% + +:EnsureManagedInstallDir +set "MANAGED_DIR=%~1" +set "QWEN_MANAGED_DIR=!MANAGED_DIR!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $dir = $env:QWEN_MANAGED_DIR; if (!(Test-Path -LiteralPath $dir)) { exit 0 }; if (!(Test-Path -LiteralPath $dir -PathType Container)) { exit 1 }; $manifest = Join-Path $dir 'manifest.json'; if (!(Test-Path -LiteralPath $manifest -PathType Leaf)) { exit 1 }; try { $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json } catch { exit 1 }; if ($data.name -ne '@qwen-code/qwen-code') { exit 1 }; if ([string]$data.target -notmatch '^win-(x64|arm64)$') { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'bin\qwen.cmd') -PathType Leaf)) { exit 1 }; if (!(Test-Path -LiteralPath (Join-Path $dir 'node\node.exe') -PathType Leaf)) { exit 1 }; exit 0" +set "PS_STATUS=!ERRORLEVEL!" +set "QWEN_MANAGED_DIR=" +if !PS_STATUS! EQU 0 exit /b 0 + +rem Directory exists but is not a qwen-code standalone install. +rem Back it up so the user doesn't lose data, then proceed. +for /f "delims=" %%t in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddTHHmmss"') do set "BACKUP_TIMESTAMP=%%t" +set "BACKUP_DIR=!MANAGED_DIR!.backup.!BACKUP_TIMESTAMP!" +if "!BACKUP_TIMESTAMP!"=="" set "BACKUP_DIR=!MANAGED_DIR!.backup" +echo WARNING: !MANAGED_DIR! exists but is not a Qwen Code standalone install. +echo WARNING: Backing up to !BACKUP_DIR! +move /Y "!MANAGED_DIR!" "!BACKUP_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo ERROR: Failed to back up !MANAGED_DIR!. Move or remove it manually, then rerun the installer. + exit /b 1 +) +exit /b 0 + +:RequireNode +where node >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Node.js was not found. + echo. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('node -p "process.versions.node" 2^>nul') do set "NODE_VERSION=%%i" +if "%NODE_VERSION%"=="" ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +for /f "tokens=1 delims=." %%a in ("%NODE_VERSION%") do set "MAJOR_VERSION=%%a" +set /a NODE_MAJOR_NUM=%MAJOR_VERSION% >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Unable to determine Node.js version. + echo Node.js 22 or newer is required before installing Qwen Code with npm. + exit /b 1 +) + +if %NODE_MAJOR_NUM% LSS 22 ( + echo ERROR: Node.js %NODE_VERSION% is installed, but Node.js 22 or newer is required. + echo Please install Node.js from https://nodejs.org/ and rerun this installer. + exit /b 1 +) + +echo SUCCESS: Node.js %NODE_VERSION% detected. +exit /b 0 + +:RequireNpm +where npm >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: npm was not found. + echo Please install Node.js with npm included, then rerun this installer. + exit /b 1 +) + +for /f "delims=" %%i in ('npm -v 2^>nul') do set "NPM_VERSION=%%i" +echo SUCCESS: npm %NPM_VERSION% detected. +exit /b 0 + +:NpmPackageSpec +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@latest" +if /i "!VERSION!"=="latest" exit /b 0 +set "NPM_VERSION_SPEC=!VERSION!" +if /i "!NPM_VERSION_SPEC:~0,1!"=="v" set "NPM_VERSION_SPEC=!NPM_VERSION_SPEC:~1!" +set "NPM_PACKAGE_SPEC=@qwen-code/qwen-code@!NPM_VERSION_SPEC!" +exit /b 0 + +:InstallNpm +call :RequireNode +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :RequireNpm +if %ERRORLEVEL% NEQ 0 exit /b 1 + +call :NpmPackageSpec + +where qwen >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + for /f "delims=" %%i in ('qwen --version 2^>nul') do set "QWEN_VERSION=%%i" + echo INFO: Existing Qwen Code detected: !QWEN_VERSION! + if /i "!VERSION!"=="latest" ( + echo INFO: Upgrading to the latest version. + ) else ( + echo INFO: Installing requested version !VERSION!. + ) +) + +echo INFO: Running: npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! +call npm install -g !NPM_PACKAGE_SPEC! --registry "!NPM_REGISTRY!" +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Failed to install Qwen Code. + echo. + echo This installer does not change your npm prefix or PATH. + echo If the failure is a permission error, fix your npm global package directory, then run: + echo npm install -g !NPM_PACKAGE_SPEC! --registry !NPM_REGISTRY! + exit /b 1 +) + +echo SUCCESS: Qwen Code installed successfully. +call :CreateSourceJson +exit /b 0 + +:CreateSourceJson +if "!SOURCE!"=="unknown" exit /b 0 + +set "QWEN_DIR=!USERPROFILE!\.qwen" +call :EnsureDir "!QWEN_DIR!" +if !ERRORLEVEL! NEQ 0 exit /b 1 + +( +echo { +echo "source": "!SOURCE!" +echo } +) > "!QWEN_DIR!\source.json" + +echo SUCCESS: Installation source saved to !USERPROFILE!\.qwen\source.json +exit /b 0 + +:PrintFinalInstructions +set "EXTRA_BIN=%~1" +set "SUMMARY_INSTALL_DIR=%~2" +set "SUMMARY_INSTALL_METHOD=%~3" +set "STANDALONE_UNINSTALL_URL=https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1" +if "!SUMMARY_INSTALL_METHOD!"=="" set "SUMMARY_INSTALL_METHOD=standalone" + +set "INSTALLED_BIN=" +if not "!EXTRA_BIN!"=="" ( + set "INSTALLED_BIN=!EXTRA_BIN!\qwen.cmd" + set "PATH=!EXTRA_BIN!;!PATH!" +) + +echo. + +set "INSTALLED_VERSION=unknown" +if not "!INSTALLED_BIN!"=="" if exist "!INSTALLED_BIN!" ( + for /f "delims=" %%i in ('"!INSTALLED_BIN!" --version 2^>nul') do set "INSTALLED_VERSION=%%i" +) + +echo QWEN CODE +echo. +echo Qwen Code !INSTALLED_VERSION! installed successfully. +echo. +echo To start: +echo cd ^ +echo qwen + +if not "!SUMMARY_INSTALL_DIR!"=="" ( + echo. + echo Installed to: + echo !SUMMARY_INSTALL_DIR! +) + +echo. +echo Uninstall: +if /i "!SUMMARY_INSTALL_METHOD!"=="npm" ( + echo npm uninstall -g @qwen-code/qwen-code +) else ( + if not "!SUMMARY_INSTALL_DIR!"=="" ( + if not "!EXTRA_BIN!"=="" ( + echo set "QWEN_INSTALL_LIB_DIR=!SUMMARY_INSTALL_DIR!" ^&^& set "QWEN_INSTALL_BIN_DIR=!EXTRA_BIN!" ^&^& powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" + ) else ( + echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" + ) + ) else ( + echo powershell -ExecutionPolicy Bypass -c "irm !STANDALONE_UNINSTALL_URL! ^| iex" + ) +) + +rem Build OTHER_QWENS = PRE_INSTALL_QWENS_LIST minus the install we just made. +set "OTHER_QWENS=" +if defined PRE_INSTALL_QWENS_LIST ( + for %%i in ("!PRE_INSTALL_QWENS_LIST:|=" "!") do ( + set "ENTRY=%%~i" + if not "!ENTRY!"=="" if /i not "!ENTRY!"=="!INSTALLED_BIN!" ( + if "!OTHER_QWENS!"=="" ( + set "OTHER_QWENS=!ENTRY!" + ) else ( + set "OTHER_QWENS=!OTHER_QWENS!|!ENTRY!" + ) + ) + ) +) + +rem Persist the install bin to user PATH unless --no-modify-path is set. +if not "!EXTRA_BIN!"=="" if /i not "!NO_MODIFY_PATH!"=="1" ( + call :MaybeUpdateUserPath "!EXTRA_BIN!" + if !ERRORLEVEL! NEQ 0 ( + echo WARNING: Failed to update user PATH. Add the directory manually: + echo !EXTRA_BIN! + ) +) + +if defined OTHER_QWENS ( + echo. + echo WARNING: Other 'qwen' executables exist on this system. Depending on + echo WARNING: your PATH order, one of these may run instead of the install above: + for %%i in ("!OTHER_QWENS:|=" "!") do ( + set "OQ=%%~i" + if not "!OQ!"=="" echo WARNING: !OQ! + ) + echo. + echo To make this install take priority, restart your command prompt. + echo Or invoke directly: "!INSTALLED_BIN!" + exit /b 0 +) + +if /i "!QWEN_INSTALLER_PARENT_POWERSHELL!"=="1" ( + echo INFO: Final PATH refresh is handled by the PowerShell entrypoint. + exit /b 0 +) +echo qwen is ready to use in this terminal. +exit /b 0 diff --git a/scripts/installation/install-qwen-standalone.ps1 b/scripts/installation/install-qwen-standalone.ps1 new file mode 100644 index 0000000000..430547a444 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.ps1 @@ -0,0 +1,415 @@ +# Qwen Code Windows hosted PowerShell entrypoint. +# Pairs with install-qwen-standalone.bat: this shim downloads the .bat into TEMP, +# verifies its checksum, and runs it with forwarded arguments. +# +# PowerShell (runs in current session, qwen available immediately): +# irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex +# +# cmd.exe (runs in current session, qwen available immediately): +# curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat -o %TEMP%\install-qwen.bat && %TEMP%\install-qwen.bat +# +# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking, +# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing +# --version vX.Y.Z to install-qwen-standalone.bat directly. +# +# To point this shim at a non-production hosted endpoint (staging buckets, +# private mirrors), set $env:QWEN_INSTALLER_BAT_URL to the alternate .bat URL. +# The override is required to be HTTPS so a misconfigured value can't silently +# downgrade the download channel. The downstream .bat continues to honor +# QWEN_INSTALL_BASE_URL for archive resolution. +# +# By default the matching SHA256SUMS file is read from the same hosted +# directory as the .bat. Set $env:QWEN_INSTALLER_CHECKSUMS_URL to override it +# when testing a custom installer endpoint. + +$ErrorActionPreference = 'Stop' + +function Download-File { + param([string]$Url, [string]$OutFile) + $prevProgressPreference = $global:ProgressPreference + $global:ProgressPreference = 'SilentlyContinue' + try { + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo $OutFile $Url + if ($LASTEXITCODE -ne 0) { + throw "curl.exe download failed (exit code $LASTEXITCODE)" + } + return + } + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -MaximumRedirection 10 -TimeoutSec 300 + } finally { + $global:ProgressPreference = $prevProgressPreference + } +} + +function Get-QwenInstallBase { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + return $env:QWEN_INSTALL_ROOT + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + return Join-Path $env:LOCALAPPDATA 'qwen-code' + } + + return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local') 'qwen-code' +} + +function Get-QwenInstallBinDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + return $env:QWEN_INSTALL_BIN_DIR + } + + return Join-Path (Get-QwenInstallBase) 'bin' +} + +function Get-CurrentCmdShimStatePath { + return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt' +} + +function Save-CurrentCmdPathShim { + param([string]$ShimPath) + + if ([string]::IsNullOrEmpty($ShimPath)) { + return + } + + try { + $statePath = Get-CurrentCmdShimStatePath + New-Item -ItemType Directory -Path (Split-Path -Parent $statePath) -Force | Out-Null + [IO.File]::WriteAllText($statePath, $ShimPath, [Text.UTF8Encoding]::new($false)) + } catch { + # Best-effort cleanup hint only. The installer still works if this fails. + } +} + +function Update-CurrentSessionPath { + param([string]$BinDir) + + if ([string]::IsNullOrEmpty($BinDir)) { + return + } + + $entries = @($env:Path -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) }) + foreach ($entry in $entries) { + if ([string]::Equals($entry, $BinDir, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $env:Path = (@($BinDir) + $entries) -join ';' +} + +function Get-ParentProcessName { + try { + $current = Get-CimInstance Win32_Process -Filter "ProcessId = $PID" -ErrorAction Stop + if ($null -eq $current -or $null -eq $current.ParentProcessId) { + return $null + } + $parent = Get-CimInstance Win32_Process -Filter "ProcessId = $($current.ParentProcessId)" -ErrorAction Stop + if ($null -eq $parent) { + return $null + } + return $parent.Name + } catch { + return $null + } +} + +function Get-NormalizedPath { + param([string]$PathValue) + + if ([string]::IsNullOrEmpty($PathValue)) { + return $null + } + + $trimmed = $PathValue.Trim().Trim('"') + if ([string]::IsNullOrEmpty($trimmed)) { + return $null + } + + try { + return [IO.Path]::GetFullPath($trimmed).TrimEnd('\') + } catch { + return $trimmed.TrimEnd('\') + } +} + +function Test-PathContainsDirectory { + param([string]$PathValue, [string]$Directory) + + $target = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($target)) { + return $false + } + + foreach ($entry in @($PathValue -split ';')) { + $normalizedEntry = Get-NormalizedPath -PathValue $entry + if ([string]::Equals($normalizedEntry, $target, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + return $false +} + +function Test-WritableDirectory { + param([string]$Directory) + + if ([string]::IsNullOrEmpty($Directory)) { + return $false + } + + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + return $false + } + + $probe = Join-Path $Directory ('.qwen-write-test-' + [IO.Path]::GetRandomFileName()) + try { + [IO.File]::WriteAllText($probe, '') + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + return $true + } catch { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + return $false + } +} + +function Add-PathCandidate { + param( + [System.Collections.Generic.List[string]]$Candidates, + [string]$Directory + ) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($normalizedDirectory)) { + return + } + + foreach ($candidate in $Candidates) { + $normalizedCandidate = Get-NormalizedPath -PathValue $candidate + if ([string]::Equals($normalizedCandidate, $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + [void]$Candidates.Add($Directory.Trim().Trim('"')) +} + +function Test-SystemManagedPathDirectory { + param([string]$Directory) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + return ( + -not [string]::IsNullOrEmpty($normalizedDirectory) -and + $normalizedDirectory -match '\\Microsoft\\WindowsApps$' + ) +} + +function Install-CurrentCmdPathShim { + param([string]$QwenCommand, [string]$PathValue) + + $pathEntries = @($PathValue -split ';' | Where-Object { -not [string]::IsNullOrEmpty($_) }) + $candidates = [System.Collections.Generic.List[string]]::new() + $preferredDirectories = @() + + if (-not [string]::IsNullOrEmpty($env:APPDATA)) { + $preferredDirectories += Join-Path $env:APPDATA 'npm' + } + if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) { + $preferredDirectories += Join-Path $env:USERPROFILE '.bun\bin' + } + + foreach ($preferredDirectory in $preferredDirectories) { + $preferredNormalized = Get-NormalizedPath -PathValue $preferredDirectory + foreach ($entry in $pathEntries) { + $entryNormalized = Get-NormalizedPath -PathValue $entry + if ([string]::Equals($entryNormalized, $preferredNormalized, [StringComparison]::OrdinalIgnoreCase)) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + } + + $userRoot = Get-NormalizedPath -PathValue $env:USERPROFILE + foreach ($entry in $pathEntries) { + if (Test-SystemManagedPathDirectory -Directory $entry) { + continue + } + $entryNormalized = Get-NormalizedPath -PathValue $entry + if ( + -not [string]::IsNullOrEmpty($userRoot) -and + -not [string]::IsNullOrEmpty($entryNormalized) -and + $entryNormalized.StartsWith($userRoot, [StringComparison]::OrdinalIgnoreCase) + ) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + + foreach ($candidate in $candidates) { + if (-not (Test-WritableDirectory -Directory $candidate)) { + continue + } + + $shimPath = Join-Path $candidate 'qwen.cmd' + if (Test-Path -LiteralPath $shimPath -PathType Leaf) { + $existingShim = Get-Content -LiteralPath $shimPath -Raw -ErrorAction SilentlyContinue + if ($existingShim -notmatch 'Qwen Code current-session shim') { + continue + } + } + + $shim = "@echo off`r`nREM Qwen Code current-session shim. Generated by install-qwen-standalone.ps1.`r`ncall `"$QwenCommand`" %*`r`n" + # Write to a sibling temp file first, then atomically rename so a partial + # write (process killed, disk full) cannot leave a half-written shim on + # PATH. + $shimTempPath = "$shimPath.new" + [IO.File]::WriteAllText($shimTempPath, $shim, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $shimTempPath -Destination $shimPath -Force + Save-CurrentCmdPathShim -ShimPath $shimPath + return $shimPath + } + + return $null +} + +function Update-CurrentShell { + $qwenInstallBinDir = Get-QwenInstallBinDir + $qwenCommandPath = Join-Path $qwenInstallBinDir 'qwen.cmd' + if (-not (Test-Path -LiteralPath $qwenCommandPath -PathType Leaf)) { + return + } + + if ($env:QWEN_NO_MODIFY_PATH -eq '1') { + Write-Output "Run: ${qwenCommandPath}" + Write-Output "INFO: QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh." + return + } + + $inheritedPath = $env:Path + Update-CurrentSessionPath -BinDir $qwenInstallBinDir + + Write-Output "Run: qwen" + $parentProcessName = Get-ParentProcessName + if ($parentProcessName -ieq 'cmd.exe') { + if (Test-PathContainsDirectory -PathValue $inheritedPath -Directory $qwenInstallBinDir) { + Write-Output "qwen is ready to use after this installer command returns." + return + } + + $shimPath = Install-CurrentCmdPathShim -QwenCommand $qwenCommandPath -PathValue $inheritedPath + if (-not [string]::IsNullOrEmpty($shimPath)) { + Write-Output "INFO: Added qwen.cmd to a directory already on this cmd.exe PATH:" + Write-Output "INFO: ${shimPath}" + Write-Output "qwen is ready to use after this installer command returns." + return + } + + Write-Output "WARNING: Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly." + Write-Output "Or, for this cmd.exe window, run:" + Write-Output " set `"PATH=${qwenInstallBinDir};%PATH%`"" + return + } + + Write-Output "qwen is ready to use in this PowerShell session." +} + +$qwenDefaultInstallerUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.bat' +$qwenDefaultChecksumsUrl = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/SHA256SUMS' +if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_BAT_URL)) { + $qwenInstallerUrl = $qwenDefaultInstallerUrl +} else { + if ($env:QWEN_INSTALLER_BAT_URL -notmatch '^https://') { + Write-Error "QWEN_INSTALLER_BAT_URL must start with https://" + exit 1 + } + $qwenInstallerUrl = $env:QWEN_INSTALLER_BAT_URL +} + +if ([string]::IsNullOrEmpty($env:QWEN_INSTALLER_CHECKSUMS_URL)) { + if ($qwenInstallerUrl -eq $qwenDefaultInstallerUrl) { + $qwenChecksumsUrl = $qwenDefaultChecksumsUrl + } else { + $qwenChecksumsUrl = [Uri]::new([Uri]$qwenInstallerUrl, 'SHA256SUMS').AbsoluteUri + } +} else { + if ($env:QWEN_INSTALLER_CHECKSUMS_URL -notmatch '^https://') { + Write-Error "QWEN_INSTALLER_CHECKSUMS_URL must start with https://" + exit 1 + } + $qwenChecksumsUrl = $env:QWEN_INSTALLER_CHECKSUMS_URL +} + +$qwenInstallerName = [IO.Path]::GetFileName(([Uri]$qwenInstallerUrl).AbsolutePath) +if ([string]::IsNullOrEmpty($qwenInstallerName)) { + $qwenInstallerName = 'install-qwen-standalone.bat' +} +if ([string]::IsNullOrEmpty($env:TEMP)) { + Write-Error "TEMP environment variable is not set. Please set TEMP to a writable directory." + exit 1 +} +# Use a cryptographically random staging filename so a same-user attacker cannot +# pre-stage a malicious .bat at a predictable path and race the verify/execute +# window between Get-FileHash and `& $qwenInstallerPath`. +$qwenStagingSuffix = [IO.Path]::GetRandomFileName() +$qwenInstallerPath = Join-Path $env:TEMP "qwen-installer-$qwenStagingSuffix.bat" +$qwenChecksumsPath = Join-Path $env:TEMP "qwen-installation-SHA256SUMS-$qwenStagingSuffix" + +try { + Download-File -Url $qwenInstallerUrl -OutFile $qwenInstallerPath +} catch { + Write-Error "Failed to download Qwen Code installer from ${qwenInstallerUrl}: $($_.Exception.Message)" + exit 1 +} + +try { + Download-File -Url $qwenChecksumsUrl -OutFile $qwenChecksumsPath +} catch { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Write-Error "Failed to download Qwen Code installer checksums from ${qwenChecksumsUrl}: $($_.Exception.Message)" + exit 1 +} + +$qwenExpectedHash = $null +foreach ($qwenChecksumLine in Get-Content -LiteralPath $qwenChecksumsPath) { + if ($qwenChecksumLine -match '^([0-9a-fA-F]{64})\s+\*?(.+)$') { + if ($Matches[2] -eq $qwenInstallerName) { + $qwenExpectedHash = $Matches[1].ToLowerInvariant() + break + } + } +} +if ([string]::IsNullOrEmpty($qwenExpectedHash)) { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue + Write-Error "Checksum entry for ${qwenInstallerName} not found in ${qwenChecksumsUrl}" + exit 1 +} + +$qwenActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $qwenInstallerPath).Hash.ToLowerInvariant() +if ($qwenActualHash -ne $qwenExpectedHash) { + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue + Write-Error "Checksum mismatch for ${qwenInstallerName}: expected ${qwenExpectedHash}, got ${qwenActualHash}." + exit 1 +} + +$qwenInstallerExitCode = 0 +$qwenPreviousParentPowerShell = $env:QWEN_INSTALLER_PARENT_POWERSHELL +try { + $env:QWEN_INSTALLER_PARENT_POWERSHELL = '1' + & $qwenInstallerPath @args + $qwenInstallerExitCode = $LASTEXITCODE +} finally { + if ($null -eq $qwenPreviousParentPowerShell) { + Remove-Item Env:\QWEN_INSTALLER_PARENT_POWERSHELL -ErrorAction SilentlyContinue + } else { + $env:QWEN_INSTALLER_PARENT_POWERSHELL = $qwenPreviousParentPowerShell + } + Remove-Item -LiteralPath $qwenInstallerPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $qwenChecksumsPath -Force -ErrorAction SilentlyContinue +} + +if ($qwenInstallerExitCode -ne 0) { + exit $qwenInstallerExitCode +} + +Update-CurrentShell diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh new file mode 100755 index 0000000000..3dc8d5c668 --- /dev/null +++ b/scripts/installation/install-qwen-standalone.sh @@ -0,0 +1,1467 @@ +#!/usr/bin/env bash + +# Qwen Code Installation Script +# Installs Qwen Code from a standalone archive when available, with npm fallback. +# This script intentionally does not install Node.js or change npm config. +# +# Usage: +# install-qwen-standalone.sh --source [github|npm|internal|local-build] +# install-qwen-standalone.sh --method [detect|standalone|npm] + +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then + if command -v bash >/dev/null 2>&1; then + if [ -f "${0}" ]; then + export __QWEN_INSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the installer with: curl ... | bash" + exit 1 + fi + + echo "Error: This script requires bash. Please install bash first." + exit 1 +fi + +set -eo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" +} + +log_success() { + printf '%bSUCCESS:%b %s\n' "${GREEN}" "${NC}" "$1" +} + +log_warning() { + printf '%bWARNING:%b %s\n' "${YELLOW}" "${NC}" "$1" +} + +log_error() { + printf '%bERROR:%b %s\n' "${RED}" "${NC}" "$1" >&2 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +TEMP_DIRS=() + +cleanup_temp_dirs() { + local temp_dir + for temp_dir in "${TEMP_DIRS[@]}"; do + if [[ -n "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi + done +} + +register_temp_dir() { + local temp_dir="$1" + TEMP_DIRS+=("${temp_dir}") +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +display_install_version() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + echo "${VERSION#v}" +} + +trap cleanup_temp_dirs EXIT +trap 'cleanup_temp_dirs; exit 130' INT +trap 'cleanup_temp_dirs; exit 143' TERM + +print_usage() { + cat </dev/null || echo unknown)" in + Darwin) + echo " brew install node" + echo " # or download from https://nodejs.org/" + ;; + Linux) + echo " # Use your distribution package manager or:" + echo " https://nodejs.org/en/download/package-manager" + ;; + *) + echo " https://nodejs.org/" + ;; + esac + echo "" + echo "If you already use a Node version manager, activate Node.js 22+" + echo "in this shell before rerunning the installer." +} + +require_node() { + if ! command_exists node; then + log_error "Node.js was not found." + print_node_help + return 1 + fi + + local node_version + node_version=$(node -p "process.versions.node" 2>/dev/null || true) + local node_major + node_major=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || true) + + if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then + log_error "Unable to determine Node.js version." + print_node_help + return 1 + fi + + if [[ "${node_major}" -lt 22 ]]; then + log_error "Node.js ${node_version:-unknown} is installed, but Node.js 22 or newer is required." + print_node_help + return 1 + fi + + log_success "Node.js ${node_version} detected." +} + +require_npm() { + if command_exists npm; then + log_success "npm $(npm -v 2>/dev/null || echo unknown) detected." + return 0 + fi + + log_error "npm was not found." + echo "" + echo "Please install Node.js with npm included, then rerun this installer." + echo "Download Node.js from https://nodejs.org/ if your package manager" + echo "installed Node without npm." + return 1 +} + +get_npm_global_bin() { + local prefix + prefix=$(npm prefix -g 2>/dev/null || true) + + if [[ -z "${prefix}" ]]; then + return 0 + fi + + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*) + echo "${prefix}" + ;; + *) + echo "${prefix}/bin" + ;; + esac +} + +get_npm_global_root() { + npm root -g 2>/dev/null || true +} + +create_source_json() { + if [[ "${SOURCE}" == "unknown" ]]; then + return 0 + fi + + local qwen_dir="${HOME}/.qwen" + mkdir -p "${qwen_dir}" + + local escaped_source + escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') + + cat > "${qwen_dir}/source.json" </dev/null || echo unknown) + local arch + arch=$(uname -m 2>/dev/null || echo unknown) + + case "${os}" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + return 1 + ;; + esac + + case "${arch}" in + x86_64|amd64) + arch="x64" + ;; + arm64|aarch64) + arch="arm64" + ;; + *) + return 1 + ;; + esac + + echo "${os}-${arch}" +} + +archive_extension_for_target() { + case "$1" in + darwin-*|linux-*) + echo "tar.gz" + ;; + *) + return 1 + ;; + esac +} + +release_version_path() { + if [[ "${VERSION}" == "latest" ]]; then + echo "latest" + return 0 + fi + + case "${VERSION}" in + v*) + echo "${VERSION}" + ;; + *) + echo "v${VERSION}" + ;; + esac +} + +# When a shadowing 'qwen' is detected, append a PATH prepend to the user's +# shell rc file at the very end. Putting it at the END means our prepend runs +# AFTER any earlier PATH munging in the rc file (e.g., other tools' shell +# init), so our installed_bin wins. Idempotent via a marker comment. +maybe_update_shell_path() { + local install_bin_dir="$1" + + [[ "${NO_MODIFY_PATH:-0}" == "1" ]] && return 0 + [[ -z "${install_bin_dir}" ]] && return 0 + [[ -z "${HOME:-}" ]] && return 0 + + local rc_file="" + case "${SHELL:-}" in + */zsh) rc_file="${HOME}/.zshrc" ;; + */bash) + if [[ -f "${HOME}/.bashrc" ]]; then + rc_file="${HOME}/.bashrc" + elif [[ -f "${HOME}/.bash_profile" ]]; then + rc_file="${HOME}/.bash_profile" + else + rc_file="${HOME}/.bashrc" + fi + ;; + */fish) rc_file="${HOME}/.config/fish/config.fish" ;; + *) + log_warning "Unsupported shell for automatic PATH update: ${SHELL:-unknown}. Add ${install_bin_dir} to PATH manually." + return 0 + ;; + esac + + [[ -z "${rc_file}" ]] && return 0 + + local begin_marker="# Qwen Code PATH block begin" + local end_marker="# Qwen Code PATH block end" + local quoted_install_bin_dir + quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") + local export_line + if [[ "${rc_file}" == *config.fish ]]; then + export_line="set -gx PATH ${quoted_install_bin_dir} \$PATH" + else + export_line="export PATH=${quoted_install_bin_dir}:\$PATH" + fi + + if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then + log_info "PATH update for ${install_bin_dir} already present in ${rc_file} (skipping)." + return 0 + fi + + mkdir -p "$(dirname "${rc_file}")" 2>/dev/null || true + { + echo "" + echo "${begin_marker}" + echo "${export_line}" + echo "${end_marker}" + } >> "${rc_file}" || { + log_warning "Could not write PATH update to ${rc_file}." + return 0 + } + + log_success "Appended PATH prepend to ${rc_file}" + log_info "Open a new terminal, or run: source ${rc_file}" +} + +github_base_url_for_version() { + local version_path="$1" + local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}" + if [[ "${version_path}" == "latest" ]]; then + echo "https://github.com/${github_repo}/releases/latest/download" + else + echo "https://github.com/${github_repo}/releases/download/${version_path}" + fi +} + +aliyun_base_url_for_version() { + local version_path="$1" + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}" +} + +aliyun_latest_version_url() { + echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/latest/VERSION" +} + +normalize_version_path_value() { + local raw_version="$1" + local version_path + + raw_version=$(printf '%s' "${raw_version}" | tr -d '\r' | awk 'NF { print $1; exit }') + if [[ -z "${raw_version}" ]]; then + return 1 + fi + + case "${raw_version}" in + v*) + version_path="${raw_version}" + ;; + *) + version_path="v${raw_version}" + ;; + esac + + if [[ "${version_path}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$ ]]; then + echo "${version_path}" + return 0 + fi + + return 1 +} + +download_text() { + local url="$1" + + if command_exists curl; then + curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}" + return $? + fi + + if command_exists wget; then + local wget_args=(--tries=3 --timeout=10) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout=30) + fi + wget -q "${wget_args[@]}" -O - "${url}" + return $? + fi + + log_error "curl or wget is required to resolve the standalone release version." + return 1 +} + +resolve_aliyun_version_path() { + local version_path="$1" + + if [[ "${version_path}" != "latest" ]]; then + echo "${version_path}" + return 0 + fi + + local latest_url + latest_url=$(aliyun_latest_version_url) + + local latest_version + if ! latest_version=$(download_text "${latest_url}"); then + log_warning "Failed to resolve Aliyun latest VERSION pointer." >&2 + return 1 + fi + + local resolved_version_path + if ! resolved_version_path=$(normalize_version_path_value "${latest_version}"); then + log_error "Aliyun latest VERSION pointer is not a valid semver value." + return 1 + fi + + log_info "Resolved Aliyun latest to ${resolved_version_path}." >&2 + echo "${resolved_version_path}" +} + +# Probe a URL with a HEAD request first, then fall back to a 1-byte ranged GET +# for object stores or CDNs that reject HEAD while still serving the object. +probe_url_available() { + local url="$1" + local timeout="${2:-30}" + + if command_exists curl; then + if curl -fsIL --retry 1 --connect-timeout 10 --max-time "${timeout}" "${url}" >/dev/null 2>&1; then + return 0 + fi + curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}" \ + --range 0-0 -o /dev/null "${url}" >/dev/null 2>&1 + return $? + fi + + if command_exists wget; then + local wget_args=(--tries=2 --timeout=10) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout="${timeout}") + fi + if wget -q --spider "${wget_args[@]}" "${url}" >/dev/null 2>&1; then + return 0 + fi + wget -q "${wget_args[@]}" --header='Range: bytes=0-0' -O /dev/null "${url}" >/dev/null 2>&1 + return $? + fi + + return 1 +} + +# Race two availability probes; print "aliyun" or "github" based on which +# mirror's SHA256SUMS responds first, or "timeout" if neither responds before +# the deadline. Caller decides what to do with "timeout" (currently: log it and +# fall back to github). +race_mirror_head() { + local timeout="${1:-2}" + local gh_url="$2" + local oss_url="$3" + local tmpdir + if ! tmpdir=$(mktemp -d -t qwen-mirror.XXXXXX 2>/dev/null); then + # Refuse to fall back to a predictable PID-based path; a local attacker + # could pre-create it to influence mirror selection. + echo "mirror probe: mktemp failed" >&2 + echo "github" + return 0 + fi + register_temp_dir "${tmpdir}" + + (probe_url_available "${oss_url}" "${timeout}" && : > "${tmpdir}/aliyun") & + local oss_pid=$! + (probe_url_available "${gh_url}" "${timeout}" && : > "${tmpdir}/github") & + local gh_pid=$! + + local winner="" + local elapsed=0 + local max=$((timeout * 10 + 5)) + while [[ -z "${winner}" && "${elapsed}" -lt "${max}" ]]; do + # Probe OSS first to break ties in favor of the closer mirror for CN users. + [[ -e "${tmpdir}/aliyun" ]] && winner="aliyun" && break + [[ -e "${tmpdir}/github" ]] && winner="github" && break + sleep 0.1 + elapsed=$((elapsed + 1)) + done + + kill "${oss_pid}" "${gh_pid}" 2>/dev/null || true + wait "${oss_pid}" "${gh_pid}" 2>/dev/null || true + rm -rf "${tmpdir}" 2>/dev/null || true + + echo "${winner:-timeout}" +} + +standalone_base_url() { + if [[ -n "${BASE_URL}" ]]; then + echo "${BASE_URL%/}" + return 0 + fi + + local version_path + version_path=$(release_version_path) + + if [[ "${MIRROR}" == "auto" ]]; then + local gh_head oss_head selected + gh_head="$(github_base_url_for_version "${version_path}")/SHA256SUMS" + if [[ "${version_path}" == "latest" ]]; then + oss_head="$(aliyun_latest_version_url)" + else + oss_head="$(aliyun_base_url_for_version "${version_path}")/SHA256SUMS" + fi + selected=$(race_mirror_head 2 "${gh_head}" "${oss_head}") + if [[ "${selected}" == "timeout" ]]; then + log_info "Mirror auto-selection timed out; defaulting to github." >&2 + selected="github" + else + log_info "Mirror auto-selected via HEAD probe: ${selected}" >&2 + fi + MIRROR="${selected}" + fi + + if [[ "${MIRROR}" == "aliyun" ]]; then + if ! version_path=$(resolve_aliyun_version_path "${version_path}"); then + return 1 + fi + aliyun_base_url_for_version "${version_path}" + return 0 + fi + + github_base_url_for_version "${version_path}" +} + +download_file() { + local url="$1" + local destination="$2" + + if command_exists curl; then + curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}" + return $? + fi + + if command_exists wget; then + local wget_args=(--tries=3 --timeout=15) + if wget --help 2>&1 | grep -q -- '--read-timeout'; then + wget_args+=(--read-timeout=300) + fi + if wget --help 2>&1 | grep -q -- '--progress'; then + wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + else + wget "${wget_args[@]}" "${url}" -O "${destination}" || return 1 + fi + return $? + fi + + log_error "curl or wget is required to download the standalone archive." + return 1 +} + +url_exists() { + local url="$1" + + probe_url_available "${url}" 30 +} + +sha256_file() { + local file_path="$1" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print $1}' + return 0 + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print $1}' + return 0 + fi + + return 1 +} + +verify_checksum() { + local archive_path="$1" + local checksum_source="$2" + local archive_name="$3" + local checksum_file="${checksum_source}" + local temp_checksum="" + + if [[ -z "${checksum_file}" ]]; then + checksum_file="$(dirname "${archive_path}")/SHA256SUMS" + elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then + temp_checksum="$(mktemp)" + if ! download_file "${checksum_file}" "${temp_checksum}"; then + rm -f "${temp_checksum}" + log_error "Could not download SHA256SUMS for checksum verification." + return 1 + fi + checksum_file="${temp_checksum}" + fi + + if [[ ! -f "${checksum_file}" ]]; then + rm -f "${temp_checksum}" + log_error "SHA256SUMS not found at ${checksum_file}; cannot verify archive." + return 1 + fi + + local expected + expected=$(awk -v archive_name="${archive_name}" ' + { + name = $2 + sub(/^\*/, "", name) + if (name == archive_name) { + print $1 + exit + } + } + ' "${checksum_file}") + if [[ -z "${expected}" ]]; then + rm -f "${temp_checksum}" + log_error "Checksum entry for ${archive_name} not found." + return 1 + fi + + local actual + if ! actual=$(sha256_file "${archive_path}"); then + rm -f "${temp_checksum}" + log_error "No SHA-256 utility found; cannot verify archive." + return 1 + fi + + rm -f "${temp_checksum}" + + if [[ "${expected}" != "${actual}" ]]; then + log_error "Checksum mismatch for ${archive_name}: expected ${expected}, got ${actual}." + return 1 + fi + + log_success "Checksum verified for ${archive_name}." +} + +validate_archive_entry_path() { + local entry="$1" + entry="${entry//\\//}" + + while [[ "${entry}" == ./* ]]; do + entry="${entry#./}" + done + + # Reject entries containing CR/LF so a `..\r` or `..\n` entry cannot + # bypass the literal `..` glob below. + case "${entry}" in + *$'\r'*|*$'\n'*) + log_error "Archive contains unsafe path with control character: ${entry}" + return 1 + ;; + esac + + case "${entry}" in + ""|/*|..|../*|*/..|*/../*) + log_error "Archive contains unsafe path: ${entry:-}" + return 1 + ;; + esac +} + +validate_archive_contents() { + local archive_path="$1" + local entries + local entry + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to inspect ${archive_path}." + return 1 + fi + if ! entries=$(unzip -Z1 "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *.tar.gz|*.tgz|*.tar.xz) + if ! entries=$(tar -tf "${archive_path}"); then + log_error "Failed to inspect archive entries: ${archive_path}" + return 1 + fi + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + while IFS= read -r entry; do + validate_archive_entry_path "${entry}" || return 1 + done <<< "${entries}" +} + +extract_archive() { + local archive_path="$1" + local destination="$2" + + mkdir -p "${destination}" || return 1 + validate_archive_contents "${archive_path}" || return 1 + + case "${archive_path}" in + *.zip) + if ! command_exists unzip; then + log_error "unzip is required to extract ${archive_path}." + return 1 + fi + unzip -q "${archive_path}" -d "${destination}" || return 1 + ;; + *.tar.gz|*.tgz) + tar -xzf "${archive_path}" -C "${destination}" || return 1 + ;; + *.tar.xz) + tar -xf "${archive_path}" -C "${destination}" || return 1 + ;; + *) + log_error "Unsupported archive format: ${archive_path}" + return 1 + ;; + esac + + local symlink_entry + symlink_entry=$(find "${destination}" -type l -print | sed -n '1p') + if [[ -n "${symlink_entry}" ]]; then + log_error "Archive contains symlinks; refusing to install." + return 1 + fi +} + +ensure_managed_install_dir() { + local install_dir="$1" + + if [[ ! -e "${install_dir}" ]]; then + return 0 + fi + + if is_qwen_standalone_install_dir "${install_dir}"; then + return 0 + fi + + local backup="${install_dir}.backup.$(date +%Y%m%dT%H%M%S 2>/dev/null || date +%Y%m%d%H%M%S)" + log_warning "${install_dir} exists but is not a Qwen Code standalone install." + log_warning "Backing up to: ${backup}" + if mv "${install_dir}" "${backup}"; then + return 0 + fi + + log_error "Failed to back up ${install_dir}. Move or remove it manually, then rerun the installer." + return 1 +} + +restore_stale_install_backup() { + local old_install_dir="$1" + local current_install_dir="$2" + + if [[ -e "${current_install_dir}" || ! -e "${old_install_dir}" ]]; then + return 0 + fi + + log_warning "Found previous install backup without an active install: ${old_install_dir}" + log_warning "Restoring backup to ${current_install_dir} before continuing." + if mv "${old_install_dir}" "${current_install_dir}"; then + return 0 + fi + + log_error "Failed to restore previous install from ${old_install_dir}." + return 1 +} + +is_qwen_standalone_install_dir() { + local install_dir="$1" + local manifest_path="${install_dir}/manifest.json" + + [[ -f "${manifest_path}" ]] || return 1 + # Manifest format is produced by writeManifest in create-standalone-package.js. + # Keep these grep checks in sync if that JSON layout changes. + grep -Eq '"name"[[:space:]]*:[[:space:]]*"@qwen-code/qwen-code"' "${manifest_path}" 2>/dev/null || return 1 + grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 + [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 + [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 +} + +write_unix_wrapper() { + local wrapper_path="$1" + local qwen_bin="$2" + local quoted_qwen_bin + quoted_qwen_bin=$(shell_quote "${qwen_bin}") + + if ! cat > "${wrapper_path}" </dev/null || echo "unknown") + log_info "Existing Qwen Code detected: ${qwen_version}" + if [[ "${VERSION}" == "latest" ]]; then + log_info "Upgrading to the latest version." + else + log_info "Installing requested version ${VERSION}." + fi + fi + + local install_cmd=( + npm + install + -g + "${package_spec}" + --registry + "${NPM_REGISTRY}" + ) + + log_info "Running: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" + if "${install_cmd[@]}"; then + log_success "Qwen Code installed successfully." + create_source_json + return 0 + fi + + log_error "Failed to install Qwen Code." + echo "" + echo "This installer does not change your npm prefix or shell profile." + echo "If the failure is a permission error, install Node.js with a user-owned" + echo "Node version manager or fix your npm global package directory, then run:" + echo " npm install -g ${package_spec} --registry ${NPM_REGISTRY}" + return 1 +} + +print_final_instructions() { + local install_bin_dir="${1:-}" + local install_dir="${2:-}" + local install_method="${3:-standalone}" + local installed_bin="" + local quoted_install_bin_dir="" + local standalone_uninstall_url="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh" + if [[ -n "${install_bin_dir}" ]]; then + installed_bin="${install_bin_dir}/qwen" + quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") + fi + + # PRE_INSTALL_QWENS was captured by main() BEFORE the install ran + # (newline-separated list of every qwen binary found on disk). Filter out + # the one we just installed; whatever remains may shadow this install. + local other_qwens="" + if [[ -n "${PRE_INSTALL_QWENS:-}" ]]; then + local saved_ifs="${IFS}" + IFS=$'\n' + local path + for path in ${PRE_INSTALL_QWENS}; do + [[ -z "${path}" ]] && continue + [[ -n "${installed_bin}" && "${path}" == "${installed_bin}" ]] && continue + if [[ -z "${other_qwens}" ]]; then + other_qwens="${path}" + else + other_qwens="${other_qwens}"$'\n'"${path}" + fi + done + IFS="${saved_ifs}" + fi + + if [[ -n "${install_bin_dir}" ]]; then + export PATH="${install_bin_dir}:${PATH}" + fi + + echo "" + + local installed_version="unknown" + if [[ -n "${installed_bin}" && -x "${installed_bin}" ]]; then + installed_version=$("${installed_bin}" --version 2>/dev/null || echo "unknown") + elif command_exists qwen; then + installed_version=$(qwen --version 2>/dev/null || echo "unknown") + fi + + echo "QWEN CODE" + echo "" + echo "Qwen Code ${installed_version} installed successfully." + echo "" + echo "To start:" + echo " cd " + echo " qwen" + + if [[ -n "${install_dir}" ]]; then + echo "" + echo "Installed to:" + echo " ${install_dir}" + fi + + echo "" + echo "Uninstall:" + if [[ "${install_method}" == "npm" ]]; then + echo " npm uninstall -g @qwen-code/qwen-code" + elif [[ -n "${install_dir}" && -n "${install_bin_dir}" ]]; then + echo " curl -fsSL ${standalone_uninstall_url} | QWEN_INSTALL_LIB_DIR=$(shell_quote "${install_dir}") QWEN_INSTALL_BIN_DIR=$(shell_quote "${install_bin_dir}") bash" + else + echo " curl -fsSL ${standalone_uninstall_url} | bash" + fi + + if [[ -n "${install_bin_dir}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then + maybe_update_shell_path "${install_bin_dir}" + fi + + if [[ -n "${other_qwens}" ]]; then + echo "" + log_warning "Other 'qwen' executables exist on this system. Depending on your" + log_warning "shell PATH order, one of these may run instead of the install above:" + local saved_ifs="${IFS}" + IFS=$'\n' + local path + for path in ${other_qwens}; do + [[ -z "${path}" ]] && continue + log_warning " ${path}" + done + IFS="${saved_ifs}" + echo "" + echo "To make this install take priority, restart your terminal." + echo "Or invoke directly: ${installed_bin}" + return 0 + fi + + echo "(Open a new terminal for the PATH change to take effect.)" +} + +main() { + if [[ -z "${HOME:-}" ]]; then + log_error "HOME is not set; cannot determine where to install Qwen Code." + exit 1 + fi + + # Discover all qwen executables on disk BEFORE we install, so the + # just-installed binary doesn't pollute the search. We can't reliably + # simulate the user's interactive shell PATH (some tools inject their + # bin only under a tty), so we enumerate well-known per-tool bin + # directories plus whatever bash inherited on PATH. + PRE_INSTALL_QWENS=$( + { + IFS=: + for dir in $PATH; do + [[ -z "${dir}" ]] && continue + [[ -x "${dir}/qwen" ]] && echo "${dir}/qwen" + done + for candidate in \ + "${HOME}/.opencode/bin/qwen" \ + "${HOME}/.bun/bin/qwen" \ + "${HOME}/.cargo/bin/qwen" \ + "${HOME}/.deno/bin/qwen" \ + "${HOME}/.volta/bin/qwen" \ + "${HOME}/.fnm/bin/qwen" \ + "${HOME}/.local/bin/qwen" \ + "${HOME}/Library/pnpm/qwen" \ + "/usr/local/bin/qwen" \ + "/opt/homebrew/bin/qwen"; do + [[ -x "${candidate}" ]] && echo "${candidate}" + done + if command_exists npm; then + local npm_prefix + npm_prefix=$(npm prefix -g 2>/dev/null || true) + if [[ -n "${npm_prefix}" && -x "${npm_prefix}/bin/qwen" ]]; then + echo "${npm_prefix}/bin/qwen" + fi + fi + } 2>/dev/null | sort -u + ) + export PRE_INSTALL_QWENS + + print_header + + case "${METHOD}" in + standalone) + install_standalone + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" + ;; + npm) + install_npm + print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" + ;; + detect) + # Try the standalone archive first; fall back only when unavailable. + if install_standalone; then + print_final_instructions "${INSTALL_BIN_DIR}" "${INSTALL_LIB_DIR}" "standalone" + else + standalone_status=$? + if [[ "${standalone_status}" -eq 2 ]]; then + log_warning "Falling back to npm installation." + if install_npm; then + print_final_instructions "$(get_npm_global_bin)" "$(get_npm_global_root)" "npm" + else + log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed." + log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 22+ and rerun --method npm." + exit 1 + fi + else + log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure." + exit "${standalone_status}" + fi + fi + ;; + esac +} + +main "$@" diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index c8ce8cf067..246ffc5049 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -221,7 +221,8 @@ set "QWEN_VALIDATE_NPM_REGISTRY=!NPM_REGISTRY!" set "QWEN_VALIDATE_INSTALL_BASE=!INSTALL_BASE!" set "QWEN_VALIDATE_INSTALL_DIR=!INSTALL_DIR!" set "QWEN_VALIDATE_INSTALL_BIN_DIR=!INSTALL_BIN_DIR!" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" +set "QWEN_VALIDATE_SOURCE=!SOURCE!" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$unsafe = [char[]](10,13,33,34,37,38,60,62,94,96,124); foreach ($name in 'METHOD','MIRROR','BASE_URL','ARCHIVE_PATH','VERSION','NPM_REGISTRY','INSTALL_BASE','INSTALL_DIR','INSTALL_BIN_DIR','SOURCE') { $value = [Environment]::GetEnvironmentVariable('QWEN_VALIDATE_' + $name); if ($null -ne $value -and $value.IndexOfAny($unsafe) -ge 0) { exit 1 } }" set "PS_STATUS=%ERRORLEVEL%" set "QWEN_VALIDATE_METHOD=" set "QWEN_VALIDATE_MIRROR=" @@ -232,6 +233,7 @@ set "QWEN_VALIDATE_NPM_REGISTRY=" set "QWEN_VALIDATE_INSTALL_BASE=" set "QWEN_VALIDATE_INSTALL_DIR=" set "QWEN_VALIDATE_INSTALL_BIN_DIR=" +set "QWEN_VALIDATE_SOURCE=" if %PS_STATUS% NEQ 0 ( echo ERROR: installer options contain unsafe command characters. exit /b 1 @@ -509,6 +511,11 @@ if !ERRORLEVEL! NEQ 0 ( REM Extract into a temporary directory, then validate required entry points. set "EXTRACT_DIR=!TEMP_DIR!\extract" mkdir "!EXTRACT_DIR!" >nul 2>&1 +call :ValidateArchiveContents "!ARCHIVE_FILE!" +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) set "QWEN_ARCHIVE_FILE=!ARCHIVE_FILE!" set "QWEN_EXTRACT_DIR=!EXTRACT_DIR!" powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath $env:QWEN_ARCHIVE_FILE -DestinationPath $env:QWEN_EXTRACT_DIR -Force" @@ -620,6 +627,26 @@ echo SUCCESS: Qwen Code standalone archive installed successfully. echo INFO: Installed to !INSTALL_DIR! exit /b 0 +:ValidateArchiveContents +set "QWEN_ARCHIVE_FILE=%~1" +REM Enumerate archive entries and reject any with path traversal indicators: +REM empty names, leading '/', drive-rooted paths, '..' segments, or control chars. +REM This prevents Zip Slip attacks before extraction. +powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $archive = $null; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($env:QWEN_ARCHIVE_FILE); foreach ($entry in $archive.Entries) { $raw = $entry.FullName; if ($raw.IndexOfAny([char[]](10,13)) -ge 0) { [Console]::Error.WriteLine('Archive contains unsafe path with control character: ' + $raw); exit 1 }; $name = $raw -replace '\\', '/'; while ($name.StartsWith('./')) { $name = $name.Substring(2) }; if ($name -eq '' -or $name.StartsWith('/') -or $name -match '^[A-Za-z]:' -or $name -match '(^|/)\.\.(/|$)') { [Console]::Error.WriteLine('Archive contains unsafe path: ' + $entry.FullName); exit 1 } } } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 } finally { if ($null -ne $archive) { $archive.Dispose() } }" +set "PS_STATUS=%ERRORLEVEL%" +set "QWEN_ARCHIVE_FILE=" +if %PS_STATUS% EQU 0 exit /b 0 +if %PS_STATUS% EQU 1 ( + echo ERROR: Archive contains unsafe path entries. + exit /b 1 +) +if %PS_STATUS% EQU 2 ( + echo ERROR: Archive could not be inspected before extraction. + exit /b 1 +) +echo ERROR: Archive validation failed before extraction. +exit /b %PS_STATUS% + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 new file mode 100644 index 0000000000..cee6854bef --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -0,0 +1,372 @@ +# Qwen Code standalone uninstaller. +# Removes files owned by install-qwen-standalone.bat/.ps1 and preserves user +# config by default. +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.ps1 | iex" +# +# Set $env:QWEN_UNINSTALL_PURGE = '1' (or pass -Purge) to also remove the +# installer source marker at %USERPROFILE%\.qwen\source.json. Other Qwen Code +# config and auth files are preserved. + +param( + [switch]$Purge, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output @" +Qwen Code standalone uninstaller. + +Usage: + uninstall-qwen-standalone.ps1 [-Purge] [-Help] + +Options: + -Purge Also remove %USERPROFILE%\.qwen\source.json (same as + QWEN_UNINSTALL_PURGE=1). + -Help Show this message and exit. +"@ + exit 0 +} + +if ($Purge) { + $env:QWEN_UNINSTALL_PURGE = '1' +} + +function Write-Info { + param([string]$Message) + Write-Output "INFO: $Message" +} + +function Write-Success { + param([string]$Message) + Write-Output "SUCCESS: $Message" +} + +function Write-WarningMessage { + param([string]$Message) + Write-Output "WARNING: $Message" +} + +function Get-QwenInstallBase { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + return $env:QWEN_INSTALL_ROOT + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + return Join-Path $env:LOCALAPPDATA 'qwen-code' + } + + return Join-Path (Join-Path $env:USERPROFILE 'AppData\Local') 'qwen-code' +} + +function Get-QwenInstallDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_LIB_DIR)) { + return $env:QWEN_INSTALL_LIB_DIR + } + + return Join-Path (Get-QwenInstallBase) 'qwen-code' +} + +function Get-QwenInstallBinDir { + if (-not [string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + return $env:QWEN_INSTALL_BIN_DIR + } + + return Join-Path (Get-QwenInstallBase) 'bin' +} + +function Get-CurrentCmdShimStatePath { + return Join-Path (Get-QwenInstallBase) 'current-cmd-shim.txt' +} + +function Get-NormalizedPath { + param([string]$PathValue) + + if ([string]::IsNullOrEmpty($PathValue)) { + return $null + } + + $trimmed = $PathValue.Trim().Trim('"') + if ([string]::IsNullOrEmpty($trimmed)) { + return $null + } + + try { + return [IO.Path]::GetFullPath($trimmed).TrimEnd('\') + } catch { + return $trimmed.TrimEnd('\') + } +} + +function Test-PathMatches { + param([string]$Left, [string]$Right) + + $leftPath = Get-NormalizedPath -PathValue $Left + $rightPath = Get-NormalizedPath -PathValue $Right + if ([string]::IsNullOrEmpty($leftPath) -or [string]::IsNullOrEmpty($rightPath)) { + return $false + } + + return [string]::Equals($leftPath, $rightPath, [StringComparison]::OrdinalIgnoreCase) +} + +function Test-QwenStandaloneInstallDir { + param([string]$InstallDir) + + if (-not (Test-Path -LiteralPath $InstallDir -PathType Container)) { + return $false + } + + $manifestPath = Join-Path $InstallDir 'manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + return $false + } + + try { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + } catch { + return $false + } + + if ($manifest.name -ne '@qwen-code/qwen-code') { + return $false + } + + if ([string]$manifest.target -notmatch '^win-(x64|arm64)$') { + return $false + } + + if (-not (Test-Path -LiteralPath (Join-Path $InstallDir 'bin\qwen.cmd') -PathType Leaf)) { + return $false + } + + if (-not (Test-Path -LiteralPath (Join-Path $InstallDir 'node\node.exe') -PathType Leaf)) { + return $false + } + + return $true +} + +function Remove-UserPathEntry { + param([string]$BinDir) + + $target = Get-NormalizedPath -PathValue $BinDir + if ([string]::IsNullOrEmpty($target)) { + return + } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if ([string]::IsNullOrEmpty($userPath)) { + return + } + + $kept = New-Object System.Collections.Generic.List[string] + $removed = $false + foreach ($entry in @($userPath -split ';')) { + if ([string]::IsNullOrEmpty($entry)) { + continue + } + + if (Test-PathMatches -Left $entry -Right $target) { + $removed = $true + continue + } + + [void]$kept.Add($entry) + } + + if ($removed) { + [Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User') + Write-Success "Removed $BinDir from user PATH." + } + + $current = New-Object System.Collections.Generic.List[string] + foreach ($entry in @($env:Path -split ';')) { + if ([string]::IsNullOrEmpty($entry)) { + continue + } + if (-not (Test-PathMatches -Left $entry -Right $target)) { + [void]$current.Add($entry) + } + } + $env:Path = $current -join ';' +} + +function Add-PathCandidate { + param( + [System.Collections.Generic.List[string]]$Candidates, + [string]$Directory + ) + + $normalizedDirectory = Get-NormalizedPath -PathValue $Directory + if ([string]::IsNullOrEmpty($normalizedDirectory)) { + return + } + + foreach ($candidate in $Candidates) { + $normalizedCandidate = Get-NormalizedPath -PathValue $candidate + if ([string]::Equals($normalizedCandidate, $normalizedDirectory, [StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + [void]$Candidates.Add($Directory.Trim().Trim('"')) +} + +function Remove-CurrentCmdPathShimFile { + param([string]$ShimPath) + + if ([string]::IsNullOrEmpty($ShimPath)) { + return + } + + if (-not (Test-Path -LiteralPath $ShimPath -PathType Leaf)) { + return + } + + $existingShim = Get-Content -LiteralPath $ShimPath -Raw -ErrorAction SilentlyContinue + if ($existingShim -notmatch 'Qwen Code current-session shim') { + return + } + + Remove-Item -LiteralPath $ShimPath -Force -ErrorAction SilentlyContinue + Write-Success "Removed current cmd.exe qwen shim: $ShimPath" +} + +function Remove-RecordedCurrentCmdPathShim { + $statePath = Get-CurrentCmdShimStatePath + if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { + return + } + + foreach ($shimPath in Get-Content -LiteralPath $statePath -ErrorAction SilentlyContinue) { + Remove-CurrentCmdPathShimFile -ShimPath $shimPath + } + + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue +} + +function Remove-CurrentCmdPathShim { + Remove-RecordedCurrentCmdPathShim + + $candidates = [System.Collections.Generic.List[string]]::new() + foreach ($entry in @($env:Path -split ';')) { + if (-not [string]::IsNullOrEmpty($entry)) { + Add-PathCandidate -Candidates $candidates -Directory $entry + } + } + + if (-not [string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps') + } + if (-not [string]::IsNullOrEmpty($env:APPDATA)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:APPDATA 'npm') + } + if (-not [string]::IsNullOrEmpty($env:USERPROFILE)) { + Add-PathCandidate -Candidates $candidates -Directory (Join-Path $env:USERPROFILE '.bun\bin') + } + + foreach ($candidate in $candidates) { + $shimPath = Join-Path $candidate 'qwen.cmd' + Remove-CurrentCmdPathShimFile -ShimPath $shimPath + } +} + +function Remove-InstallWrapper { + param([string]$InstallDir, [string]$BinDir) + + $wrapperPath = Join-Path $BinDir 'qwen.cmd' + if (-not (Test-Path -LiteralPath $wrapperPath -PathType Leaf)) { + return + } + + $wrapper = Get-Content -LiteralPath $wrapperPath -Raw -ErrorAction SilentlyContinue + $targetCommand = Join-Path (Join-Path $InstallDir 'bin') 'qwen.cmd' + if ( + $wrapper -notmatch [regex]::Escape($targetCommand) -and + $wrapper -notmatch 'Qwen Code current-session shim' + ) { + Write-WarningMessage "$wrapperPath does not point at this standalone install; skipping." + return + } + + Remove-Item -LiteralPath $wrapperPath -Force + Write-Success "Removed $wrapperPath" +} + +function Remove-EmptyDirectory { + param([string]$Directory) + + if ([string]::IsNullOrEmpty($Directory)) { + return + } + + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { + return + } + + try { + Remove-Item -LiteralPath $Directory -Force -ErrorAction Stop + } catch { + return + } +} + +function Remove-SourceMarker { + if ([string]::IsNullOrEmpty($env:USERPROFILE)) { + return + } + + $qwenDir = Join-Path $env:USERPROFILE '.qwen' + $sourceJson = Join-Path $qwenDir 'source.json' + + if ($env:QWEN_UNINSTALL_PURGE -ne '1') { + Write-Info "Preserving $qwenDir (set QWEN_UNINSTALL_PURGE=1 to remove source.json)." + return + } + + if (Test-Path -LiteralPath $sourceJson -PathType Leaf) { + Remove-Item -LiteralPath $sourceJson -Force + Write-Success "Removed $sourceJson" + } + + Remove-EmptyDirectory -Directory $qwenDir +} + +Write-Output "Qwen Code Standalone Uninstaller" +Write-Output "" + +$installBase = Get-QwenInstallBase +$installDir = Get-QwenInstallDir +$installBinDir = Get-QwenInstallBinDir +$installWasManaged = Test-QwenStandaloneInstallDir -InstallDir $installDir + +if ($installWasManaged) { + Remove-CurrentCmdPathShim + Remove-Item -LiteralPath $installDir -Recurse -Force + Write-Success "Removed $installDir" +} elseif (Test-Path -LiteralPath $installDir) { + Write-WarningMessage "$installDir exists but is not a Qwen Code standalone install; skipping." +} else { + Write-Info "No standalone runtime found at $installDir." +} + +if ($installWasManaged) { + Remove-InstallWrapper -InstallDir $installDir -BinDir $installBinDir +} else { + Write-Info "Leaving $(Join-Path $installBinDir 'qwen.cmd') unchanged because no managed standalone runtime was removed." +} + +Remove-UserPathEntry -BinDir $installBinDir +Remove-SourceMarker +if ([string]::IsNullOrEmpty($env:QWEN_INSTALL_BIN_DIR)) { + Remove-EmptyDirectory -Directory $installBinDir +} +if ([string]::IsNullOrEmpty($env:QWEN_INSTALL_ROOT)) { + Remove-EmptyDirectory -Directory $installBase +} + +Write-Success "Qwen Code standalone install removed." diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh new file mode 100755 index 0000000000..b59230758e --- /dev/null +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash + +# Qwen Code standalone uninstaller. +# Removes files owned by install-qwen-standalone.sh and preserves user config. + +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_UNINSTALL_REEXEC:-}" ]; then + if command -v bash >/dev/null 2>&1; then + if [ -f "${0}" ]; then + export __QWEN_UNINSTALL_REEXEC=1 + exec bash -- "${0}" "$@" + fi + + echo "Error: This script requires bash. Run the uninstaller with: curl ... | bash" + exit 1 + fi + + echo "Error: This script requires bash. Please install bash first." + exit 1 +fi + +set -eo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" +} + +log_success() { + printf '%bSUCCESS:%b %s\n' "${GREEN}" "${NC}" "$1" +} + +log_warning() { + printf '%bWARNING:%b %s\n' "${YELLOW}" "${NC}" "$1" +} + +log_error() { + printf '%bERROR:%b %s\n' "${RED}" "${NC}" "$1" >&2 +} + +print_usage() { + cat </dev/null || return 1 + grep -Eq '"target"[[:space:]]*:[[:space:]]*"(darwin|linux)-(arm64|x64)"' "${manifest_path}" 2>/dev/null || return 1 + [[ -f "${install_dir}/bin/qwen" && ! -L "${install_dir}/bin/qwen" && -x "${install_dir}/bin/qwen" ]] || return 1 + [[ -f "${install_dir}/node/bin/node" && ! -L "${install_dir}/node/bin/node" && -x "${install_dir}/node/bin/node" ]] || return 1 +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +remove_install_wrapper() { + local wrapper_path="${INSTALL_BIN_DIR}/qwen" + local qwen_bin="${INSTALL_LIB_DIR}/bin/qwen" + + if [[ ! -e "${wrapper_path}" ]]; then + return 0 + fi + + if [[ ! -f "${wrapper_path}" || -L "${wrapper_path}" ]]; then + log_warning "${wrapper_path} exists but is not an install-owned wrapper; skipping." + return 0 + fi + + # The installer writes the path through shell_quote, so the wrapper may + # contain the raw path (no special chars) or the single-quoted form + # (paths with spaces, quotes, or other shell metacharacters). + local quoted_qwen_bin + quoted_qwen_bin=$(shell_quote "${qwen_bin}") + if ! grep -qF "${qwen_bin}" "${wrapper_path}" 2>/dev/null && + ! grep -qF "${quoted_qwen_bin}" "${wrapper_path}" 2>/dev/null; then + log_warning "${wrapper_path} does not point at this standalone install; skipping." + return 0 + fi + + # Defense in depth: only delete files that look like the installer-generated + # wrapper (shebang on first line). A user-authored script that happens to + # mention the install path stays untouched. + if ! head -n 1 "${wrapper_path}" 2>/dev/null | grep -q '^#!'; then + log_warning "${wrapper_path} mentions this install but is not a shell wrapper; skipping." + return 0 + fi + + rm -f "${wrapper_path}" + log_success "Removed ${wrapper_path}" +} + +remove_shell_path_entry() { + local begin_marker="# Qwen Code PATH block begin" + local end_marker="# Qwen Code PATH block end" + local legacy_marker="# Added by qwen-code installer (multi-qwen shadow fix)" + local rc_files=() + local rc_file + + [[ -n "${HOME:-}" ]] || return 0 + rc_files+=("${HOME}/.zshrc") + rc_files+=("${HOME}/.bashrc") + rc_files+=("${HOME}/.bash_profile") + rc_files+=("${HOME}/.profile") + rc_files+=("${HOME}/.config/fish/config.fish") + + for rc_file in "${rc_files[@]}"; do + [[ -f "${rc_file}" ]] || continue + grep -qF "${begin_marker}" "${rc_file}" 2>/dev/null || + grep -qF "${legacy_marker}" "${rc_file}" 2>/dev/null || + continue + + local temp_file + temp_file=$(mktemp "${rc_file}.qwen-uninstall.XXXXXX") || { + log_warning "Could not create temp file for ${rc_file}; leaving PATH entry unchanged." + continue + } + + awk -v begin_marker="${begin_marker}" \ + -v end_marker="${end_marker}" \ + -v legacy_marker="${legacy_marker}" ' + function reset_block( i) { + for (i = 1; i <= block_count; i++) { + delete block[i] + } + block_count = 0 + in_block = 0 + } + function flush_block( i) { + for (i = 1; i <= block_count; i++) { + print block[i] + } + reset_block() + } + index($0, begin_marker) { + if (in_block) { + flush_block() + } + in_block = 1 + block_count = 1 + block[block_count] = $0 + next + } + in_block { + block_count++ + block[block_count] = $0 + if (index($0, end_marker)) { + reset_block() + } + next + } + index($0, legacy_marker) { check_next = 1; next } + check_next == 1 { + check_next = 0 + if ($0 ~ /^[[:space:]]*export PATH=/ || + $0 ~ /^[[:space:]]*set -gx PATH /) { + next + } + } + { print } + END { + if (in_block) { + flush_block() + } + } + ' "${rc_file}" > "${temp_file}" && mv "${temp_file}" "${rc_file}" || { + rm -f "${temp_file}" + log_warning "Could not remove Qwen Code PATH entry from ${rc_file}." + continue + } + + log_success "Removed Qwen Code PATH entry from ${rc_file}" + done +} + +remove_empty_dir() { + local dir="$1" + + [[ -d "${dir}" ]] || return 0 + rmdir "${dir}" 2>/dev/null || true +} + +remove_source_marker() { + local source_json="${HOME:-}/.qwen/source.json" + + if [[ "${PURGE}" != "1" ]]; then + log_info "Preserving ${HOME:-~}/.qwen (set QWEN_UNINSTALL_PURGE=1 to remove source.json)." + return 0 + fi + + [[ -n "${HOME:-}" ]] || return 0 + if [[ -f "${source_json}" ]]; then + rm -f "${source_json}" + log_success "Removed ${source_json}" + fi + remove_empty_dir "${HOME}/.qwen" +} + +validate_options + +echo "Qwen Code Standalone Uninstaller" +echo "" + +install_was_managed=0 +if is_qwen_standalone_install_dir "${INSTALL_LIB_DIR}"; then + install_was_managed=1 + rm -rf "${INSTALL_LIB_DIR}" + log_success "Removed ${INSTALL_LIB_DIR}" +elif [[ -e "${INSTALL_LIB_DIR}" ]]; then + log_warning "${INSTALL_LIB_DIR} exists but is not a Qwen Code standalone install; skipping." +else + log_info "No standalone runtime found at ${INSTALL_LIB_DIR}." +fi + +if [[ "${install_was_managed}" == "1" ]]; then + remove_install_wrapper +else + log_info "Leaving ${INSTALL_BIN_DIR}/qwen unchanged because no managed standalone runtime was removed." +fi + +remove_shell_path_entry +remove_source_marker + +log_success "Qwen Code standalone install removed." diff --git a/scripts/release-script-utils.js b/scripts/release-script-utils.js new file mode 100644 index 0000000000..a574915dba --- /dev/null +++ b/scripts/release-script-utils.js @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +export function fail(message) { + throw new Error(`ERROR: ${message}`); +} + +export async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await pipeline(fs.createReadStream(filePath), hash); + return hash.digest('hex'); +} + +/** + * Parse a SHA256SUMS file. Handles: + * - optional leading UTF-8 BOM (uploaded via Windows tools) + * - binary-prefix markers (`*` before filename) + * - empty lines and CRLF / LF line endings + */ +export function parseSha256Sums(content) { + // Strip a leading UTF-8 BOM so a SHA256SUMS file uploaded via a Windows tool + // that prepends one still reports a useful "Missing checksum entry" error + // instead of "Malformed SHA256SUMS line 1". + const normalized = content.replace(/^\uFEFF/, ''); + const checksums = new Map(); + for (const [index, line] of normalized.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(trimmed); + if (!match) { + fail(`Malformed SHA256SUMS line ${index + 1}: ${trimmed}`); + } + if (checksums.has(match[2])) { + fail(`Duplicate SHA256SUMS entry for: ${match[2]}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +export function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + fail(`${optionName} requires a value`); + } + return value; +} + +export function isMainModule(importMetaUrl) { + const filename = fileURLToPath(importMetaUrl); + return process.argv[1] && path.resolve(process.argv[1]) === filename; +} + +/** + * Parse CLI arguments. Supports: + * - --flag → args[def.key] = true + * - --key value → args[def.key] = value + * - --key=value → args[def.key] = value + * - -h, --help → args.help = true (always recognised) + * + * @param {string[]} argv + * @param {Record} definitions + * @returns {{help: false} & Record} + */ +export function parseArgs(argv, definitions) { + const args = { help: false }; + + for (let index = 0; index < argv.length; index += 1) { + const raw = argv[index]; + if (raw === '--help' || raw === '-h') { + args.help = true; + continue; + } + + // --key=value form + const eqIndex = raw.indexOf('='); + if (eqIndex >= 0) { + const key = raw.slice(0, eqIndex); + const value = raw.slice(eqIndex + 1); + if (key === '--help' || key === '-h') { + fail(`${key} does not accept a value`); + } + const def = definitions[key]; + if (!def) { + fail(`Unknown option: ${key}`); + } + if (def.type === 'flag') { + fail(`${key} does not accept a value`); + } + if (!value || value.startsWith('-')) { + fail(`${key} requires a value`); + } + args[def.key] = value; + continue; + } + + const def = definitions[raw]; + if (!def) { + fail(`Unknown option: ${raw}`); + } + + if (def.type === 'flag') { + args[def.key] = true; + continue; + } + + args[def.key] = readOptionValue(argv, index, raw); + index += 1; + } + + return args; +} diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index a83a54292d..763fe61cc6 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -13,7 +13,9 @@ const { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -27,15 +29,26 @@ const readScript = (path) => readFileSync(path, 'utf8'); const standaloneReleaseScriptUrl = pathToFileURL( path.resolve('scripts/build-standalone-release.js'), ).href; +const hostedInstallationScriptUrl = pathToFileURL( + path.resolve('scripts/build-hosted-installation-assets.js'), +).href; +const installationReleaseVerificationScriptUrl = pathToFileURL( + path.resolve('scripts/verify-installation-release.js'), +).href; +const releaseScriptUtilsUrl = pathToFileURL( + path.resolve('scripts/release-script-utils.js'), +).href; // These E2E cases execute the Unix shell installer and POSIX symlink behavior. // Windows batch behavior has separate Windows-only E2E coverage below. const itOnUnix = process.platform === 'win32' ? it.skip : it; const itOnWindows = process.platform === 'win32' ? it : it.skip; +vi.setConfig({ testTimeout: 30_000 }); + describe('installation scripts', () => { it('keeps the Linux/macOS installer lightweight', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', ); expect(script).not.toContain('install_nvm'); @@ -48,21 +61,29 @@ describe('installation scripts', () => { expect(script).not.toContain('.npm-global'); expect(script).not.toMatch(/^\s*exec\s+qwen\s*$/m); expect(script).not.toContain('--print-env'); - expect(script).not.toContain('brew install node@20'); + expect(script).not.toMatch(/brew install node@\d+/); expect(script).toContain('brew install node'); expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', ); - expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Node.js 22 or newer is required'); + expect(script).toContain('npm_package_spec()'); + expect(script).toContain('@qwen-code/qwen-code@latest'); + expect(script).toContain('Installing Qwen Code version:'); + expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', + 'Qwen Code ${installed_version} installed successfully.', ); - expect(script).toContain('You can now run: qwen'); + expect(script).toContain('To start:'); + expect(script).toContain('Installed to:'); + expect(script).toContain('Uninstall:'); + expect(script).toContain('uninstall-qwen-standalone.sh'); + expect(script).not.toContain('rm -rf $(shell_quote "${install_dir}")'); }); it('supports code-server-style standalone install on Linux/macOS', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', ); expect(script).toContain('--method METHOD'); @@ -73,7 +94,9 @@ describe('installation scripts', () => { expect(script).toContain('install_npm()'); expect(script).toContain('detect_target()'); expect(script).toContain('verify_checksum()'); - expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain( + 'SHA256SUMS not found at ${checksum_file}; cannot verify archive', + ); expect(script).toContain('awk -v archive_name'); expect(script).not.toContain( 'grep -E "(^|[[:space:]])[*]?${archive_name}$"', @@ -87,6 +110,12 @@ describe('installation scripts', () => { expect(script).toContain('Falling back to npm installation'); expect(script).toContain('standalone_status=$?'); expect(script).toContain('[[ "${standalone_status}" -eq 2 ]]'); + expect(script).toMatch( + /Aliyun standalone archive not found; retrying GitHub mirror\.[\s\S]*checksum_source="\$\{base_url\}\/SHA256SUMS"[\s\S]*MIRROR="github"/, + ); + expect(script).toMatch( + /archive_url="\$\{github_fallback_base_url\}\/\$\{archive_name\}"[\s\S]*checksum_source="\$\{github_fallback_base_url\}\/SHA256SUMS"[\s\S]*MIRROR="github"[\s\S]*Aliyun standalone archive download failed; retrying GitHub mirror\./, + ); expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); @@ -109,20 +138,71 @@ describe('installation scripts', () => { expect(script).toContain( 'tar -xzf "${archive_path}" -C "${destination}" || return 1', ); - expect(script).toContain('wget -q --tries=3 "${url}" -O "${destination}"'); + expect(script).toContain( + 'curl -fL --retry 2 --connect-timeout 15 --max-time 300 --progress-bar "${url}" -o "${destination}"', + ); + expect(script).toContain( + 'curl -fsSL --retry 2 --connect-timeout 10 --max-time 30 "${url}"', + ); + expect(script).toContain('wget -q "${wget_args[@]}" -O - "${url}"'); + expect(script).toContain( + 'wget --progress=bar:force:noscroll "${wget_args[@]}" "${url}" -O "${destination}"', + ); + expect(script).toContain('wget_args+=(--read-timeout=300)'); + expect(script).toContain( + 'curl -fsL --retry 1 --connect-timeout 10 --max-time "${timeout}"', + ); + expect(script).toContain('wget_args+=(--read-timeout=30)'); + expect(script).toContain('echo "Downloading ${archive_name}"'); + expect(script).not.toContain( + 'curl -fsSL --retry 2 "${url}" -o "${destination}"', + ); + expect(script).not.toContain( + 'wget -q --tries=3 "${url}" -O "${destination}"', + ); expect(script).toContain('TEMP_DIRS+='); + expect(script).toContain('validate_github_repo()'); + expect(script).toContain( + 'QWEN_INSTALL_GITHUB_REPO must be in owner/repo format', + ); + expect(script).toContain('set -gx PATH ${quoted_install_bin_dir} \\$PATH'); + expect(script).toContain('export PATH=${quoted_install_bin_dir}:\\$PATH'); + expect(script).toContain('Unsupported shell for automatic PATH update'); + expect(script).toContain('# Qwen Code PATH block begin'); + expect(script).toContain('# Qwen Code PATH block end'); + expect(script).toContain('probe_url_available()'); + expect(script).toContain('/latest/VERSION'); + expect(script).toContain('resolve_aliyun_version_path()'); + expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('entry="${entry//\\\\//}"'); + expect(script).toContain('restore_stale_install_backup()'); + expect(script).toContain( + 'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"', + ); + expect(script).not.toContain( + 'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"', + ); expect(script).not.toContain('-print -quit'); }); it('keeps the Windows installer lightweight', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', + 'scripts/installation/install-qwen-standalone.bat', ); expect(script).not.toContain('InstallNodeJSDirectly'); expect(script).not.toContain('node-v!NODE_VERSION!'); expect(script).not.toContain('msiexec'); - expect(script).not.toContain('Invoke-WebRequest'); + expect(script).toContain('Invoke-WebRequest'); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -#fSLo', + ); + expect(script).toContain( + '& $curl --connect-timeout 15 --max-time 300 --retry 2 -fsSLo', + ); + expect(script).toContain('-TimeoutSec 300'); + expect(script).toContain('$request.Timeout = 10000'); + expect(script).toContain('$request.ReadWriteTimeout = 30000'); expect(script).not.toContain('PowerShell (Administrator)'); expect(script).not.toContain('echo INFO: Installation source: %SOURCE%'); expect(script).not.toMatch(/^\s*call\s+qwen\s*$/m); @@ -132,17 +212,38 @@ describe('installation scripts', () => { expect(script).toContain( '--source may only contain letters, numbers, dot, underscore, or dash', ); - expect(script).toContain('Node.js 20 or newer is required'); + expect(script).toContain('Node.js 22 or newer is required'); expect(script).toContain('Please install Node.js'); + expect(script).toContain(':NpmPackageSpec'); + expect(script).toContain('@qwen-code/qwen-code@latest'); + expect(script).toContain('Installing Qwen Code version:'); + expect(script).toContain('QWEN CODE'); expect(script).toContain( - 'npm install -g @qwen-code/qwen-code@latest --registry', + 'Qwen Code !INSTALLED_VERSION! installed successfully.', ); - expect(script).toContain('You can now run: qwen'); + expect(script).toContain('To start:'); + expect(script).toContain('Installed to:'); + expect(script).toContain('Uninstall:'); + expect(script).toContain('uninstall-qwen-standalone.ps1'); + expect(script).toContain('QWEN_VERSION_POINTER_FILE'); + expect(script).toContain('QWEN_NORMALIZED_VERSION_FILE'); + expect(script).toContain('NORMALIZED_VERSION_FILE'); + expect(script).toContain( + '[IO.File]::ReadAllText($env:QWEN_VERSION_POINTER_FILE)', + ); + expect(script).toContain( + '[IO.File]::WriteAllText($env:QWEN_NORMALIZED_VERSION_FILE', + ); + expect(script).not.toContain( + 'findstr /R /C:"^[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*$"', + ); + expect(script).not.toContain('rmdir /S /Q "!SUMMARY_INSTALL_DIR!"'); + expect(script).not.toContain('del /F /Q "!INSTALLED_BIN!"'); }); it('supports code-server-style standalone install on Windows', () => { const script = readScript( - 'scripts/installation/install-qwen-with-source.bat', + 'scripts/installation/install-qwen-standalone.bat', ); expect(script).toContain('--method METHOD'); @@ -152,14 +253,20 @@ describe('installation scripts', () => { expect(script).toContain(':InstallStandalone'); expect(script).toContain(':InstallNpm'); expect(script).toContain(':VerifyChecksum'); - expect(script).toContain('SHA256SUMS not found; cannot verify archive'); + expect(script).toContain( + 'SHA256SUMS not found at !CHECKSUM_FILE!; cannot verify archive', + ); expect(script).toContain('Get-FileHash -Algorithm SHA256'); expect(script).toContain('tokens=1,2'); expect(script).toContain('CHECKSUM_NAME'); expect(script).toContain('if "!CHECKSUM_NAME!"=="!ARCHIVE_NAME!"'); expect(script).not.toContain('findstr /C:"!ARCHIVE_NAME!"'); expect(script).not.toContain('certutil -hashfile'); - expect(script).toContain('qwen-code-win-x64.zip'); + expect(script).toContain('qwen-code-!TARGET!.zip'); + expect(script).toContain( + 'if /i "!PROCESSOR_ARCHITECTURE!"=="AMD64" set "TARGET=win-x64"', + ); + expect(script).not.toContain('if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64"'); expect(script).toContain('Expand-Archive'); expect(script).toContain('$env:QWEN_DOWNLOAD_URL'); expect(script).toContain('$env:QWEN_ARCHIVE_FILE'); @@ -171,7 +278,12 @@ describe('installation scripts', () => { expect(script).toContain( 'installer options contain unsafe command characters', ); - expect(script).toContain('[char[]](10,13,33,34'); + expect(script).not.toContain('-EncodedCommand'); + expect(script).toContain('QWEN_VALIDATE_OPTIONS_SCRIPT'); + expect(script).toContain('$unsafe = [char[]](10,13,33,34'); + expect(script).toContain( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "!QWEN_VALIDATE_OPTIONS_SCRIPT!"', + ); expect(script).toContain('if "!INSTALL_BASE:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_DIR:~1,2!"==":/"'); expect(script).toContain('if "!INSTALL_BIN_DIR:~1,2!"==":/"'); @@ -179,21 +291,267 @@ describe('installation scripts', () => { expect(script).toContain( 'call :ValidateHttpsUrlVar "NPM_REGISTRY" "--registry"', ); - expect(script).toContain("$ErrorActionPreference = 'Stop'; try"); + expect(script).toContain('$curl = $env:QWEN_INSTALL_CURL_EXE'); + expect(script).toContain('QWEN_INSTALL_CURL_EXE'); + expect(script).toContain('Get-Command curl.exe -CommandType Application'); expect(script).toContain( - '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + '--connect-timeout 15 --max-time 300 --retry 2 -#fSLo', ); + expect(script).toContain( + '--connect-timeout 15 --max-time 300 --retry 2 -fsSLo', + ); + expect(script).toContain('Invoke-WebRequest'); + expect(script).toContain('-TimeoutSec 300'); + expect(script).toContain( + '[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13', + ); + expect(script).toContain( + '$request = [Net.WebRequest]::Create($env:QWEN_CHECK_URL)', + ); + expect(script).toContain("Headers.Add('Range', 'bytes=0-0')"); expect(script).toContain('must start with https://'); expect(script).toContain('Falling back to npm installation'); expect(script).toContain('set "STANDALONE_STATUS=!ERRORLEVEL!"'); expect(script).toContain('if !STANDALONE_STATUS! EQU 2'); + expect(script).toContain('set "ARG_KEY=%~1"'); + expect(script).toContain('set "ARG_HAS_INLINE_VALUE=0"'); + expect(script).toContain('if "!ARG_HAS_INLINE_VALUE!"=="1"'); + expect(script).toContain('if /i "!ARG_KEY!"=="--version"'); + expect(script).toContain('$value -match'); + expect(script).toContain('QWEN_INSTALL_GITHUB_REPO'); + expect(script).toContain( + 'QWEN_INSTALL_GITHUB_REPO must be in owner/repo format', + ); expect(script).toContain( 'Standalone install failed. Retry with --method npm', ); expect(script).toContain('qwen-code\\node\\node.exe'); expect(script).toContain('Archive contains symlinks or reparse points'); + expect(script).toContain('unsafe path with control character'); + expect(script).toContain('Failed to update user PATH'); expect(script).toContain('QWEN_INSTALL_ROOT'); expect(script).toContain('npm fallback also failed'); + expect(script).toContain('echo Downloading !ARCHIVE_NAME!'); + expect(script).toContain(':CreateTempFile'); + expect(script).toContain('/latest/VERSION'); + expect(script).toContain(':ResolveAliyunVersionPath'); + expect(script).toContain(':UseGithubFallbackBaseUrl'); + expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%"'); + expect(script).not.toContain( + 'endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%"', + ); + expect(script).toContain( + 'if /i "!METHOD!"=="detect" exit /b 2\r\n exit /b 1', + ); + expect(script).toContain(':RestoreStaleInstallBackup'); + expect(script).toContain('call :RestoreStaleInstallBackup'); + expect(script).not.toContain( + 'ERROR: Failed to remove stale backup directory', + ); + expect(script).toContain('call :ValidateRawEnvironmentOptions'); + expect(script).toContain('$rawNames = @('); + expect(script).toContain("'QWEN_INSTALL_VERSION'"); + expect(script.indexOf('$rawNames = @(')).toBeLessThan( + script.indexOf('set "QWEN_VALIDATE_VERSION=!VERSION!"'), + ); + expect(script).toContain('set "ARCHIVE_NAME=qwen-code-!TARGET!.zip"'); + expect(script).toContain('Keep :DetectTarget in sync with RELEASE_TARGETS'); + // ARM64 is intentionally not detected: RELEASE_TARGETS has no win-arm64 + // entry, so we want :DetectTarget to fall through to the unsupported-arch + // branch and let the caller fall back to npm. + expect(script).not.toContain( + 'if /i "!PROCESSOR_ARCHITECTURE!"=="ARM64" set "TARGET=win-arm64"', + ); + expect(script).not.toContain('%RANDOM%'); + }); + + it('checks out the Windows standalone batch installer with CRLF line endings', () => { + const attrs = execFileSync( + 'git', + [ + 'check-attr', + 'eol', + '--', + 'scripts/installation/install-qwen-standalone.bat', + ], + { encoding: 'utf8' }, + ); + + expect(attrs).toContain( + 'scripts/installation/install-qwen-standalone.bat: eol: crlf', + ); + + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + const bareLfLines = script + .split(/(?<=\n)/) + .filter((line) => line.endsWith('\n') && !line.endsWith('\r\n')); + expect(bareLfLines).toHaveLength(0); + }); + + it('prepends fake Windows tools to both PATH casings', () => { + const fakeBin = 'C:\\qwen-test-bin'; + + const env = prependWindowsPath(fakeBin); + + expect(env.PATH).toMatch(/^C:\\qwen-test-bin;/); + expect(env.Path).toMatch(/^C:\\qwen-test-bin;/); + }); + + it('creates a fake Windows curl command script', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-curl-helper-')); + + try { + const fakeCurl = createFakeWindowsCurlCommand(tmpDir); + + expect(fakeCurl).toBe(path.join(tmpDir, 'curl.cmd')); + expect(readScript(fakeCurl)).toContain('QWEN_FAKE_CURL_LOG'); + expect(readScript(fakeCurl)).toContain( + '/releases/qwen-code/latest/VERSION', + ); + expect(readScript(fakeCurl)).toContain('set "destination=%~2"'); + expect(readScript(fakeCurl)).not.toContain('set "destination=%~1"'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('injects Windows processor overrides directly into cmd commands', () => { + const prepared = prepareWindowsCommand( + 'call "C:\\tools\\install-qwen-standalone.bat"', + { + Path: 'C:\\fake-bin', + PROCESSOR_ARCHITECTURE: 'AMD64', + PROCESSOR_ARCHITEW6432: '', + }, + { + Path: 'C:\\Windows\\System32', + processor_architecture: 'ARM64', + PROCESSOR_ARCHITEW6432: 'ARM64', + }, + ); + + expect(prepared.command).toBe( + 'set "PROCESSOR_ARCHITECTURE=AMD64" && set "PROCESSOR_ARCHITEW6432=" && call "C:\\tools\\install-qwen-standalone.bat"', + ); + expect(prepared.env).toEqual({ Path: 'C:\\fake-bin' }); + }); + + it('creates PowerShell validation scripts with a ps1 extension', () => { + const script = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + + expect(script).toContain( + 'call :CreateTempFile "qwen-validate-options" ".ps1"', + ); + expect(script).toContain( + "($env:QWEN_TEMP_FILE_PREFIX + '-' + [IO.Path]::GetRandomFileName() + $env:QWEN_TEMP_FILE_EXTENSION)", + ); + }); +}); + +describe('release-script-utils', () => { + it('parses SHA256SUMS with BOM, empty lines, and CRLF', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + + const checksums = parseSha256Sums( + `\uFEFF${'a'.repeat(64)} install-qwen-standalone.sh\n\n${'b'.repeat(64)} *install-qwen-standalone.bat\r\n${'c'.repeat(64)} install-qwen-standalone.ps1\n`, + ); + + expect(checksums.get('install-qwen-standalone.sh')).toBe('a'.repeat(64)); + expect(checksums.get('install-qwen-standalone.bat')).toBe('b'.repeat(64)); + expect(checksums.get('install-qwen-standalone.ps1')).toBe('c'.repeat(64)); + }); + + it('rejects malformed SHA256SUMS entries', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + + expect(() => + parseSha256Sums('short-hash install-qwen-standalone.sh\n'), + ).toThrow(/Malformed SHA256SUMS line 1/); + }); + + it('rejects duplicate SHA256SUMS entries', async () => { + const { parseSha256Sums } = await import(releaseScriptUtilsUrl); + const first = 'a'.repeat(64); + const second = 'b'.repeat(64); + + expect(() => + parseSha256Sums( + `${first} install-qwen-standalone.sh\n${second} install-qwen-standalone.sh\n`, + ), + ).toThrow(/Duplicate SHA256SUMS entry for: install-qwen-standalone\.sh/); + }); + + it('supports --key=value form in parseArgs', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { + '--out-dir': { key: 'outDir', type: 'value' }, + '--verbose': { key: 'verbose', type: 'flag' }, + }; + + const args = parseArgs(['--out-dir=/tmp/build', '--verbose'], defs); + expect(args.outDir).toBe('/tmp/build'); + expect(args.verbose).toBe(true); + expect(args.help).toBe(false); + }); + + it('supports --key value form in parseArgs', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--out-dir': { key: 'outDir', type: 'value' } }; + + const args = parseArgs(['--out-dir', '/tmp/build'], defs); + expect(args.outDir).toBe('/tmp/build'); + }); + + it('rejects unknown options and missing values', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--out-dir': { key: 'outDir', type: 'value' } }; + + expect(() => parseArgs(['--unknown'], defs)).toThrow( + /Unknown option: --unknown/, + ); + expect(() => parseArgs(['--out-dir'], defs)).toThrow( + /--out-dir requires a value/, + ); + expect(() => parseArgs(['--out-dir='], defs)).toThrow( + /--out-dir requires a value/, + ); + expect(() => parseArgs(['--out-dir', '--help'], defs)).toThrow( + /--out-dir requires a value/, + ); + expect(() => parseArgs(['--out-dir=-tmp'], defs)).toThrow( + /--out-dir requires a value/, + ); + }); + + it('rejects --key=value for flag-type options', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + const defs = { '--verbose': { key: 'verbose', type: 'flag' } }; + + expect(() => parseArgs(['--verbose=true'], defs)).toThrow( + /--verbose does not accept a value/, + ); + }); + + it('recognises -h and --help without definitions', async () => { + const { parseArgs } = await import(releaseScriptUtilsUrl); + + expect(parseArgs(['--help'], {}).help).toBe(true); + expect(parseArgs(['-h'], {}).help).toBe(true); + expect(() => parseArgs(['--help=anything'], {})).toThrow( + /--help does not accept a value/, + ); + }); + + it('fail() wraps messages with ERROR: prefix', async () => { + const { fail } = await import(releaseScriptUtilsUrl); + expect(() => fail('something went wrong')).toThrow( + 'ERROR: something went wrong', + ); }); }); @@ -207,8 +565,20 @@ describe('standalone release packaging', () => { expect(packageJson.scripts['package:standalone:release']).toBe( 'node scripts/build-standalone-release.js', ); + expect(packageJson.scripts['package:hosted-installation']).toBe( + 'node scripts/build-hosted-installation-assets.js', + ); + expect(packageJson.scripts['verify:installation-release']).toBe( + 'node scripts/verify-installation-release.js', + ); + expect(packageJson.scripts['package:installation-assets']).toBeUndefined(); expect(existsSync('scripts/create-standalone-package.js')).toBe(true); expect(existsSync('scripts/build-standalone-release.js')).toBe(true); + expect(existsSync('scripts/build-hosted-installation-assets.js')).toBe( + true, + ); + expect(existsSync('scripts/verify-installation-release.js')).toBe(true); + expect(existsSync('scripts/build-installation-assets.js')).toBe(false); const packageScript = readScript('scripts/create-standalone-package.js'); expect(packageScript).toContain('Copyright 2025 Qwen Team'); @@ -242,6 +612,39 @@ describe('standalone release packaging', () => { expect(releaseScript).toContain('scripts/create-standalone-package.js'); expect(releaseScript).toContain('--skip-checksums'); expect(releaseScript).toContain('writeSha256Sums(outDir)'); + + const hostedInstallScript = readScript( + 'scripts/build-hosted-installation-assets.js', + ); + expect(hostedInstallScript).toContain('Copyright 2026 Qwen Team'); + expect(hostedInstallScript).toContain('buildHostedInstallationAssets'); + expect(hostedInstallScript).toContain('HOSTED_INSTALLATION_ASSETS'); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.sh'", + ); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.bat'", + ); + expect(hostedInstallScript).toContain( + "output: 'install-qwen-standalone.ps1'", + ); + expect(hostedInstallScript).not.toContain("output: 'install'"); + + const releaseVerifyScript = readScript( + 'scripts/verify-installation-release.js', + ); + expect(releaseVerifyScript).toContain('Copyright 2026 Qwen Team'); + expect(releaseVerifyScript).toContain('verifyReleaseDirectory'); + expect(releaseVerifyScript).toContain('verifyReleaseBaseUrl'); + expect(releaseVerifyScript).toContain('EXPECTED_RELEASE_ASSET_NAMES'); + expect(releaseVerifyScript).toContain('EXPECTED_STANDALONE_ARCHIVE_NAMES'); + expect(releaseVerifyScript).toContain('import { RELEASE_TARGETS }'); + expect(releaseVerifyScript).toContain( + 'standaloneArchiveNamesFromReleaseTargets', + ); + expect(releaseVerifyScript).not.toContain("'qwen-code-win-x64.zip'"); + expect(releaseVerifyScript).not.toContain('INSTALLATION_ASSET_NAMES'); + expect(releaseVerifyScript).not.toContain('assertInstallAliasMatches'); }); it('loads the standalone release packaging helper', () => { @@ -255,21 +658,92 @@ describe('standalone release packaging', () => { expect(output).toContain('--node-version VERSION'); }); + it('loads the hosted installation release helpers', () => { + const hostedOutput = execFileSync( + process.execPath, + ['scripts/build-hosted-installation-assets.js', '--help'], + { encoding: 'utf8' }, + ); + const verifierOutput = execFileSync( + process.execPath, + ['scripts/verify-installation-release.js', '--help'], + { encoding: 'utf8' }, + ); + + expect(hostedOutput).toContain('package:hosted-installation'); + expect(hostedOutput).toContain('--out-dir PATH'); + expect(verifierOutput).toContain('verify:installation-release'); + expect(verifierOutput).toContain('--dir PATH'); + expect(verifierOutput).toContain('--base-url URL'); + }); + + it('rejects invalid installation release verification CLI arguments', () => { + const expectFail = (args, expectedOutput) => { + let caughtError; + try { + execFileSync(process.execPath, args, { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeTruthy(); + expect( + [ + caughtError?.message, + caughtError?.stdout?.toString(), + caughtError?.stderr?.toString(), + ].join('\n'), + ).toMatch(expectedOutput); + }; + + expectFail( + ['scripts/verify-installation-release.js', '--unknown'], + /Unknown option: --unknown/, + ); + expectFail( + ['scripts/verify-installation-release.js', '--dir'], + /--dir requires a value/, + ); + expectFail( + [ + 'scripts/verify-installation-release.js', + '--dir', + '/tmp', + '--base-url', + 'https://example.com/r/', + ], + /Pass --dir or --base-url, not both/, + ); + expectFail( + [ + 'scripts/verify-installation-release.js', + '--dir=/tmp', + '--base-url=https://example.com/r/', + ], + /Pass --dir or --base-url, not both/, + ); + expectFail( + ['scripts/verify-installation-release.js', '--unknown=foo'], + /Unknown option: --unknown/, + ); + }); + it('parses Node.js SHASUMS entries', async () => { const { parseChecksums } = await import(standaloneReleaseScriptUrl); const checksums = parseChecksums( [ - 'a'.repeat(64) + ' node-v20.19.0-linux-x64.tar.xz', - 'b'.repeat(64) + ' *node-v20.19.0-win-x64.zip', + 'a'.repeat(64) + ' node-v22.0.0-linux-x64.tar.xz', + 'b'.repeat(64) + ' *node-v22.0.0-win-x64.zip', '', ].join('\n'), ); - expect(checksums.get('node-v20.19.0-linux-x64.tar.xz')).toBe( - 'a'.repeat(64), - ); - expect(checksums.get('node-v20.19.0-win-x64.zip')).toBe('b'.repeat(64)); + expect(checksums.get('node-v22.0.0-linux-x64.tar.xz')).toBe('a'.repeat(64)); + expect(checksums.get('node-v22.0.0-win-x64.zip')).toBe('b'.repeat(64)); }); it('validates standalone release checksum output', async () => { @@ -297,6 +771,686 @@ describe('standalone release packaging', () => { } }); + it('installer scripts honor --version for hosted entrypoints', () => { + const installShellSource = readScript( + 'scripts/installation/install-qwen-standalone.sh', + ); + expect(installShellSource).toContain( + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"', + ); + expect(installShellSource).toContain('--version)'); + expect(installShellSource).toContain('--version requires a value'); + + const installBatchSource = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + expect(installBatchSource).toContain('set "VERSION=latest"'); + expect(installBatchSource).toContain( + 'if defined QWEN_INSTALL_VERSION set "VERSION=!QWEN_INSTALL_VERSION!"', + ); + expect(installBatchSource).toContain('!ARG_KEY!"=="--version"'); + expect(installBatchSource).toContain('--version requires a value'); + + const installPowerShellSource = readScript( + 'scripts/installation/install-qwen-standalone.ps1', + ); + expect(installPowerShellSource).toContain('install-qwen-standalone.bat'); + expect(installPowerShellSource).toContain('Invoke-WebRequest'); + expect(installPowerShellSource).toContain('Download-File'); + expect(installPowerShellSource).toContain( + 'curl.exe --connect-timeout 15 --max-time 300 --retry 2 -sSfLo', + ); + expect(installPowerShellSource).toContain('-TimeoutSec 300'); + expect(installPowerShellSource).toContain( + "$global:ProgressPreference = 'SilentlyContinue'", + ); + expect(installPowerShellSource).toContain('QWEN_INSTALL_VERSION'); + expect(installPowerShellSource).toContain('--version vX.Y.Z'); + expect(installPowerShellSource).toContain('SHA256SUMS'); + expect(installPowerShellSource).toContain('Get-FileHash'); + expect(installPowerShellSource).toContain('Checksum mismatch'); + expect(installPowerShellSource).toContain('@args'); + }); + + it('PowerShell hosted entrypoint refreshes the current Windows shell', () => { + const installPowerShellSource = readScript( + 'scripts/installation/install-qwen-standalone.ps1', + ); + const installBatchSource = readScript( + 'scripts/installation/install-qwen-standalone.bat', + ); + + expect(installPowerShellSource).toContain('Update-CurrentSessionPath'); + expect(installPowerShellSource).toContain('Install-CurrentCmdPathShim'); + expect(installPowerShellSource).toContain('Save-CurrentCmdPathShim'); + expect(installPowerShellSource).toContain('current-cmd-shim.txt'); + expect(installPowerShellSource).toContain('Test-WritableDirectory'); + expect(installPowerShellSource).toContain('Qwen Code current-session shim'); + expect(installPowerShellSource).toContain( + 'TEMP environment variable is not set', + ); + expect(installPowerShellSource).toMatch( + /function Get-QwenInstallBinDir \{[\s\S]*QWEN_INSTALL_BIN_DIR[\s\S]*return Join-Path \(Get-QwenInstallBase\) 'bin'[\s\S]*\}/, + ); + expect(installPowerShellSource).toContain( + 'Test-SystemManagedPathDirectory', + ); + expect(installPowerShellSource).not.toContain( + "$preferredDirectories += Join-Path $env:LOCALAPPDATA 'Microsoft\\WindowsApps'", + ); + expect(installPowerShellSource).toContain( + 'QWEN_NO_MODIFY_PATH=1; skipping current-session PATH refresh.', + ); + expect(installPowerShellSource).not.toContain('doskey.exe'); + expect(installPowerShellSource).toContain( + 'qwen is ready to use in this PowerShell session.', + ); + expect(installPowerShellSource).toContain( + 'Added qwen.cmd to a directory already on this cmd.exe PATH:', + ); + expect(installPowerShellSource).toContain( + 'Windows does not allow this PowerShell child process to update the parent cmd.exe PATH directly.', + ); + + expect(installBatchSource).toContain('QWEN_INSTALLER_PARENT_POWERSHELL'); + expect(installBatchSource).toContain( + 'Final PATH refresh is handled by the PowerShell entrypoint.', + ); + }); + + it('stages hosted installation assets with checksums', async () => { + const { + HOSTED_INSTALLATION_ASSET_NAMES, + HOSTED_INSTALLATION_ASSETS, + assertHostedInstallationAssetChecksums, + buildHostedInstallationAssets, + } = await import(hostedInstallationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + + try { + await buildHostedInstallationAssets(tmpDir); + + const installSh = path.join(tmpDir, 'install-qwen-standalone.sh'); + const installBat = path.join(tmpDir, 'install-qwen-standalone.bat'); + const installPs1 = path.join(tmpDir, 'install-qwen-standalone.ps1'); + const uninstallSh = path.join(tmpDir, 'uninstall-qwen-standalone.sh'); + const uninstallPs1 = path.join(tmpDir, 'uninstall-qwen-standalone.ps1'); + const checksums = readScript(path.join(tmpDir, 'SHA256SUMS')); + const checksumLines = checksums.trim().split('\n'); + + expect(HOSTED_INSTALLATION_ASSET_NAMES).toEqual([ + 'install-qwen-standalone.sh', + 'install-qwen-standalone.bat', + 'install-qwen-standalone.ps1', + 'uninstall-qwen-standalone.sh', + 'uninstall-qwen-standalone.ps1', + ]); + expect(HOSTED_INSTALLATION_ASSETS.map(({ output }) => output)).toEqual( + HOSTED_INSTALLATION_ASSET_NAMES, + ); + expect(readScript(installSh)).toBe( + readScript('scripts/installation/install-qwen-standalone.sh'), + ); + expect(readScript(installBat)).toBe( + readScript('scripts/installation/install-qwen-standalone.bat').replace( + /\r?\n/g, + '\r\n', + ), + ); + expect(readScript(installPs1)).toBe( + readScript('scripts/installation/install-qwen-standalone.ps1'), + ); + expect(readScript(uninstallSh)).toBe( + readScript('scripts/installation/uninstall-qwen-standalone.sh'), + ); + expect(readScript(uninstallPs1)).toBe( + readScript('scripts/installation/uninstall-qwen-standalone.ps1'), + ); + expect(existsSync(path.join(tmpDir, 'install'))).toBe(false); + expect(checksumLines.map((line) => line.split(' ')[1])).toEqual([ + 'install-qwen-standalone.bat', + 'install-qwen-standalone.ps1', + 'install-qwen-standalone.sh', + 'uninstall-qwen-standalone.ps1', + 'uninstall-qwen-standalone.sh', + ]); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.sh$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.bat$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}install-qwen-standalone\.ps1$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}uninstall-qwen-standalone\.sh$/m, + ); + expect(checksums).toMatch( + /^[0-9a-f]{64} {2}uninstall-qwen-standalone\.ps1$/m, + ); + if (process.platform !== 'win32') { + expect(lstatSync(installSh).mode & 0o111).not.toBe(0); + expect(lstatSync(uninstallSh).mode & 0o111).not.toBe(0); + } + + writeFileSync(installSh, 'tampered'); + await expect( + assertHostedInstallationAssetChecksums(tmpDir), + ).rejects.toThrow(/Checksum mismatch for install-qwen-standalone\.sh/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects hosted installer sources without pinned hosted behavior', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-stable}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=latest"\r\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + "# --version vX.Y.Z\n$env:QWEN_INSTALL_VERSION = 'latest'\n", + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\nis_qwen_standalone_install_dir() { return 0; }\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow( + /install-qwen-standalone\.sh default install version must be 'latest'/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects hosted installer sources without real version parsing', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'echo "Usage: --version VERSION"\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=latest"\r\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '& $qwenInstallerPath @args\n# QWEN_INSTALL_VERSION\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\nis_qwen_standalone_install_dir() { return 0; }\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow(/install-qwen-standalone\.sh.*--version parser/); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects hosted ps1 shim with a hardcoded version pin', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; --version=*) VERSION="${1#*=}" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=%QWEN_INSTALL_VERSION%"\r\nif "%VERSION%"=="" set "VERSION=latest"\r\nset "VERSION=latest"\r\nif "%~1"=="--version" set "VERSION=%~2"\r\n', + ); + // The ps1 shim has every required behavior pattern but also contains + // a hardcoded $env:QWEN_INSTALL_VERSION assignment, which must be + // rejected by the forbidden-patterns guard. + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '# QWEN_INSTALL_VERSION documentation\n' + + '$env:QWEN_INSTALL_VERSION = "v0.1.0"\n' + + '$tmp = Get-FileHash $env:TEMP\n' + + '# SHA256SUMS\n' + + '& $qwenInstallerPath @args\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'is_qwen_standalone_install_dir() { return 0; }\n' + + 'remove_shell_path_entry() { :; }\n' + + 'QWEN_UNINSTALL_PURGE=""\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n' + + 'function Remove-UserPathEntry { }\n' + + 'function Remove-CurrentCmdPathShim { }\n' + + '$env:QWEN_UNINSTALL_PURGE = ""\n', + ); + + await expect( + buildHostedInstallationAssets(tmpDir, { root: tmpRoot }), + ).rejects.toThrow( + /install-qwen-standalone\.ps1 must not contain.*no hardcoded QWEN_INSTALL_VERSION assignment/, + ); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('allows hosted ps1 shim that only documents QWEN_INSTALL_VERSION in comments', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-root-')); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + const sourceDir = path.join(tmpRoot, 'scripts', 'installation'); + + try { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'VERSION="${QWEN_INSTALL_VERSION:-latest}"\n' + + 'case "$1" in --version) shift; VERSION="$1" ;; --version=*) VERSION="${1#*=}" ;; esac\n', + ); + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.bat'), + '@echo off\r\nset "VERSION=%QWEN_INSTALL_VERSION%"\r\nif "%VERSION%"=="" set "VERSION=latest"\r\nset "VERSION=latest"\r\nif "%~1"=="--version" set "VERSION=%~2"\r\n', + ); + // ps1 contains the exact docstring shipped in production + // ("$env:QWEN_INSTALL_VERSION = 'vX.Y.Z'") as a `#` comment; the + // forbidden-pattern guard must not regress on that documented example. + writeFileSync( + path.join(sourceDir, 'install-qwen-standalone.ps1'), + '# To pin a specific release, set $env:QWEN_INSTALL_VERSION before invoking,\n' + + "# e.g. $env:QWEN_INSTALL_VERSION = 'vX.Y.Z'. This is equivalent to passing\n" + + '# --version vX.Y.Z to install-qwen-standalone.bat directly.\n' + + '$tmp = Get-FileHash $env:TEMP\n' + + '# SHA256SUMS\n' + + '& $qwenInstallerPath @args\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.sh'), + '#!/usr/bin/env bash\n' + + 'is_qwen_standalone_install_dir() { return 0; }\n' + + 'remove_shell_path_entry() { :; }\n' + + 'QWEN_UNINSTALL_PURGE=""\n', + ); + writeFileSync( + path.join(sourceDir, 'uninstall-qwen-standalone.ps1'), + 'function Test-QwenStandaloneInstallDir { return $true }\n' + + 'function Remove-UserPathEntry { }\n' + + 'function Remove-CurrentCmdPathShim { }\n' + + '$env:QWEN_UNINSTALL_PURGE = ""\n', + ); + + // Build should succeed (only resolves; throws would fail the test). + await buildHostedInstallationAssets(tmpDir, { root: tmpRoot }); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects stale hosted installation assets in the output directory', async () => { + const { buildHostedInstallationAssets } = await import( + hostedInstallationScriptUrl + ); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-hosted-install-')); + + try { + writeFileSync(path.join(tmpDir, 'install'), 'stale alias'); + + await expect(buildHostedInstallationAssets(tmpDir)).rejects.toThrow( + /Unexpected hosted installer asset: install/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('verifies release asset directory contents and checksums', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + await expect(verifyReleaseDirectory(tmpDir)).resolves.not.toThrow(); + + appendFileSync( + path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0]), + 'tamper', + ); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + new RegExp( + `Checksum mismatch for ${escapeRegExp(EXPECTED_STANDALONE_ARCHIVE_NAMES[0])}`, + ), + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects missing release archives and unexpected checksum entries', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-verify-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + rmSync(path.join(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES[0])); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Missing release asset: qwen-code-/, + ); + + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + writeStandaloneReleaseChecksums(tmpDir, [ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'qwen-code-extra.tar.gz', + ]); + await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow( + /Unexpected release asset checksum: qwen-code-extra\.tar\.gz/, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('verifies release asset URLs from SHA256SUMS', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const fetchedUrls = []; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + fetchedUrls.push([url, options.method || 'GET', !!options.signal]); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + + expect(fetchedUrls).toContainEqual([ + 'https://example.com/qwen-code/v0.0.0/SHA256SUMS', + 'GET', + true, + ]); + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + expect(fetchedUrls).toContainEqual([ + `https://example.com/qwen-code/v0.0.0/${assetName}`, + 'GET', + true, + ]); + } + expect(warnSpy).not.toHaveBeenCalled(); + for (const [url] of fetchedUrls) { + expect(url).not.toMatch(/install-qwen\.(sh|bat|ps1)$/); + expect(url).not.toMatch(/\/install$/); + } + }); + + it('rejects remote release archives whose downloaded hash differs', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + if (assetName === EXPECTED_STANDALONE_ARCHIVE_NAMES[0]) { + return new Response('tampered\n'); + } + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow(/Checksum mismatch for qwen-code-/); + }); + + it('rejects a release base URL that is not https', async () => { + const { verifyReleaseBaseUrl } = await import( + installationReleaseVerificationScriptUrl + ); + + await expect(verifyReleaseBaseUrl('file:///tmp/release/')).rejects.toThrow( + /--base-url must use https/, + ); + await expect( + verifyReleaseBaseUrl('http://example.com/release/'), + ).rejects.toThrow(/--base-url must use https/); + }); + + it('downloads release archive bodies instead of relying on HEAD probes', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const fetchedUrls = []; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url, options = {}) => { + const method = options.method || 'GET'; + const range = options.headers?.Range || ''; + fetchedUrls.push([url, method, range]); + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + if (method === 'HEAD') { + return new Response(null, { status: 405 }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + const assetUrl = `https://example.com/qwen-code/v0.0.0/${assetName}`; + expect(fetchedUrls).toContainEqual([assetUrl, 'GET', '']); + expect(fetchedUrls).not.toContainEqual([assetUrl, 'HEAD', '']); + } + }); + + it('reports each unavailable asset with its reason', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const unavailableAsset = EXPECTED_STANDALONE_ARCHIVE_NAMES[0]; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + // The first asset always fails (HEAD and Range); the rest succeed + // on HEAD. Verifier should list only the failing one in the error. + if (url.endsWith(`/${unavailableAsset}`)) { + return new Response(null, { status: 404 }); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).rejects.toThrow( + new RegExp( + `Unavailable or invalid release asset\\(s\\): ${escapeRegExp(unavailableAsset)} \\(.*\\)`, + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('reports a single error when every asset URL is unavailable', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = placeholderChecksumContent( + EXPECTED_STANDALONE_ARCHIVE_NAMES, + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + return new Response(null, { status: 503 }); + }, + }), + ).rejects.toThrow( + new RegExp( + `All ${EXPECTED_STANDALONE_ARCHIVE_NAMES.length} release asset URLs are unavailable; check --base-url: https://example\\.com/qwen-code/v0\\.0\\.0/`, + ), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('parses SHA256SUMS even when the file starts with a UTF-8 BOM', async () => { + const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseBaseUrl } = + await import(installationReleaseVerificationScriptUrl); + const checksumContent = + '\uFEFF' + placeholderChecksumContent(EXPECTED_STANDALONE_ARCHIVE_NAMES); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + verifyReleaseBaseUrl('https://example.com/qwen-code/v0.0.0', { + fetchImpl: async (url) => { + if (url.endsWith('/SHA256SUMS')) { + return new Response(checksumContent); + } + const assetName = EXPECTED_STANDALONE_ARCHIVE_NAMES.find((name) => + url.endsWith(`/${name}`), + ); + return new Response(`${assetName}\n`); + }, + }), + ).resolves.not.toThrow(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('prints explicit release asset paths for GitHub release upload', async () => { + const { EXPECTED_RELEASE_ASSET_NAMES, EXPECTED_STANDALONE_ARCHIVE_NAMES } = + await import(installationReleaseVerificationScriptUrl); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-list-')); + + try { + writeStandaloneReleaseAssets(tmpDir, EXPECTED_STANDALONE_ARCHIVE_NAMES); + + const output = execFileSync( + process.execPath, + [ + 'scripts/verify-installation-release.js', + '--dir', + tmpDir, + '--list-release-asset-paths', + ], + { encoding: 'utf8' }, + ); + + expect(output.trim().split('\n')).toEqual( + EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => + path.join(tmpDir, assetName), + ), + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('rejects a runtime archive without a Node executable', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-')); @@ -327,9 +1481,7 @@ describe('standalone release packaging', () => { ).toThrow(/Node\.js runtime for .* must contain/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -372,9 +1524,7 @@ describe('standalone release packaging', () => { ); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }, 30_000); @@ -401,9 +1551,7 @@ describe('standalone release packaging', () => { expect(lstatSync(npmShim).isSymbolicLink()).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -433,9 +1581,7 @@ describe('standalone release packaging', () => { ).toThrow(/symlink escapes the archive/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -465,9 +1611,7 @@ describe('standalone release packaging', () => { ).toThrow(/symlink cycle/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -497,22 +1641,153 @@ describe('standalone release packaging', () => { ).toThrow(/Unexpected dist asset/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } else { - rmSync('dist/debug-cache.tmp', { force: true }); - } + restoreMinimalDist(createdDist); } }); - it('uploads standalone archives during release', () => { + it('syncs standalone and hosted installation assets during release', () => { const workflow = readScript('.github/workflows/release.yml'); expect(workflow).toContain('npm run package:standalone:release --'); + expect(workflow).toContain( + 'npm run package:hosted-installation -- --out-dir dist/installation', + ); + expect(workflow).not.toContain('package:installation-assets'); expect(workflow).not.toContain('verify_node_checksum()'); expect(workflow).not.toContain('download_node()'); - expect(workflow).toContain('dist/standalone/qwen-code-*'); - expect(workflow).toContain('dist/standalone/SHA256SUMS'); + expect(workflow).not.toContain('dist/standalone/qwen-code-*.tar.gz'); + expect(workflow).not.toContain('dist/standalone/qwen-code-*.zip'); + expect(workflow).toContain('--list-release-asset-paths'); + expect(workflow).toContain( + 'npm run verify:installation-release -- --dir dist/standalone', + ); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_ID'); + expect(workflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET'); + expect(workflow).toContain('vars.ALIYUN_OSS_BUCKET'); + expect(workflow).toContain('vars.ALIYUN_OSS_ENDPOINT'); + expect(workflow).toContain('vars.OSSUTIL_URL'); + expect(workflow).toContain('vars.OSSUTIL_SHA256'); + expect(workflow).not.toContain('sudo install'); + expect(workflow).toContain('${HOME}/.local/bin/ossutil'); + expect(workflow).toContain('${GITHUB_PATH}'); + expect(existsSync('scripts/upload-aliyun-oss-assets.js')).toBe(true); + expect(workflow).toContain('node scripts/upload-aliyun-oss-assets.js'); + expect(workflow.match(/upload_asset\(\)/g) || []).toHaveLength(0); + expect(workflow).toContain('releases/qwen-code/${RELEASE_TAG}'); + expect(workflow).toContain('releases/qwen-code/latest'); + expect(workflow).not.toContain( + 'upload_release_assets "releases/qwen-code/latest"', + ); + const createReleaseStepIndex = workflow.indexOf( + "name: 'Create GitHub Release and Tag'", + ); + expect(createReleaseStepIndex).toBeGreaterThanOrEqual(0); + const createReleaseStep = workflow.slice(createReleaseStepIndex); + expect(createReleaseStep).toContain('mapfile -t release_assets'); + expect(createReleaseStep).toContain('"${release_assets[@]}"'); + expect(createReleaseStep).not.toContain( + 'dist/standalone/qwen-code-*.tar.gz', + ); + expect(createReleaseStep).not.toContain('dist/standalone/qwen-code-*.zip'); + + const syncStepIndex = workflow.indexOf( + "name: 'Sync Release Assets to Aliyun OSS'", + ); + const verifyStepIndex = workflow.indexOf( + "name: 'Verify Aliyun OSS Release Assets'", + ); + const publishLatestStepIndex = workflow.indexOf( + "name: 'Publish Aliyun OSS Latest VERSION'", + ); + const syncHostedStepIndex = workflow.indexOf( + "name: 'Sync Hosted Installation Assets to Aliyun OSS'", + ); + const verifyHostedStepIndex = workflow.indexOf( + "name: 'Verify Aliyun OSS Hosted Installation Assets'", + ); + expect(syncStepIndex).toBeGreaterThanOrEqual(0); + expect(verifyStepIndex).toBeGreaterThan(syncStepIndex); + expect(syncHostedStepIndex).toBeGreaterThan(verifyStepIndex); + expect(verifyHostedStepIndex).toBeGreaterThan(syncHostedStepIndex); + // Latest VERSION pointer must flip only after every release asset and + // hosted installer object is uploaded and verified. + expect(publishLatestStepIndex).toBeGreaterThan(verifyHostedStepIndex); + expect(workflow.slice(syncStepIndex, verifyStepIndex)).not.toContain( + 'releases/qwen-code/latest/VERSION', + ); + expect(workflow.slice(publishLatestStepIndex)).toContain( + 'releases/qwen-code/latest/VERSION', + ); + const syncStep = workflow.slice(syncStepIndex, verifyStepIndex); + expect(syncStep).not.toContain('dist/installation/'); + expect(syncStep).not.toContain('installation/install-qwen-standalone.sh'); + const syncHostedStep = workflow.slice( + syncHostedStepIndex, + verifyHostedStepIndex, + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.sh', + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.bat', + ); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.ps1', + ); + expect(syncHostedStep).toContain( + 'dist/installation/uninstall-qwen-standalone.sh', + ); + expect(syncHostedStep).toContain( + 'dist/installation/uninstall-qwen-standalone.ps1', + ); + expect(syncHostedStep).toContain('--prefix "installation/${RELEASE_TAG}"'); + expect(syncHostedStep).toContain('--prefix "installation"'); + expect(syncHostedStep).toContain( + 'dist/installation/install-qwen-standalone.sh', + ); + const uploadScript = readScript('scripts/upload-aliyun-oss-assets.js'); + expect(uploadScript).toContain("'--acl'"); + expect(uploadScript).toContain("'public-read'"); + expect(workflow).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${OSSUTIL_URL}"', + ); + expect(workflow).toContain( + 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"', + ); + expect(workflow).toContain( + 'latest_version="$(curl -fsSL --connect-timeout 15 --max-time 300 "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest/VERSION" | tr -d', + ); + expect(workflow).not.toContain( + 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', + ); + const verifyStep = workflow.slice(verifyStepIndex, syncHostedStepIndex); + expect(verifyStep).not.toContain('hosted_tmp_dir'); + const verifyHostedStep = workflow.slice(verifyHostedStepIndex); + expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); + expect(verifyHostedStep).toContain( + 'url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${RELEASE_TAG}/${asset}"', + ); + expect(verifyHostedStep).toContain( + 'global_url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}"', + ); + expect(verifyHostedStep).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${url}"', + ); + expect(verifyHostedStep).toContain( + 'curl -fsSL --connect-timeout 15 --max-time 300 "${global_url}"', + ); + expect(workflow).toContain( + 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/versioned/SHA256SUMS"', + ); + expect(workflow).toContain( + 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/global/SHA256SUMS"', + ); + expect(workflow).toContain( + '(cd "${hosted_tmp_dir}/versioned" && sha256sum -c SHA256SUMS)', + ); + expect(workflow).toContain( + '(cd "${hosted_tmp_dir}/global" && sha256sum -c SHA256SUMS)', + ); }); it('does not whitelist internal planning documents in gitignore', () => { @@ -526,9 +1801,58 @@ describe('standalone release packaging', () => { const guide = readScript('scripts/installation/INSTALLATION_GUIDE.md'); expect(guide).toContain('Optional Native Modules'); + expect(guide).toContain('package:hosted-installation'); + expect(guide).toContain('installation/install-qwen-standalone.sh'); + expect(guide).toContain('installation/install-qwen-standalone.bat'); + expect(guide).toContain('installation/install-qwen-standalone.ps1'); + expect(guide).toContain('installation/uninstall-qwen-standalone.sh'); + expect(guide).toContain('installation/uninstall-qwen-standalone.ps1'); + expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_ID'); + expect(guide).toContain('ALIYUN_OSS_ACCESS_KEY_SECRET'); + expect(guide).toContain('ALIYUN_OSS_BUCKET'); + expect(guide).toContain('ALIYUN_OSS_ENDPOINT'); + expect(guide).toContain('Public installation documentation'); expect(guide).toContain('node-pty'); expect(guide).toContain('clipboard'); }); + + it('provides standalone uninstall scripts that clean install-owned files only', () => { + const uninstallShellSource = readScript( + 'scripts/installation/uninstall-qwen-standalone.sh', + ); + const uninstallPowerShellSource = readScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ); + + expect(uninstallShellSource).toContain('is_qwen_standalone_install_dir'); + expect(uninstallShellSource).toContain('remove_shell_path_entry'); + expect(uninstallShellSource).toContain('shell_quote'); + expect(uninstallShellSource).toContain('quoted_qwen_bin'); + expect(uninstallShellSource).toContain('QWEN_UNINSTALL_PURGE'); + expect(uninstallShellSource).toContain('Preserving'); + expect(uninstallShellSource).toContain('source.json'); + + expect(uninstallPowerShellSource).toContain( + 'Test-QwenStandaloneInstallDir', + ); + expect(uninstallPowerShellSource).toContain('Remove-UserPathEntry'); + expect(uninstallPowerShellSource).toContain('Remove-CurrentCmdPathShim'); + expect(uninstallPowerShellSource).toContain( + 'Remove-RecordedCurrentCmdPathShim', + ); + expect(uninstallPowerShellSource).toContain('current-cmd-shim.txt'); + expect(uninstallPowerShellSource).toContain( + 'Qwen Code current-session shim', + ); + expect(uninstallPowerShellSource).toContain('QWEN_UNINSTALL_PURGE'); + expect(uninstallPowerShellSource).toContain('Preserving'); + expect(uninstallPowerShellSource).toMatch( + /if \(\$installWasManaged\) \{\n\s+Remove-CurrentCmdPathShim\n\s+Remove-Item/, + ); + expect(uninstallPowerShellSource).not.toMatch( + /\$installWasManaged = Test-QwenStandaloneInstallDir[^\n]*\n\nRemove-CurrentCmdPathShim\n\nif \(\$installWasManaged\)/, + ); + }); }); // These end-to-end installs spawn child processes via execFileSync; @@ -545,7 +1869,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { const archive = packageFakeStandalone(tmpDir); const installRoot = path.join(tmpDir, 'install'); const home = path.join(tmpDir, 'home'); - runUnixInstaller(archive, installRoot, home); + const output = runUnixInstaller(archive, installRoot, home).toString(); expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(true); expect( @@ -563,11 +1887,421 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { .toString() .trim(); expect(version).toBe('0.0.0-smoke'); + expect(output).toContain('Installing Qwen Code version: latest'); + expect(output).toContain('QWEN CODE'); + expect(output).toContain( + 'Qwen Code 0.0.0-smoke installed successfully.', + ); + expect(output).toContain('To start:\n cd \n qwen'); + expect(output).toContain( + `Installed to:\n ${path.join(installRoot, 'lib', 'qwen-code')}`, + ); + expect(output).toContain('Uninstall:'); + expect(output).toContain( + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/uninstall-qwen-standalone.sh', + ); + expect(output).toContain( + `QWEN_INSTALL_LIB_DIR='${path.join(installRoot, 'lib', 'qwen-code')}'`, + ); + expect(output).toContain( + `QWEN_INSTALL_BIN_DIR='${path.join(installRoot, 'bin')}'`, + ); + expect(output).not.toContain('rm -rf'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'resolves Aliyun latest through a single VERSION pointer before downloading archives', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s\\n" "$url" >> "$QWEN_FAKE_CURL_LOG"', + 'case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' if [ -n "$dest" ]; then', + ' printf "v0.0.0-smoke\\n" > "$dest"', + ' else', + ' printf "v0.0.0-smoke\\n"', + ' fi ;;', + ' */releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest" ;;', + ' */releases/qwen-code/v0.0.0-smoke/SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest" ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(path.join(fakeBin, 'uname'), 0o755); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'standalone', + '--mirror', + 'aliyun', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/SHA256SUMS', + ); + expect(curlUrls).not.toContain( + '/releases/qwen-code/latest/qwen-code-linux-x64.tar.gz', + ); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'tries GitHub before npm when auto-selected Aliyun archive is unavailable', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'is_head=0', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' -*) case "$1" in *I*) is_head=1 ;; esac ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s\\n" "$url" >> "$QWEN_FAKE_CURL_LOG"', + 'if [ "$is_head" = "1" ]; then', + ' case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' exit 0 ;;', + ' */releases/latest/download/SHA256SUMS)', + ' exit 22 ;;', + ' */releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' exit 22 ;;', + ' */releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' exit 0 ;;', + ' *)', + ' echo "unexpected HEAD url: $url" >&2', + ' exit 22 ;;', + ' esac', + 'fi', + 'case "$url" in', + ' */releases/qwen-code/latest/VERSION)', + ' printf "v0.0.0-smoke\\n" ;;', + ' */releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz)', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest" ;;', + ' */releases/download/v0.0.0-smoke/SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest" ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(path.join(fakeBin, 'uname'), 0o755); + chmodSync(path.join(fakeBin, 'curl'), 0o755); + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--mirror', + 'auto', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/download/v0.0.0-smoke/qwen-code-linux-x64.tar.gz', + ); + expect(curlUrls).toContain( + '/releases/download/v0.0.0-smoke/SHA256SUMS', + ); + expect(output).toContain( + 'Aliyun standalone archive not found; retrying GitHub mirror.', + ); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); + expect(output).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix('uninstalls standalone files while preserving user config', () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + '# Qwen Code PATH block end', + 'after', + ].join('\n') + '\n', + ); + const qwenDir = path.join(home, '.qwen'); + const sourceJson = path.join(qwenDir, 'source.json'); + const settingsJson = path.join(qwenDir, 'settings.json'); + writeFileSync(settingsJson, '{"theme":"dark"}\n'); + + runUnixUninstaller(installRoot, home); + + expect(existsSync(path.join(installRoot, 'lib', 'qwen-code'))).toBe( + false, + ); + expect(existsSync(path.join(installRoot, 'bin', 'qwen'))).toBe(false); + expect(readScript(rcFile)).toBe('before\nafter\n'); + expect(existsSync(sourceJson)).toBe(true); + expect(existsSync(settingsJson)).toBe(true); + + runUnixUninstaller(installRoot, home, { QWEN_UNINSTALL_PURGE: '1' }); + + expect(existsSync(sourceJson)).toBe(false); + expect(existsSync(settingsJson)).toBe(true); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }); + + itOnUnix( + 'removes only installer-owned shell rc PATH lines during uninstall', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Added by qwen-code installer (multi-qwen shadow fix) ', + `export PATH='${installRoot}/bin':$PATH`, + 'middle', + '# Added by qwen-code installer (multi-qwen shadow fix)', + 'echo keep-me', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe( + ['before', 'middle', 'echo keep-me', 'after'].join('\n') + '\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'removes installer-owned shell rc PATH blocks even when extra lines are inserted', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Qwen Code PATH block begin', + '# inserted by another tool', + `export PATH='${installRoot}/bin':$PATH`, + '# Qwen Code PATH block end', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe('before\nafter\n'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + ); + + itOnUnix( + 'preserves malformed shell rc PATH blocks without an end marker', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + 'user content that must stay', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe( + [ + 'before', + '# Qwen Code PATH block begin', + `export PATH='${installRoot}/bin':$PATH`, + 'user content that must stay', + 'after', + ].join('\n') + '\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); } }, ); @@ -603,12 +2337,239 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { expect(existsSync(path.join(tmpDir, 'qwen-pwned'))).toBe(false); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); + itOnUnix( + 'shell-quotes PATH updates written to shell rc files', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'shadow-bin'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const marker = path.join(tmpDir, 'qwen-pwned'); + const unsafeBinDir = path.join( + installRoot, + 'bin path $(touch qwen-pwned)', + ); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'qwen'), '#!/usr/bin/env sh\n'); + chmodSync(path.join(fakeBin, 'qwen'), 0o755); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: unsafeBinDir, + }); + + const bashrc = path.join(home, '.bashrc'); + expect(readScript(bashrc)).toContain( + `export PATH='${unsafeBinDir}':$PATH`, + ); + execFileSync('bash', ['-c', `source "${bashrc}"`], { + cwd: tmpDir, + stdio: 'pipe', + }); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'skips shell rc PATH updates for unsupported shells', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const fakeBin = path.join(tmpDir, 'shadow-bin'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync(path.join(fakeBin, 'qwen'), '#!/usr/bin/env sh\n'); + chmodSync(path.join(fakeBin, 'qwen'), 0o755); + + const output = runUnixInstaller( + archive, + installRoot, + home, + 'standalone', + { + PATH: `${fakeBin}:${process.env.PATH}`, + SHELL: '/bin/tcsh', + }, + ).toString(); + + expect(output).toContain('Unsupported shell for automatic PATH update'); + expect(existsSync(path.join(home, '.profile'))).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'uses ranged GET fallback when archive HEAD probes fail', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'uname'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -s) echo Linux ;;', + ' -m) echo x86_64 ;;', + ' *) /usr/bin/uname "$@" ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env sh', + 'url=', + 'dest=', + 'is_head=0', + 'is_range=0', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -o) shift; dest="$1" ;;', + ' --range|-r) is_range=1; shift ;;', + ' -H) shift; case "$1" in Range:*) is_range=1 ;; esac ;;', + ' -*) case "$1" in *I*) is_head=1 ;; esac ;;', + ' http*) url="$1" ;;', + ' esac', + ' shift', + 'done', + 'printf "%s %s %s\\n" "$url" "$is_head" "$is_range" >> "$QWEN_FAKE_CURL_LOG"', + 'case "$url" in', + ' */qwen-code-linux-x64.tar.gz)', + ' if [ "$is_head" = "1" ]; then exit 22; fi', + ' if [ "$is_range" = "1" ]; then : > "${dest:-/dev/null}"; exit 0; fi', + ' cp "$QWEN_FAKE_ARCHIVE" "$dest"; exit 0 ;;', + ' */SHA256SUMS)', + ' cp "$QWEN_FAKE_SHA256SUMS" "$dest"; exit 0 ;;', + ' *)', + ' echo "unexpected url: $url" >&2', + ' exit 22 ;;', + 'esac', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then echo 22.0.0; exit 0; fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + '#!/usr/bin/env sh\necho npm fallback should not run >&2\nexit 1\n', + ); + for (const command of ['uname', 'curl', 'node', 'npm']) { + chmodSync(path.join(fakeBin, command), 0o755); + } + + const output = execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--base-url', + 'https://example.com/qwen-code', + '--source', + 'smoke', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_ROOT: installRoot, + }, + stdio: 'pipe', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('qwen-code-linux-x64.tar.gz 1 0'); + expect(curlUrls).toContain('qwen-code-linux-x64.tar.gz 0 1'); + expect(output).toContain('Downloading qwen-code-linux-x64.tar.gz'); + expect(output).not.toContain('Falling back to npm installation'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + + itOnUnix( + 'adds a new shell rc PATH entry when reinstalling with a different bin dir', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + const firstBinDir = path.join(installRoot, 'bin-one'); + const secondBinDir = path.join(installRoot, 'bin-two'); + + runUnixInstaller(archive, installRoot, home, 'standalone', { + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: firstBinDir, + }); + runUnixInstaller(archive, installRoot, home, 'standalone', { + SHELL: '/bin/bash', + QWEN_INSTALL_BIN_DIR: secondBinDir, + }); + + const bashrc = readScript(path.join(home, '.bashrc')); + expect(bashrc).toContain(`export PATH='${firstBinDir}':$PATH`); + expect(bashrc).toContain(`export PATH='${secondBinDir}':$PATH`); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + restoreMinimalDist(createdDist); + } + }, + 15000, + ); + itOnUnix('rejects a tampered local archive', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -623,12 +2584,10 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -649,9 +2608,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { ).toThrow(/SHA256SUMS not found/); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -694,7 +2651,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); - itOnUnix('refuses to overwrite a non-managed install directory', () => { + itOnUnix('backs up and overwrites a non-managed install directory', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -705,17 +2662,29 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { mkdirSync(installDir, { recursive: true }); writeFileSync(path.join(installDir, 'important.txt'), 'keep me\n'); - expect(() => - runUnixInstaller(archive, installRoot, path.join(tmpDir, 'home')), - ).toThrow(/not a Qwen Code standalone install/); - expect(readScript(path.join(installDir, 'important.txt'))).toBe( - 'keep me\n', + const output = runUnixInstaller( + archive, + installRoot, + path.join(tmpDir, 'home'), + ).toString(); + + expect(output).toContain('not a Qwen Code standalone install'); + expect(output).toContain('Backing up to'); + + // Original directory should be backed up, not destroyed + const backups = readdirSync(path.join(installRoot, 'lib')).filter((e) => + e.startsWith('qwen-code.backup.'), ); + expect(backups.length).toBe(1); + expect( + readScript(path.join(installRoot, 'lib', backups[0], 'important.txt')), + ).toBe('keep me\n'); + + // New install should be at the original location + expect(existsSync(path.join(installDir, 'manifest.json'))).toBe(true); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -739,14 +2708,12 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { failureMessage = error.message; } - expect(failureMessage).toContain('Checksum verification failed'); + expect(failureMessage).toContain('Checksum mismatch'); expect(failureMessage).toContain('Standalone install failed'); expect(failureMessage).not.toContain('Falling back to npm installation'); } finally { rmSync(tmpDir, { recursive: true, force: true }); - if (createdDist) { - rmSync('dist', { recursive: true, force: true }); - } + restoreMinimalDist(createdDist); } }); @@ -772,8 +2739,8 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { '#!/usr/bin/env sh', 'if [ "$1" = "-p" ]; then', ' case "$2" in', - ' *split*) echo 20 ;;', - ' *) echo 20.19.0 ;;', + ' *split*) echo 22 ;;', + ' *) echo 22.0.0 ;;', ' esac', ' exit 0', 'fi', @@ -805,7 +2772,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { const output = execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -835,6 +2802,80 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { }, ); + itOnUnix('passes pinned versions through to npm fallback', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const home = path.join(tmpDir, 'home'); + const npmLog = path.join(tmpDir, 'npm-args.txt'); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(home, { recursive: true }); + + writeFileSync(path.join(fakeBin, 'curl'), '#!/usr/bin/env sh\nexit 22\n'); + writeFileSync( + path.join(fakeBin, 'node'), + [ + '#!/usr/bin/env sh', + 'if [ "$1" = "-p" ]; then', + ' case "$2" in', + ' *split*) echo 22 ;;', + ' *) echo 22.0.0 ;;', + ' esac', + ' exit 0', + 'fi', + 'exit 0', + '', + ].join('\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm'), + [ + '#!/usr/bin/env sh', + 'case "$1" in', + ' -v) echo 10.0.0 ;;', + ' prefix) echo "$QWEN_FAKE_NPM_PREFIX" ;;', + ' install) printf "%s\\n" "$*" > "$QWEN_FAKE_NPM_LOG" ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + for (const command of ['curl', 'node', 'npm']) { + chmodSync(path.join(fakeBin, command), 0o755); + } + + execFileSync( + 'bash', + [ + 'scripts/installation/install-qwen-standalone.sh', + '--method', + 'detect', + '--base-url', + 'https://example.invalid/qwen-code', + '--version', + 'v0.15.10', + ], + { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + }, + stdio: 'pipe', + }, + ); + + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@0.15.10 --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + itOnUnix('preserves context when npm fallback also fails', () => { const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); @@ -849,7 +2890,7 @@ describe('Linux/macOS installer end-to-end', { timeout: 15000 }, () => { execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', 'detect', '--base-url', @@ -934,7 +2975,7 @@ describe('Windows installer end-to-end', { timeout: 30000 }, () => { path.join(tmpDir, 'install'), path.join(tmpDir, 'home'), ), - ).toThrow(/Checksum verification failed/); + ).toThrow(/Checksum mismatch/); } finally { rmSync(tmpDir, { recursive: true, force: true }); } @@ -963,25 +3004,290 @@ describe('Windows installer end-to-end', { timeout: 30000 }, () => { rmSync(tmpDir, { recursive: true, force: true }); } }); + + itOnWindows( + 'resolves Aliyun latest through a single VERSION pointer before downloading archives', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const archive = createFakeWindowsStandaloneArchive(tmpDir); + const checksumFile = path.join(path.dirname(archive), 'SHA256SUMS'); + const fakeBin = path.join(tmpDir, 'bin'); + const curlLog = path.join(tmpDir, 'curl-urls.log'); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + + const fakeCurl = createFakeWindowsCurlCommand(fakeBin); + + const output = runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'standalone', + '--mirror', + 'aliyun', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + QWEN_FAKE_ARCHIVE: archive, + QWEN_FAKE_SHA256SUMS: checksumFile, + QWEN_FAKE_CURL_LOG: curlLog, + QWEN_INSTALL_CURL_EXE: fakeCurl, + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'AMD64', + PROCESSOR_ARCHITEW6432: '', + }, + ).toString(); + + const curlUrls = readScript(curlLog); + expect(curlUrls).toContain('/releases/qwen-code/latest/VERSION'); + expect(curlUrls).toContain( + '/releases/qwen-code/v0.0.0/qwen-code-win-x64.zip', + ); + expect(curlUrls).toContain('/releases/qwen-code/v0.0.0/SHA256SUMS'); + expect(curlUrls).not.toContain( + '/releases/qwen-code/latest/qwen-code-win-x64.zip', + ); + expect(output).toContain('Downloading qwen-code-win-x64.zip'); + expect(existsSync(path.join(installRoot, 'bin', 'qwen.cmd'))).toBe( + true, + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnWindows( + 'falls back to npm in detect mode when archive is unavailable', + () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const npmLog = path.join(tmpDir, 'npm-install.log'); + createFakeWindowsNpmTools(fakeBin); + + const output = runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ).toString(); + + expect(output).toContain('Falling back to npm installation'); + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@latest --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + itOnWindows('passes pinned versions through to npm fallback', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + const npmLog = path.join(tmpDir, 'npm-install.log'); + createFakeWindowsNpmTools(fakeBin); + + runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + '--version', + 'v0.15.10', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + QWEN_FAKE_NPM_LOG: npmLog, + QWEN_FAKE_NPM_PREFIX: path.join(tmpDir, 'npm-prefix'), + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ); + + expect(readScript(npmLog)).toContain( + 'install -g @qwen-code/qwen-code@0.15.10 --registry', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnWindows('preserves context when npm fallback also fails', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-')); + + try { + const fakeBin = path.join(tmpDir, 'bin'); + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'node.cmd'), + ['@echo off', 'exit /b 1', ''].join('\r\n'), + ); + + let failureMessage = ''; + try { + runWindowsCommand( + [ + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, + '--method', + 'detect', + '--source', + 'smoke', + ].join(' '), + { + USERPROFILE: path.join(tmpDir, 'home'), + QWEN_INSTALL_ROOT: path.join(tmpDir, 'install'), + ...prependWindowsPath(fakeBin), + PROCESSOR_ARCHITECTURE: 'ARM64', + PROCESSOR_ARCHITEW6432: '', + }, + ); + } catch (error) { + failureMessage = [ + error.message, + error.stdout?.toString() || '', + error.stderr?.toString() || '', + ].join('\n'); + } + + expect(failureMessage).toContain('Falling back to npm installation'); + expect(failureMessage).toContain('Unable to determine Node.js version'); + expect(failureMessage).toContain('npm fallback also failed'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('Windows PowerShell uninstaller end-to-end', () => { + itOnWindows('prints help without deleting standalone files', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'qwen-code'); + const home = path.join(tmpDir, 'home'); + createFakeWindowsStandaloneInstall(installRoot); + + const output = runWindowsPowerShellScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ['-Help'], + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + }, + ).toString(); + + expect(output).toContain('Usage:'); + expect(output).toContain('-Purge'); + expect(existsSync(installDir)).toBe(true); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + itOnWindows('purges the source marker while preserving other config', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const installRoot = path.join(tmpDir, 'install'); + const installDir = path.join(installRoot, 'qwen-code'); + const installBinDir = path.join(installRoot, 'bin'); + const home = path.join(tmpDir, 'home'); + const qwenConfigDir = path.join(home, '.qwen'); + const sourceMarker = path.join(qwenConfigDir, 'source.json'); + const settingsFile = path.join(qwenConfigDir, 'settings.json'); + + createFakeWindowsStandaloneInstall(installRoot); + mkdirSync(qwenConfigDir, { recursive: true }); + writeFileSync(sourceMarker, '{"source":"smoke"}\n'); + writeFileSync(settingsFile, '{"theme":"dark"}\n'); + + const output = runWindowsPowerShellScript( + 'scripts/installation/uninstall-qwen-standalone.ps1', + ['-Purge'], + { + USERPROFILE: home, + QWEN_INSTALL_ROOT: installRoot, + }, + ).toString(); + + expect(output).toContain('Removed'); + expect(existsSync(installDir)).toBe(false); + expect(existsSync(path.join(installBinDir, 'qwen.cmd'))).toBe(false); + expect(existsSync(sourceMarker)).toBe(false); + expect(readScript(settingsFile)).toContain('"theme":"dark"'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); function ensureMinimalDist() { - if (existsSync('dist')) { - return false; + const distPath = path.resolve('dist'); + const backupPath = existsSync(distPath) + ? path.join( + path.dirname(distPath), + `qwen-dist-backup-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ) + : null; + if (backupPath) { + renameSync(distPath, backupPath); } - mkdirSync('dist/vendor', { recursive: true }); - mkdirSync('dist/bundled/qc-helper/docs', { recursive: true }); - writeFileSync('dist/cli.js', 'console.log("qwen");\n'); + mkdirSync(path.join(distPath, 'chunks'), { recursive: true }); + mkdirSync(path.join(distPath, 'vendor'), { recursive: true }); + mkdirSync(path.join(distPath, 'bundled/qc-helper/docs'), { + recursive: true, + }); + writeFileSync(path.join(distPath, 'cli.js'), 'console.log("qwen");\n'); + writeFileSync(path.join(distPath, 'chunks/index.js'), 'export {};\n'); writeFileSync( - 'dist/package.json', + path.join(distPath, 'package.json'), JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.0.0' }), ); - return true; + return { backupPath, distPath }; +} + +function restoreMinimalDist(state) { + rmSync(state?.distPath || path.resolve('dist'), { + recursive: true, + force: true, + }); + if (state?.backupPath) { + renameSync(state.backupPath, state.distPath); + } } function createFakeNodeArchive(tmpDir, options = {}) { - const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-linux-x64'); + const fakeNodeDir = path.join(tmpDir, 'node-v22.0.0-linux-x64'); mkdirSync(path.join(fakeNodeDir, 'bin'), { recursive: true }); writeFileSync( path.join(fakeNodeDir, 'bin', 'node'), @@ -1005,7 +3311,7 @@ function createFakeNodeArchive(tmpDir, options = {}) { symlinkSync('../bin', path.join(fakeNodeDir, 'bin', 'cycle')); } - const archive = path.join(tmpDir, 'node-v20.0.0-linux-x64.tar.gz'); + const archive = path.join(tmpDir, 'node-v22.0.0-linux-x64.tar.gz'); execFileSync( 'tar', ['-czf', archive, '-C', tmpDir, path.basename(fakeNodeDir)], @@ -1041,11 +3347,11 @@ function createBadWindowsNodeArchive(tmpDir) { } function createFakeWindowsNodeArchive(tmpDir) { - const fakeNodeDir = path.join(tmpDir, 'node-v20.0.0-win-x64'); + const fakeNodeDir = path.join(tmpDir, 'node-v22.0.0-win-x64'); mkdirSync(fakeNodeDir, { recursive: true }); writeFileSync(path.join(fakeNodeDir, 'node.exe'), 'fake node.exe\n'); - const archive = path.join(tmpDir, 'node-v20.0.0-win-x64.zip'); + const archive = path.join(tmpDir, 'node-v22.0.0-win-x64.zip'); createZipForTest(archive, tmpDir, path.basename(fakeNodeDir)); return archive; } @@ -1064,7 +3370,7 @@ function createFakeWindowsStandaloneArchive(tmpDir) { writeFileSync(path.join(packageRoot, 'node', 'node.exe'), 'fake node.exe\n'); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }), ); const archive = path.join(outDir, 'qwen-code-win-x64.zip'); @@ -1073,6 +3379,127 @@ function createFakeWindowsStandaloneArchive(tmpDir) { return archive; } +function createFakeWindowsStandaloneInstall(installRoot) { + const installDir = path.join(installRoot, 'qwen-code'); + const installBinDir = path.join(installRoot, 'bin'); + mkdirSync(path.join(installDir, 'bin'), { recursive: true }); + mkdirSync(path.join(installDir, 'node'), { recursive: true }); + mkdirSync(installBinDir, { recursive: true }); + + writeFileSync( + path.join(installDir, 'manifest.json'), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }), + ); + writeFileSync( + path.join(installDir, 'bin', 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'), + ); + writeFileSync(path.join(installDir, 'node', 'node.exe'), 'fake node.exe\n'); + writeFileSync( + path.join(installBinDir, 'qwen.cmd'), + ['@echo off', `"${path.join(installDir, 'bin', 'qwen.cmd')}" %*`, ''].join( + '\r\n', + ), + ); +} + +function createFakeWindowsNpmTools(fakeBin) { + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + path.join(fakeBin, 'node.cmd'), + ['@echo off', 'if "%~1"=="-p" echo 22.0.0', 'exit /b 0', ''].join('\r\n'), + ); + writeFileSync( + path.join(fakeBin, 'npm.cmd'), + [ + '@echo off', + 'if "%~1"=="-v" echo 10.0.0 & exit /b 0', + 'if "%~1"=="prefix" echo %QWEN_FAKE_NPM_PREFIX% & exit /b 0', + 'if "%~1"=="install" echo %* > "%QWEN_FAKE_NPM_LOG%" & exit /b 0', + 'exit /b 0', + '', + ].join('\r\n'), + ); + writeFileSync( + path.join(fakeBin, 'qwen.cmd'), + ['@echo off', 'echo 0.0.0-npm', ''].join('\r\n'), + ); +} + +function createFakeWindowsCurlCommand(fakeBin) { + mkdirSync(fakeBin, { recursive: true }); + const outputPath = path.join(fakeBin, 'curl.cmd'); + writeFileSync( + outputPath, + [ + '@echo off', + 'setlocal EnableExtensions EnableDelayedExpansion', + 'set "destination="', + 'set "url="', + ':parse_args', + 'if "%~1"=="" goto done_parse', + 'set "arg=%~1"', + 'if "!arg:~0,1!"=="-" (', + ' if /i "!arg!"=="-o" (', + ' set "destination=%~2"', + ' shift', + ' shift', + ' goto parse_args', + ' )', + ' if /i "!arg!"=="--output" (', + ' set "destination=%~2"', + ' shift', + ' shift', + ' goto parse_args', + ' )', + ' if not "!arg:~0,2!"=="--" if /i "!arg:~-1!"=="o" (', + ' set "destination=%~2"', + ' shift', + ' shift', + ' goto parse_args', + ' )', + ' shift', + ' goto parse_args', + ')', + 'if /i "!arg:~0,4!"=="http" set "url=!arg!"', + 'shift', + 'goto parse_args', + ':done_parse', + '>>"%QWEN_FAKE_CURL_LOG%" echo(!url!', + 'if "!url!"=="" echo missing url or destination 1>&2 & exit /b 2', + 'if "!destination!"=="" echo missing url or destination 1>&2 & exit /b 2', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/latest/VERSION" >nul && (', + ' > "!destination!" echo 0.0.0', + ' exit /b 0', + ')', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0/qwen-code-win-x64.zip" >nul && (', + ' copy /Y "%QWEN_FAKE_ARCHIVE%" "!destination!" >nul', + ' exit /b 0', + ')', + 'echo(!url! | findstr /I /C:"/releases/qwen-code/v0.0.0/SHA256SUMS" >nul && (', + ' copy /Y "%QWEN_FAKE_SHA256SUMS%" "!destination!" >nul', + ' exit /b 0', + ')', + 'echo unexpected url: !url! 1>&2', + 'exit /b 22', + '', + ].join('\r\n'), + ); + return outputPath; +} + +function prependWindowsPath(directory) { + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || + 'Path'; + const value = `${directory};${process.env[pathKey] || ''}`; + return { + PATH: value, + Path: value, + [pathKey]: value, + }; +} + function createZipForTest(archive, cwd, entry) { if (process.platform === 'win32') { execFileSync( @@ -1163,7 +3590,7 @@ function runUnixInstaller( return execFileSync( 'bash', [ - 'scripts/installation/install-qwen-with-source.sh', + 'scripts/installation/install-qwen-standalone.sh', '--method', method, '--archive', @@ -1193,6 +3620,34 @@ function runUnixInstaller( } } +function runUnixUninstaller(installRoot, home, extraEnv = {}) { + mkdirSync(home, { recursive: true }); + try { + return execFileSync( + 'bash', + ['scripts/installation/uninstall-qwen-standalone.sh'], + { + env: { + ...process.env, + HOME: home, + QWEN_INSTALL_ROOT: installRoot, + ...extraEnv, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + function runWindowsInstaller( archive, installRoot, @@ -1204,7 +3659,7 @@ function runWindowsInstaller( try { return runWindowsCommand( [ - `call "${path.resolve('scripts/installation/install-qwen-with-source.bat')}"`, + `call "${path.resolve('scripts/installation/install-qwen-standalone.bat')}"`, '--method', method, '--archive', @@ -1231,15 +3686,90 @@ function runWindowsInstaller( } function runWindowsCommand(command, env = {}) { - return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], { - env: { - ...process.env, - ...env, - }, - stdio: 'pipe', - // cmd.exe parses the command string itself; preserve quoted paths. - windowsVerbatimArguments: true, - }); + const prepared = prepareWindowsCommand(command, env); + try { + return execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/c', prepared.command], + { + env: { + ...prepared.env, + }, + stdio: 'pipe', + // cmd.exe parses the command string itself; preserve quoted paths. + windowsVerbatimArguments: true, + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +function runWindowsPowerShellScript(scriptPath, args = [], env = {}) { + try { + return execFileSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.resolve(scriptPath), + ...args, + ], + { + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + }, + ); + } catch (error) { + const processError = error; + throw new Error( + [ + processError.message, + processError.stdout?.toString() || '', + processError.stderr?.toString() || '', + ].join('\n'), + ); + } +} + +const WINDOWS_COMMAND_ENV_OVERRIDES = [ + 'PROCESSOR_ARCHITECTURE', + 'PROCESSOR_ARCHITEW6432', +]; + +function prepareWindowsCommand(command, env = {}, baseEnv = process.env) { + const commandEnv = { ...baseEnv, ...env }; + const commandPrefix = []; + + for (const key of WINDOWS_COMMAND_ENV_OVERRIDES) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + continue; + } + + for (const existingKey of Object.keys(commandEnv)) { + if (existingKey.toLowerCase() === key.toLowerCase()) { + delete commandEnv[existingKey]; + } + } + commandPrefix.push(`set "${key}=${env[key] ?? ''}"`); + } + + return { + command: [...commandPrefix, command].join(' && '), + env: commandEnv, + }; } function createSymlinkStandaloneArchive(tmpDir) { @@ -1254,7 +3784,7 @@ function createSymlinkStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); const outDir = path.join(tmpDir, 'out'); @@ -1289,7 +3819,7 @@ function createTraversalStandaloneArchive(tmpDir) { chmodSync(path.join(packageRoot, 'node', 'bin', 'node'), 0o755); writeFileSync( path.join(packageRoot, 'manifest.json'), - JSON.stringify({ name: '@qwen-code/qwen-code' }), + JSON.stringify({ name: '@qwen-code/qwen-code', target: 'linux-x64' }), ); writeFileSync(path.join(tmpDir, 'qwen-slip'), 'path traversal\n'); @@ -1312,3 +3842,38 @@ function writeChecksumFile(outDir, archiveName) { .digest('hex'); writeFileSync(path.join(outDir, 'SHA256SUMS'), `${hash} ${archiveName}\n`); } + +function writeStandaloneReleaseAssets(outDir, archiveNames) { + mkdirSync(outDir, { recursive: true }); + for (const assetName of archiveNames) { + writeFileSync(path.join(outDir, assetName), `${assetName}\n`); + } + writeStandaloneReleaseChecksums(outDir, archiveNames); +} + +function writeStandaloneReleaseChecksums(outDir, archiveNames) { + const lines = archiveNames.map((assetName) => { + const filePath = path.join(outDir, assetName); + const hash = existsSync(filePath) + ? crypto.createHash('sha256').update(readFileSync(filePath)).digest('hex') + : 'a'.repeat(64); + return `${hash} ${assetName}`; + }); + writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`); +} + +function placeholderChecksumContent(archiveNames) { + return `${archiveNames + .map( + (assetName) => + `${crypto + .createHash('sha256') + .update(`${assetName}\n`) + .digest('hex')} ${assetName}`, + ) + .join('\n')}\n`; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/tests/upload-aliyun-oss-assets.test.js b/scripts/tests/upload-aliyun-oss-assets.test.js new file mode 100644 index 0000000000..0705b421ef --- /dev/null +++ b/scripts/tests/upload-aliyun-oss-assets.test.js @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseUploadArgs, uploadAssets } from '../upload-aliyun-oss-assets.js'; + +describe('parseUploadArgs', () => { + it('returns help=true and skips later validation when --help is passed', () => { + const args = parseUploadArgs(['--help']); + expect(args.help).toBe(true); + // Other fields stay at their defaults; no fail() is thrown. + expect(args.assets).toEqual([]); + }); + + it('parses required options and asset list', () => { + const args = parseUploadArgs([ + '--bucket', + 'my-bucket', + '--config', + '/tmp/.ossutilconfig', + '--prefix', + 'releases/qwen-code/v1.2.3', + 'a.tar.gz', + 'b.zip', + ]); + expect(args).toMatchObject({ + bucket: 'my-bucket', + config: '/tmp/.ossutilconfig', + prefix: 'releases/qwen-code/v1.2.3', + assets: ['a.tar.gz', 'b.zip'], + help: false, + }); + }); + + it('strips a trailing slash from --prefix', () => { + const args = parseUploadArgs([ + '--bucket', + 'b', + '--config', + 'c', + '--prefix', + 'installation/', + 'one.txt', + ]); + expect(args.prefix).toBe('installation'); + }); + + it.each([ + [['--bucket', 'b', '--config', 'c', 'asset.txt'], '--prefix'], + [['--config', 'c', '--prefix', 'p', 'asset.txt'], '--bucket'], + [['--bucket', 'b', '--prefix', 'p', 'asset.txt'], '--config'], + [['--bucket', 'b', '--config', 'c', '--prefix', 'p'], 'ASSET path'], + ])('rejects when %j is missing', (argv, expectedFragment) => { + expect(() => parseUploadArgs(argv)).toThrow( + new RegExp(expectedFragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + }); + + it('rejects unknown options', () => { + expect(() => + parseUploadArgs([ + '--bucket', + 'b', + '--config', + 'c', + '--prefix', + 'p', + '--bogus', + 'asset.txt', + ]), + ).toThrow(/Unknown option: --bogus/); + }); + + it('errors when an option is missing its value', () => { + expect(() => parseUploadArgs(['--bucket'])).toThrow(); + }); +}); + +describe('uploadAssets (integration)', () => { + function makeOssutilShim(workDir, behavior = 'success') { + fs.mkdirSync(workDir, { recursive: true }); + const ossutilPath = path.join(workDir, 'ossutil-shim.cjs'); + const logPath = path.join(workDir, 'ossutil.log'); + fs.writeFileSync( + ossutilPath, + [ + "const fs = require('node:fs');", + `fs.appendFileSync(${JSON.stringify(logPath)}, process.argv.slice(2).join('\\n') + '\\n');`, + `process.exit(${behavior === 'fail' ? 1 : 0});`, + '', + ].join('\n'), + ); + return { + logPath, + ossutilCommand: process.execPath, + ossutilCommandArgs: [ossutilPath], + }; + } + + it('spawns ossutil with the expected cp arguments per asset', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-')); + try { + const { logPath, ossutilCommand, ossutilCommandArgs } = + makeOssutilShim(tmp); + const assets = ['a.tar.gz', 'b.zip'].map((name) => { + const filePath = path.join(tmp, name); + fs.writeFileSync(filePath, name); + return filePath; + }); + const configPath = path.join(tmp, '.ossutilconfig'); + fs.writeFileSync(configPath, '[Credentials]\n'); + + uploadAssets( + { + assets, + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }, + { ossutilCommand, ossutilCommandArgs }, + ); + + const log = fs.readFileSync(logPath, 'utf8'); + expect(log).toContain( + `oss://qwen-test-bucket/releases/qwen-code/v0.0.0/a.tar.gz`, + ); + expect(log).toContain( + `oss://qwen-test-bucket/releases/qwen-code/v0.0.0/b.zip`, + ); + expect(log).toContain(`-c\n${configPath}`); + expect(log).toContain('--acl\npublic-read'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('aggregates failures from ossutil non-zero exits', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fail-')); + try { + const { logPath, ossutilCommand, ossutilCommandArgs } = makeOssutilShim( + tmp, + 'fail', + ); + const assetPath = path.join(tmp, 'asset.tar.gz'); + fs.writeFileSync(assetPath, 'asset'); + const configPath = path.join(tmp, '.ossutilconfig'); + fs.writeFileSync(configPath, '[Credentials]\n'); + + expect(() => + uploadAssets( + { + assets: [assetPath], + bucket: 'qwen-test-bucket', + config: configPath, + prefix: 'releases/qwen-code/v0.0.0', + }, + { ossutilCommand, ossutilCommandArgs }, + ), + ).toThrow(/ossutil failed after 3 attempts/); + const uploadAttempts = fs + .readFileSync(logPath, 'utf8') + .split(/\r?\n/) + .filter((line) => line === assetPath); + expect(uploadAttempts).toHaveLength(3); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/scripts/upload-aliyun-oss-assets.js b/scripts/upload-aliyun-oss-assets.js new file mode 100644 index 0000000000..7013c677ec --- /dev/null +++ b/scripts/upload-aliyun-oss-assets.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fail, isMainModule, readOptionValue } from './release-script-utils.js'; + +if (isMainModule(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +function main(argv) { + const args = parseUploadArgs(argv); + if (args.help) { + printUsage(); + return; + } + uploadAssets(args); +} + +function printUsage() { + console.log(`Usage: node scripts/upload-aliyun-oss-assets.js [options] ASSET... + +Uploads local release assets to a public Aliyun OSS prefix via ossutil. + +Options: + --bucket NAME OSS bucket name. + --config PATH ossutil config path. + --prefix PREFIX Destination object prefix. + -h, --help Show this help message. +`); +} + +function parseUploadArgs(argv) { + const args = { + assets: [], + bucket: '', + config: '', + help: false, + prefix: '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + if (arg === '--bucket') { + args.bucket = readOptionValue(argv, index, arg); + index += 1; + continue; + } + if (arg === '--config') { + args.config = readOptionValue(argv, index, arg); + index += 1; + continue; + } + if (arg === '--prefix') { + args.prefix = readOptionValue(argv, index, arg).replace(/\/+$/, ''); + index += 1; + continue; + } + if (arg.startsWith('-')) { + fail(`Unknown option: ${arg}`); + } + args.assets.push(arg); + } + + if (args.help) { + return args; + } + if (!args.bucket) { + fail('--bucket requires a value'); + } + if (!args.config) { + fail('--config requires a value'); + } + if (!args.prefix) { + fail('--prefix requires a value'); + } + if (args.assets.length === 0) { + fail('At least one ASSET path is required'); + } + + return args; +} + +const MAX_UPLOAD_ATTEMPTS = 3; +const INITIAL_BACKOFF_MS = 2000; + +function uploadAssets( + { assets, bucket, config, prefix }, + { ossutilCommand = 'ossutil', ossutilCommandArgs = [] } = {}, +) { + for (const asset of assets) { + const key = `${prefix}/${path.basename(asset)}`; + uploadWithRetry(asset, bucket, key, config, { + ossutilCommand, + ossutilCommandArgs, + }); + } +} + +function uploadWithRetry( + asset, + bucket, + key, + config, + { ossutilCommand, ossutilCommandArgs }, +) { + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { + const result = spawnSync( + ossutilCommand, + [ + ...ossutilCommandArgs, + 'cp', + asset, + `oss://${bucket}/${key}`, + '-c', + config, + '-f', + '--acl', + 'public-read', + ], + { stdio: 'inherit' }, + ); + + if (result.error) { + throw result.error; + } + if (result.status === 0) { + return; + } + if (attempt < MAX_UPLOAD_ATTEMPTS) { + const delayMs = INITIAL_BACKOFF_MS * 2 ** (attempt - 1); + console.warn( + `Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`, + ); + sleepSync(delayMs); + } + } + fail( + `ossutil failed after ${MAX_UPLOAD_ATTEMPTS} attempts while uploading ${asset}`, + ); +} + +// Cross-platform synchronous sleep. `spawnSync('sleep', ...)` is unavailable +// on Windows runners; Atomics.wait blocks the current thread without spawning. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +export { parseUploadArgs, uploadAssets }; diff --git a/scripts/verify-installation-release.js b/scripts/verify-installation-release.js new file mode 100644 index 0000000000..283001959d --- /dev/null +++ b/scripts/verify-installation-release.js @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { RELEASE_TARGETS } from './build-standalone-release.js'; +import { + fail, + isMainModule, + parseArgs, + parseSha256Sums, + sha256File, +} from './release-script-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +const EXPECTED_STANDALONE_ARCHIVE_NAMES = + standaloneArchiveNamesFromReleaseTargets(RELEASE_TARGETS); +// Release artifacts that the installer chain expects in a GitHub Release. +// Hosted installer scripts are served from a separate endpoint and are +// intentionally not part of this set; they have their own staging path in +// `package:hosted-installation`. +const EXPECTED_RELEASE_ASSET_NAMES = [ + ...EXPECTED_STANDALONE_ARCHIVE_NAMES, + 'SHA256SUMS', +]; +const REMOTE_FETCH_TIMEOUT_MS = 30_000; + +// Mirrors `build-standalone-release.js`'s archive-name derivation. The two +// must stay aligned: any new platform/extension landing in RELEASE_TARGETS +// has to be reflected here (and there) before a new target ships, otherwise +// the verify and the build will disagree on expected filenames. +function standaloneArchiveNamesFromReleaseTargets(releaseTargets) { + return releaseTargets.map( + ({ qwenTarget }) => + `qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`, + ); +} + +const ARG_DEFS = { + '--dir': { key: 'dir', type: 'value' }, + '--base-url': { key: 'baseUrl', type: 'value' }, + '--list-release-asset-paths': { + key: 'listReleaseAssetPaths', + type: 'flag', + }, +}; + +if (isMainModule(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2), ARG_DEFS); + if (args.help) { + printUsage(); + return; + } + if (args.dir && args.baseUrl) { + fail('Pass --dir or --base-url, not both.'); + } + if (args.listReleaseAssetPaths && args.baseUrl) { + fail('Pass --list-release-asset-paths with --dir, not --base-url.'); + } + if (args.listReleaseAssetPaths) { + const dir = path.resolve( + args.dir || path.join(rootDir, 'dist', 'standalone'), + ); + await verifyReleaseDirectory(dir, { silent: true }); + for (const assetPath of releaseAssetPaths(dir)) { + console.log(assetPath); + } + return; + } + if (args.baseUrl) { + await verifyReleaseBaseUrl(args.baseUrl); + return; + } + await verifyReleaseDirectory( + path.resolve(args.dir || path.join(rootDir, 'dist', 'standalone')), + ); +} + +function printUsage() { + console.log(`Usage: npm run verify:installation-release -- [options] + +Verifies that an installation release directory contains the expected standalone +archives with matching SHA256SUMS entries. For a release URL, downloads +SHA256SUMS and the expected archives, then verifies each archive hash. + +Options: + --dir PATH Verify a local release directory. Defaults to dist/standalone. + --base-url URL Verify a remote release URL (e.g. a GitHub release download + prefix). Cannot be combined with --dir. + --list-release-asset-paths + Verify --dir, then print explicit asset paths for upload. + -h, --help Show this help message. +`); +} + +async function verifyReleaseDirectory(dir, options = {}) { + const { silent = false } = options; + const checksums = readReleaseChecksums(dir); + assertExpectedChecksumEntries(checksums); + assertExpectedArchiveFiles(dir); + + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + const assetPath = path.join(dir, assetName); + if (!fs.existsSync(assetPath)) { + fail(`Missing release asset: ${assetName}`); + } + + const actual = await sha256File(assetPath); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } + } + + if (!silent) { + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`, + ); + } +} + +async function verifyReleaseBaseUrl(baseUrl, options = {}) { + const { fetchImpl = fetch } = options; + const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl); + const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString(); + const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl)); + assertExpectedChecksumEntries(checksums); + + await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl); + + console.log( + `Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${baseUrl}`, + ); +} + +function readReleaseChecksums(dir) { + const checksumPath = path.join(dir, 'SHA256SUMS'); + if (!fs.existsSync(checksumPath)) { + fail(`SHA256SUMS was not found at ${checksumPath}`); + } + + return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8')); +} + +function assertExpectedChecksumEntries(checksums) { + const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES); + const missing = EXPECTED_STANDALONE_ARCHIVE_NAMES.filter( + (assetName) => !checksums.has(assetName), + ); + const extra = Array.from(checksums.keys()).filter( + (assetName) => !expected.has(assetName), + ); + + if (missing.length > 0) { + fail(`Missing release asset checksum: ${missing.join(', ')}`); + } + if (extra.length > 0) { + fail(`Unexpected release asset checksum: ${extra.join(', ')}`); + } +} + +function assertExpectedArchiveFiles(dir) { + const expected = new Set(EXPECTED_RELEASE_ASSET_NAMES); + const extra = fs + .readdirSync(dir) + .filter((assetName) => !expected.has(assetName)) + .sort(); + + if (extra.length > 0) { + fail(`Unexpected release asset: ${extra.join(', ')}`); + } +} + +function releaseAssetPaths(dir) { + return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) => + path.join(dir, assetName), + ); +} + +async function assertRemoteAssetChecksums( + normalizedBaseUrl, + checksums, + fetchImpl, +) { + const failures = []; + for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) { + try { + const assetUrl = new URL(assetName, normalizedBaseUrl).toString(); + const actual = await fetchSha256(assetUrl, fetchImpl); + const expected = checksums.get(assetName); + if (actual !== expected) { + fail( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } + } catch (reason) { + failures.push({ + assetName, + reason: formatErrorReason(reason), + }); + } + } + + if (failures.length === 0) { + return; + } + if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) { + fail( + `All ${failures.length} release asset URLs are unavailable; check --base-url: ${normalizedBaseUrl}`, + ); + } + fail( + `Unavailable or invalid release asset(s): ${failures + .map(({ assetName, reason }) => `${assetName} (${reason})`) + .join('; ')}`, + ); +} + +async function fetchSha256(url, fetchImpl) { + const response = await fetchWithTimeout(fetchImpl, url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + if (!response.body) { + fail(`Downloaded response has no body: ${url}`); + } + + const hash = crypto.createHash('sha256'); + await pipeline(Readable.fromWeb(response.body), hash); + return hash.digest('hex'); +} + +function formatErrorReason(reason) { + if (reason instanceof Error) { + return reason.message.replace(/^ERROR:\s*/, ''); + } + return String(reason); +} + +async function fetchText(url, fetchImpl) { + const response = await fetchWithTimeout(fetchImpl, url); + if (!response.ok) { + fail( + `Failed to download ${url}: ${response.status} ${response.statusText}`, + ); + } + return response.text(); +} + +function fetchWithTimeout(fetchImpl, url, options = {}) { + return fetchImpl(url, { + ...options, + signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), + }); +} + +function normalizeHttpsBaseUrl(baseUrl) { + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + fail(`--base-url must be a valid URL: ${baseUrl}`); + } + if (parsed.protocol !== 'https:') { + fail(`--base-url must use https: ${baseUrl}`); + } + if (!parsed.pathname.endsWith('/')) { + parsed.pathname = `${parsed.pathname}/`; + } + return parsed.toString(); +} + +export { + EXPECTED_STANDALONE_ARCHIVE_NAMES, + EXPECTED_RELEASE_ASSET_NAMES, + releaseAssetPaths, + verifyReleaseBaseUrl, + verifyReleaseDirectory, +};