qwen-code/scripts/installation/install-qwen-standalone.sh
易良 d59c9e7b77
feat(installer): add standalone hosted install and uninstall flow (#3828)
* 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 <qwen-coder@alibabacloud.com>

* 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 <url>/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-<random>.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 <secret>`.
  Passing the access-key secret via argv made it visible in /proc/<pid>/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 <config>`, `--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/<pid>/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 <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <effortyiliang@gmail.com>
2026-05-21 11:57:10 +08:00

1467 lines
44 KiB
Bash
Executable file

#!/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 <<EOF
Qwen Code Installer
Usage: $0 [OPTIONS]
Options:
-s, --source SOURCE Record the installation source.
--method METHOD Install method: detect, standalone, or npm.
Defaults to QWEN_INSTALL_METHOD or detect.
--mirror MIRROR Standalone archive mirror: auto, github, or aliyun.
Defaults to QWEN_INSTALL_MIRROR or auto, which picks
whichever responds first via a HEAD probe.
--base-url URL Override standalone archive base URL.
--archive PATH Install from a local standalone archive.
--version VERSION Standalone release version. Defaults to latest.
--registry REGISTRY npm registry to use for npm fallback.
Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com
--no-modify-path Do not append PATH to the user's shell rc file even
when a shadowing 'qwen' is detected.
-h, --help Show this help message.
Examples:
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --source github
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash -s -- --method standalone
./install-qwen-standalone.sh --archive ./qwen-code-linux-x64.tar.gz
EOF
}
SOURCE="unknown"
METHOD="${QWEN_INSTALL_METHOD:-}"
MIRROR="${QWEN_INSTALL_MIRROR:-auto}"
BASE_URL="${QWEN_INSTALL_BASE_URL:-}"
ARCHIVE_PATH="${QWEN_INSTALL_ARCHIVE:-}"
VERSION="${QWEN_INSTALL_VERSION:-latest}"
NO_MODIFY_PATH="${QWEN_NO_MODIFY_PATH:-0}"
NPM_REGISTRY="${QWEN_NPM_REGISTRY:-https://registry.npmmirror.com}"
INSTALL_ROOT="${QWEN_INSTALL_ROOT:-${HOME:-}/.local}"
if [[ -n "${QWEN_INSTALL_LIB_DIR:-}" ]]; then
INSTALL_LIB_DIR="${QWEN_INSTALL_LIB_DIR}"
INSTALL_LIB_PARENT="$(dirname "${INSTALL_LIB_DIR}")"
else
INSTALL_LIB_PARENT="${QWEN_INSTALL_LIB_PARENT:-${INSTALL_ROOT}/lib}"
INSTALL_LIB_DIR="${INSTALL_LIB_PARENT}/qwen-code"
fi
INSTALL_BIN_DIR="${QWEN_INSTALL_BIN_DIR:-${INSTALL_ROOT}/bin}"
validate_source() {
if [[ "${SOURCE}" == "unknown" ]]; then
return 0
fi
if [[ "${SOURCE}" =~ ^[A-Za-z0-9._-]+$ ]]; then
return 0
fi
log_error "--source may only contain letters, numbers, dot, underscore, or dash."
exit 1
}
validate_https_url() {
local value="$1"
local option_name="$2"
if [[ -z "${value}" ]]; then
return 0
fi
if [[ "${value}" == https://* ]]; then
return 0
fi
log_error "${option_name} must start with https://"
exit 1
}
validate_version() {
if [[ "${VERSION}" == "latest" ]]; then
return 0
fi
if [[ "${VERSION}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$ ]]; then
return 0
fi
log_error "--version must be 'latest' or a semver string."
exit 1
}
validate_github_repo() {
local github_repo="${QWEN_INSTALL_GITHUB_REPO:-QwenLM/qwen-code}"
if [[ "${github_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
return 0
fi
log_error "QWEN_INSTALL_GITHUB_REPO must be in owner/repo format."
exit 1
}
validate_install_path() {
local value="$1"
local option_name="$2"
if [[ -z "${value}" ]]; then
log_error "${option_name} must not be empty."
exit 1
fi
case "${value}" in
*$'\n'*|*$'\r'*)
log_error "${option_name} must not contain newlines."
exit 1
;;
esac
if [[ "${value}" != /* ]]; then
log_error "${option_name} must be an absolute path."
exit 1
fi
}
validate_options() {
METHOD="${METHOD:-detect}"
case "${METHOD}" in
detect|standalone|npm)
;;
*)
log_error "--method must be detect, standalone, or npm."
exit 1
;;
esac
case "${MIRROR}" in
auto|github|aliyun)
;;
*)
log_error "--mirror must be auto, github, or aliyun."
exit 1
;;
esac
validate_https_url "${BASE_URL}" "--base-url"
validate_https_url "${NPM_REGISTRY}" "--registry"
validate_version
validate_github_repo
validate_install_path "${INSTALL_ROOT}" "QWEN_INSTALL_ROOT"
validate_install_path "${INSTALL_LIB_PARENT}" "QWEN_INSTALL_LIB_PARENT"
validate_install_path "${INSTALL_LIB_DIR}" "QWEN_INSTALL_LIB_DIR"
validate_install_path "${INSTALL_BIN_DIR}" "QWEN_INSTALL_BIN_DIR"
validate_source
}
while [[ $# -gt 0 ]]; do
case "$1" in
-s|--source)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--source requires a value"
exit 1
fi
SOURCE="$2"
shift 2
;;
--method)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--method requires a value"
exit 1
fi
METHOD="$2"
shift 2
;;
--mirror)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--mirror requires a value"
exit 1
fi
MIRROR="$2"
shift 2
;;
--base-url)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--base-url requires a value"
exit 1
fi
validate_https_url "$2" "--base-url"
BASE_URL="$2"
shift 2
;;
--archive)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--archive requires a value"
exit 1
fi
ARCHIVE_PATH="$2"
shift 2
;;
--version)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--version requires a value"
exit 1
fi
VERSION="$2"
shift 2
;;
--registry)
if [[ -z "${2:-}" ]] || [[ "${2:-}" == -* ]]; then
log_error "--registry requires a value"
exit 1
fi
NPM_REGISTRY="$2"
shift 2
;;
--no-modify-path)
NO_MODIFY_PATH=1
shift
;;
-h|--help)
print_usage
exit 0
;;
*)
log_error "Unknown option: $1"
echo ""
print_usage
exit 1
;;
esac
done
# Validate all user-supplied options before doing network or filesystem work.
validate_options
print_header() {
echo "Installing Qwen Code version: $(display_install_version)"
}
print_node_help() {
echo ""
echo "Node.js 22 or newer is required before installing Qwen Code with npm."
echo ""
echo "Install Node.js, then rerun this installer:"
case "$(uname -s 2>/dev/null || echo unknown)" in
Darwin)
echo " brew install node"
echo " # or download from https://nodejs.org/"
;;
Linux)
echo " # Use your distribution package manager or:"
echo " https://nodejs.org/en/download/package-manager"
;;
*)
echo " https://nodejs.org/"
;;
esac
echo ""
echo "If you already use a Node version manager, activate Node.js 22+"
echo "in this shell before rerunning the installer."
}
require_node() {
if ! command_exists node; then
log_error "Node.js was not found."
print_node_help
return 1
fi
local node_version
node_version=$(node -p "process.versions.node" 2>/dev/null || true)
local node_major
node_major=$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || true)
if [[ -z "${node_major}" ]] || ! [[ "${node_major}" =~ ^[0-9]+$ ]]; then
log_error "Unable to determine Node.js version."
print_node_help
return 1
fi
if [[ "${node_major}" -lt 22 ]]; then
log_error "Node.js ${node_version:-unknown} is installed, but Node.js 22 or newer is required."
print_node_help
return 1
fi
log_success "Node.js ${node_version} detected."
}
require_npm() {
if command_exists npm; then
log_success "npm $(npm -v 2>/dev/null || echo unknown) detected."
return 0
fi
log_error "npm was not found."
echo ""
echo "Please install Node.js with npm included, then rerun this installer."
echo "Download Node.js from https://nodejs.org/ if your package manager"
echo "installed Node without npm."
return 1
}
get_npm_global_bin() {
local prefix
prefix=$(npm prefix -g 2>/dev/null || true)
if [[ -z "${prefix}" ]]; then
return 0
fi
case "$(uname -s 2>/dev/null || echo unknown)" in
MINGW*|MSYS*|CYGWIN*)
echo "${prefix}"
;;
*)
echo "${prefix}/bin"
;;
esac
}
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" <<EOF
{
"source": "${escaped_source}"
}
EOF
log_success "Installation source saved to ~/.qwen/source.json"
}
detect_target() {
local os
os=$(uname -s 2>/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:-<empty>}"
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}" <<EOF
#!/usr/bin/env sh
exec ${quoted_qwen_bin} "\$@"
EOF
then
return 1
fi
chmod +x "${wrapper_path}"
}
install_standalone() {
# Return 2 only when a standalone archive is unavailable and detect mode may
# fall back to npm. Return 1 for integrity or install failures that should
# not be masked by an automatic fallback.
local target=""
local archive_name=""
local archive_path=""
local checksum_source=""
local temp_dir=""
# Resolve the archive from a local file or from the configured release mirror.
if [[ -n "${ARCHIVE_PATH}" ]]; then
archive_path="${ARCHIVE_PATH}"
archive_name="$(basename "${archive_path}")"
if [[ ! -f "${archive_path}" ]]; then
log_error "Standalone archive not found: ${archive_path}"
return 1
fi
else
if ! target=$(detect_target); then
log_warning "Standalone archive is not available for this platform."
return 2
fi
local archive_extension
archive_extension=$(archive_extension_for_target "${target}")
archive_name="qwen-code-${target}.${archive_extension}"
local requested_mirror="${MIRROR}"
local requested_version_path=""
local github_fallback_base_url=""
if [[ -z "${BASE_URL}" && "${requested_mirror}" == "auto" ]]; then
requested_version_path=$(release_version_path)
github_fallback_base_url="$(github_base_url_for_version "${requested_version_path}")"
fi
local base_url
if ! base_url=$(standalone_base_url); then
if [[ -n "${github_fallback_base_url}" ]]; then
log_warning "Aliyun standalone release metadata unavailable; retrying GitHub mirror."
base_url="${github_fallback_base_url}"
MIRROR="github"
github_fallback_base_url=""
else
if [[ "${METHOD}" == "detect" ]]; then
return 2
fi
return 1
fi
fi
if [[ -n "${github_fallback_base_url}" && "${requested_version_path}" == "latest" ]]; then
local aliyun_release_base="https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/"
if [[ "${base_url}" == "${aliyun_release_base}"* ]]; then
local resolved_version_path="${base_url#"${aliyun_release_base}"}"
if [[ -n "${resolved_version_path}" && "${resolved_version_path}" != "latest" && "${resolved_version_path}" != */* ]]; then
github_fallback_base_url="$(github_base_url_for_version "${resolved_version_path}")"
fi
fi
fi
if [[ "${base_url}" == "${github_fallback_base_url}" ]]; then
github_fallback_base_url=""
fi
local archive_url="${base_url}/${archive_name}"
checksum_source="${base_url}/SHA256SUMS"
if [[ "${METHOD}" == "detect" ]] && ! url_exists "${archive_url}"; then
if [[ -n "${github_fallback_base_url}" ]]; then
local github_archive_url="${github_fallback_base_url}/${archive_name}"
if url_exists "${github_archive_url}"; then
log_warning "Aliyun standalone archive not found; retrying GitHub mirror."
base_url="${github_fallback_base_url}"
archive_url="${github_archive_url}"
checksum_source="${base_url}/SHA256SUMS"
MIRROR="github"
github_fallback_base_url=""
else
log_warning "Standalone archive not found: ${archive_name}"
return 2
fi
else
log_warning "Standalone archive not found: ${archive_name}"
return 2
fi
fi
temp_dir=$(mktemp -d)
register_temp_dir "${temp_dir}"
archive_path="${temp_dir}/${archive_name}"
echo "Downloading ${archive_name}"
if ! download_file "${archive_url}" "${archive_path}"; then
if [[ -n "${github_fallback_base_url}" ]]; then
rm -f "${archive_path}"
archive_url="${github_fallback_base_url}/${archive_name}"
checksum_source="${github_fallback_base_url}/SHA256SUMS"
MIRROR="github"
github_fallback_base_url=""
log_warning "Aliyun standalone archive download failed; retrying GitHub mirror."
echo "Downloading ${archive_name}"
if download_file "${archive_url}" "${archive_path}"; then
:
else
rm -rf "${temp_dir}"
log_warning "Failed to download standalone archive."
if [[ "${METHOD}" == "detect" ]]; then
return 2
fi
return 1
fi
else
rm -rf "${temp_dir}"
log_warning "Failed to download standalone archive."
if [[ "${METHOD}" == "detect" ]]; then
return 2
fi
return 1
fi
fi
fi
if [[ -z "${temp_dir}" ]]; then
temp_dir=$(mktemp -d)
register_temp_dir "${temp_dir}"
fi
# Verify integrity before extraction or changing the install directory.
if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then
rm -rf "${temp_dir}"
return 1
fi
# Extract into a temporary directory, then validate required entry points.
local extract_dir="${temp_dir}/extract"
if ! extract_archive "${archive_path}" "${extract_dir}"; then
rm -rf "${temp_dir}"
return 1
fi
if [[ ! -f "${extract_dir}/qwen-code/bin/qwen" || -L "${extract_dir}/qwen-code/bin/qwen" || ! -x "${extract_dir}/qwen-code/bin/qwen" ]]; then
log_error "Archive does not contain qwen-code/bin/qwen."
rm -rf "${temp_dir}"
return 1
fi
if [[ ! -f "${extract_dir}/qwen-code/node/bin/node" || -L "${extract_dir}/qwen-code/node/bin/node" || ! -x "${extract_dir}/qwen-code/node/bin/node" ]]; then
log_error "Archive does not contain executable qwen-code/node/bin/node."
rm -rf "${temp_dir}"
return 1
fi
mkdir -p "${INSTALL_LIB_PARENT}" "${INSTALL_BIN_DIR}" || {
rm -rf "${temp_dir}"
return 1
}
# Stage into .new and keep .old so failed upgrades can roll back.
local new_install_dir="${INSTALL_LIB_DIR}.new"
local old_install_dir="${INSTALL_LIB_DIR}.old"
local wrapper_tmp="${INSTALL_BIN_DIR}/qwen.new"
if ! ensure_managed_install_dir "${INSTALL_LIB_DIR}" ||
! ensure_managed_install_dir "${new_install_dir}" ||
! ensure_managed_install_dir "${old_install_dir}"; then
rm -rf "${temp_dir}"
return 1
fi
if ! restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"; then
rm -rf "${temp_dir}"
return 1
fi
if [[ -e "${old_install_dir}" ]]; then
rm -rf "${old_install_dir}" || {
rm -rf "${temp_dir}"
log_error "Failed to remove stale install backup: ${old_install_dir}"
return 1
}
fi
rm -rf "${new_install_dir}" "${wrapper_tmp}"
mv "${extract_dir}/qwen-code" "${new_install_dir}"
if ! write_unix_wrapper "${wrapper_tmp}" "${INSTALL_LIB_DIR}/bin/qwen"; then
rm -rf "${temp_dir}" "${new_install_dir}" "${wrapper_tmp}"
log_error "Failed to create qwen wrapper in ${INSTALL_BIN_DIR}."
return 1
fi
if [[ -e "${INSTALL_LIB_DIR}" ]]; then
mv "${INSTALL_LIB_DIR}" "${old_install_dir}"
fi
if ! mv "${new_install_dir}" "${INSTALL_LIB_DIR}"; then
if [[ -e "${old_install_dir}" ]]; then
mv "${old_install_dir}" "${INSTALL_LIB_DIR}"
fi
rm -rf "${temp_dir}" "${wrapper_tmp}"
log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}."
return 1
fi
if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then
rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}"
if [[ -e "${old_install_dir}" ]]; then
mv "${old_install_dir}" "${INSTALL_LIB_DIR}"
fi
rm -rf "${temp_dir}"
log_error "Failed to create qwen wrapper in ${INSTALL_BIN_DIR}."
return 1
fi
rm -rf "${old_install_dir}"
export PATH="${INSTALL_BIN_DIR}:${PATH}"
create_source_json
rm -rf "${temp_dir}"
log_success "Qwen Code standalone archive installed successfully."
log_info "Installed to ${INSTALL_LIB_DIR}"
}
npm_package_spec() {
if [[ "${VERSION}" == "latest" ]]; then
echo "@qwen-code/qwen-code@latest"
return 0
fi
local npm_version="${VERSION#v}"
echo "@qwen-code/qwen-code@${npm_version}"
}
install_npm() {
require_node || return 1
require_npm || return 1
local package_spec
package_spec=$(npm_package_spec)
if command_exists qwen; then
local qwen_version
qwen_version=$(qwen --version 2>/dev/null || echo "unknown")
log_info "Existing Qwen Code detected: ${qwen_version}"
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 <project>"
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 "$@"