qwen-code/scripts/installation/install-qwen-with-source.sh
易良 aef3e704b4
feat(installer): verify release assets + switch public docs to standalone entrypoint (#3855)
* 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.

* style(installer): align installer completion output

* revert(installer): keep hosted installer output unchanged

* fix(installer): address release validation review feedback

* docs: switch public install commands to standalone hosted entrypoint

Update README, quickstart, and overview to point at the new
install-qwen-standalone.sh / install-qwen-standalone.ps1 hosted URLs.
Add standalone uninstall instructions to Uninstall.md. Remove the
staged-rollout note from INSTALLATION_GUIDE.md since the hosted
installers and release archive sync are now validated in production.

* docs: clarify pull request size guidance

* fix(installation): harden standalone release validation

* fix(installation): redact release verifier credentials

* feat(installer): add visual branding to Linux/macOS install script

Add brand-colored ASCII art logo, custom download progress bar with
Unicode block characters, and step indicators [1/3] [2/3] [3/3] to
match the quality of competing CLI installers.

* fix(test): update stale assertion after guide text was removed

The text "Public installation documentation" was removed in 20f5243f6
but the test assertion was not updated, causing a persistent failure.

* feat(installer): use truecolor per-character gradient for logo branding

Replace 256-color block coloring with 24-bit truecolor per-character
gradient interpolation matching the CLI's ink-gradient rendering.
Colors follow the fallback gradient: #4796E4 → #847ACE → #C3677F.
Remove unused BRAND_ROSE variable and switch step indicators to
BRAND_BLUE for consistency.

* fix(installer): address critical review findings on SSRF, semver, and reliability

- Fix IPv4-mapped IPv6 SSRF bypass: handle 3-part hex representations
  that Node.js produces (e.g. ::ffff:0:7f00:1)
- Reject empty hostname in isPrivateOrReservedHost
- Strip query params in redactUrlForLog to prevent credential leakage
  from signed URLs in CI logs
- Tighten bat semver regex: require '.' or '-' separator before suffix
  (rejects 1.2.3foo, matches shell installer behavior)
- Add -f flag to curl in download_with_progress so HTTP errors aren't
  silently written as file content
- Restore terminal cursor in INT/TERM signal handlers (RETURN trap
  doesn't fire on exit)
- Add unit tests for isPrivateOrReservedHost and redactUrlForLog
- Update test assertion for new split-pattern semver validation

* fix(installer): close release validation review gaps

* test(installer): cover shadowed qwen installs

* fix(installer): avoid npm auto-update for standalone installs

* fix(installer): block IPv4-compatible IPv6 SSRF and harden archive validation [skip ci]

- Add ipv4FromCompatibleIpv6() to detect deprecated RFC 4291 §2.5.5.1
  addresses (e.g. ::7f00:1 → 127.0.0.1) that bypass SSRF protection
- Extend archive validation to reject hardlinks in addition to symlinks
- Add signal trap suppression during critical mv swap to prevent
  partial-install state on Ctrl+C
- Add diagnostic logging to silent catch in standalone detection

* fix(installer): finish standalone install follow-ups

* feat(installer): streamline output with custom progress bar and minimal UX

Replicate OpenCode-style installer experience:
- Add custom ■-character progress bar with percentage (file-size polling)
- Remove verbose INFO:/SUCCESS: prefixes on happy path
- Simplify --help output to essential options
- Keep gradient logo, shadowing warnings, and PATH conflict detection
- Silence mirror probing, checksum, and npm detection messages
- Add "For more information" link to final output

Both .sh and .bat scripts updated consistently.
All 95 tests pass.

* feat(installer): add progress bar and logo to Windows installer

- Add PrintLogo subroutine with QWEN CODE ASCII header
- Add PrintProgressComplete using PowerShell VT100 ■-bar at 100%
- Show progress complete after successful download
- Add spacing in PrintHeader for consistent look with .sh

* fix(installer): address review findings on progress bar

- Replace `sleep 0.3` with `sleep 1` for busybox/minimal env compatibility
- Add file_size > 0 guard to avoid progress bar flicker on empty file
- Remove trailing blank lines before closing braces in 4 functions

* fix(installer): finalize Windows UX — suppress curl progress, fix logo

- Windows: suppress curl ### progress with -s --show-error (keep -#fSLo for test compat)
- Windows: use simple colored "Q W E N  C O D E" logo (truecolor VT100)
- Windows: SHA256SUMS download uses DownloadFileQuiet (no progress bar for small files)
- Windows: remove SUCCESS/INFO PATH messages from MaybeUpdateUserPath
- Linux: fix double 100% progress bar (skip bar for files < 100KB)

* fix(installer): handle Windows backslash paths in standalone detection

`fs.realpathSync` returns backslash paths on Windows (e.g. C:\Users\...\lib\cli.js).
Normalize to forward slashes before matching the /lib/cli.js suffix so standalone
install detection works correctly on Windows.

Fixes CI: Test (windows-latest, Node 22.x)

* fix(installer): normalize expected paths in Windows standalone test

The existsSync mock built expected paths with path.join() which produces
backslashes on Windows, but then compared against a forward-slash-normalized
candidate. Use template literals with forward slashes for the expected
array so both sides match on all platforms.

* refactor(installer): simplify post-install output

Remove verbose post-install messages (install path, uninstall command,
PATH conflict warnings, npm coexistence tips) and replace with a clean
4-line summary matching OpenCode's minimal style.

* refactor(installer): simplify Windows post-install output

Match the Linux/macOS installer simplification — remove verbose
messages (install path, uninstall command, PATH warnings) and keep
only the essential 4-line success summary.

* refactor(installer): suppress verbose Windows messages

Remove "User PATH already starts with", backup WARNING messages,
and PS1 wrapper "Run: qwen" / "qwen is ready to use" output to
match the minimal Linux installer style.

* fix(test): align install-script assertions with simplified output format

The installer scripts were refactored to use a compact output format
(no separate To start/Installed to/Uninstall lines, no shadow warnings),
but the test assertions were not updated accordingly.

* fix(installer): align hardlink detection and expand test coverage

- Rename archive_contains_symlinks to archive_contains_symlinks_or_hardlinks
  in install-qwen-with-source.sh and extend the awk pattern from ^l to ^[lh]
  to also reject hardlinks in archives, aligning with the standalone installer.

- Add macOS (darwin-arm64) standalone detection test and malformed
  manifest.json fallback test in installationInfo.test.ts.

- Add edge-case tests for isPrivateOrReservedHost: decimal-encoded IPs,
  octal-encoded IPs, IPv6 zone IDs, and empty brackets.
2026-06-04 17:23:04 +08:00

998 lines
27 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-with-source.sh --source [github|npm|internal|local-build]
# install-qwen-with-source.sh --method [detect|standalone|npm]
if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then
if command -v bash >/dev/null 2>&1; then
if [ -f "${0}" ]; then
export __QWEN_INSTALL_REEXEC=1
exec bash -- "${0}" "$@"
fi
echo "Error: This script requires bash. Run the installer with: curl ... | bash"
exit 1
fi
echo "Error: This script requires bash. Please install bash first."
exit 1
fi
set -eo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}INFO:${NC} $1"
}
log_success() {
echo -e "${GREEN}SUCCESS:${NC} $1"
}
log_warning() {
echo -e "${YELLOW}WARNING:${NC} $1"
}
log_error() {
echo -e "${RED}ERROR:${NC} $1"
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
TEMP_DIRS=()
cleanup_temp_dirs() {
local temp_dir
for temp_dir in "${TEMP_DIRS[@]}"; do
if [[ -n "${temp_dir}" ]]; then
rm -rf "${temp_dir}"
fi
done
}
register_temp_dir() {
local temp_dir="$1"
TEMP_DIRS+=("${temp_dir}")
}
shell_quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}
trap cleanup_temp_dirs EXIT
trap 'cleanup_temp_dirs; exit 130' INT
trap 'cleanup_temp_dirs; exit 143' TERM
print_usage() {
cat <<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: github or aliyun.
Defaults to QWEN_INSTALL_MIRROR or github.
--base-url URL Override standalone archive base URL.
--archive PATH Install from a local standalone archive.
--version VERSION Standalone release version. Defaults to latest.
--registry REGISTRY npm registry to use for npm fallback.
Defaults to QWEN_NPM_REGISTRY or https://registry.npmmirror.com
-h, --help Show this help message.
Examples:
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --source github
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash -s -- --method standalone
./install-qwen-with-source.sh --archive ./qwen-code-linux-x64.tar.gz
EOF
}
SOURCE="unknown"
METHOD="${QWEN_INSTALL_METHOD:-}"
MIRROR="${QWEN_INSTALL_MIRROR:-github}"
BASE_URL="${QWEN_INSTALL_BASE_URL:-}"
ARCHIVE_PATH="${QWEN_INSTALL_ARCHIVE:-}"
VERSION="${QWEN_INSTALL_VERSION:-latest}"
NPM_REGISTRY="${QWEN_NPM_REGISTRY:-https://registry.npmmirror.com}"
INSTALL_ROOT="${QWEN_INSTALL_ROOT:-${HOME:-}/.local}"
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_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
github|aliyun)
;;
*)
log_error "--mirror must be github or aliyun."
exit 1
;;
esac
validate_https_url "${BASE_URL}" "--base-url"
validate_https_url "${NPM_REGISTRY}" "--registry"
validate_version
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
;;
-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 "=========================================="
echo " Qwen Code Installation Script"
echo "=========================================="
echo ""
log_info "System: $(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)"
log_info "Install method: ${METHOD}"
if [[ "${METHOD}" != "npm" ]]; then
log_info "Standalone mirror: ${MIRROR}"
if [[ -n "${BASE_URL}" ]]; then
log_info "Standalone base URL: ${BASE_URL}"
fi
if [[ -n "${ARCHIVE_PATH}" ]]; then
log_info "Standalone archive: ${ARCHIVE_PATH}"
else
log_info "Standalone version: ${VERSION}"
fi
fi
if [[ "${METHOD}" != "standalone" ]]; then
log_info "npm registry: ${NPM_REGISTRY}"
fi
if [[ "${SOURCE}" != "unknown" ]]; then
log_info "Installation source: ${SOURCE}"
fi
echo ""
}
print_node_help() {
echo ""
echo "Node.js 20 or newer is required before installing Qwen Code with npm."
echo ""
echo "Install Node.js, then rerun this installer:"
case "$(uname -s 2>/dev/null || echo unknown)" in
Darwin)
echo " brew install node"
echo " # or download from https://nodejs.org/"
;;
Linux)
echo " # Use your distribution package manager or:"
echo " https://nodejs.org/en/download/package-manager"
;;
*)
echo " https://nodejs.org/"
;;
esac
echo ""
echo "If you already use a Node version manager, activate Node.js 20+"
echo "in this shell before rerunning the installer."
}
require_node() {
if ! command_exists node; then
log_error "Node.js was not found."
print_node_help
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 20 ]]; then
log_error "Node.js ${node_version:-unknown} is installed, but Node.js 20 or newer is required."
print_node_help
return 1
fi
log_success "Node.js ${node_version} detected."
}
require_npm() {
if command_exists npm; then
log_success "npm $(npm -v 2>/dev/null || echo unknown) detected."
return 0
fi
log_error "npm was not found."
echo ""
echo "Please install Node.js with npm included, then rerun this installer."
echo "Download Node.js from https://nodejs.org/ if your package manager"
echo "installed Node without npm."
return 1
}
get_npm_global_bin() {
local prefix
prefix=$(npm prefix -g 2>/dev/null || true)
if [[ -z "${prefix}" ]]; then
return 0
fi
case "$(uname -s 2>/dev/null || echo unknown)" in
MINGW*|MSYS*|CYGWIN*)
echo "${prefix}"
;;
*)
echo "${prefix}/bin"
;;
esac
}
create_source_json() {
if [[ "${SOURCE}" == "unknown" ]]; then
return 0
fi
local qwen_dir="${HOME}/.qwen"
mkdir -p "${qwen_dir}"
local escaped_source
escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g')
cat > "${qwen_dir}/source.json" <<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
}
standalone_base_url() {
if [[ -n "${BASE_URL}" ]]; then
echo "${BASE_URL%/}"
return 0
fi
local version_path
version_path=$(release_version_path)
if [[ "${MIRROR}" == "aliyun" ]]; then
echo "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code/${version_path}"
return 0
fi
if [[ "${version_path}" == "latest" ]]; then
echo "https://github.com/QwenLM/qwen-code/releases/latest/download"
return 0
fi
echo "https://github.com/QwenLM/qwen-code/releases/download/${version_path}"
}
download_file() {
local url="$1"
local destination="$2"
if command_exists curl; then
curl -fsSL --retry 2 "${url}" -o "${destination}"
return $?
fi
if command_exists wget; then
wget -q --tries=3 "${url}" -O "${destination}" || return 1
return $?
fi
log_error "curl or wget is required to download the standalone archive."
return 1
}
url_exists() {
local url="$1"
if command_exists curl; then
curl -fsIL --retry 1 "${url}" >/dev/null 2>&1
return $?
fi
if command_exists wget; then
wget -q --spider "${url}" >/dev/null 2>&1
return $?
fi
return 1
}
sha256_file() {
local file_path="$1"
if command_exists sha256sum; then
sha256sum "${file_path}" | awk '{print $1}'
return 0
fi
if command_exists shasum; then
shasum -a 256 "${file_path}" | awk '{print $1}'
return 0
fi
return 1
}
verify_checksum() {
local archive_path="$1"
local checksum_source="$2"
local archive_name="$3"
local checksum_file="${checksum_source}"
local temp_checksum=""
if [[ -z "${checksum_file}" ]]; then
checksum_file="$(dirname "${archive_path}")/SHA256SUMS"
elif [[ "${checksum_file}" == http://* || "${checksum_file}" == https://* ]]; then
temp_checksum="$(mktemp)"
if ! download_file "${checksum_file}" "${temp_checksum}"; then
rm -f "${temp_checksum}"
log_error "Could not download SHA256SUMS for checksum verification."
return 1
fi
checksum_file="${temp_checksum}"
fi
if [[ ! -f "${checksum_file}" ]]; then
rm -f "${temp_checksum}"
log_error "SHA256SUMS not found; cannot verify archive."
return 1
fi
local expected
expected=$(awk -v archive_name="${archive_name}" '
{
name = $2
sub(/^\*/, "", name)
if (name == archive_name) {
print $1
exit
}
}
' "${checksum_file}")
if [[ -z "${expected}" ]]; then
rm -f "${temp_checksum}"
log_error "Checksum entry for ${archive_name} not found."
return 1
fi
local actual
if ! actual=$(sha256_file "${archive_path}"); then
rm -f "${temp_checksum}"
log_error "No SHA-256 utility found; cannot verify archive."
return 1
fi
rm -f "${temp_checksum}"
if [[ "${expected}" != "${actual}" ]]; then
log_error "Checksum verification failed for ${archive_name}."
return 1
fi
log_success "Checksum verified for ${archive_name}."
}
validate_archive_entry_path() {
local entry="$1"
while [[ "${entry}" == ./* ]]; do
entry="${entry#./}"
done
case "${entry}" in
""|/*|..|../*|*/..|*/../*|*\\*)
log_error "Archive contains unsafe path: ${entry:-<empty>}"
return 1
;;
esac
}
archive_contains_symlinks_or_hardlinks() {
local archive_path="$1"
case "${archive_path}" in
*.zip)
unzip -Z -v "${archive_path}" 2>/dev/null | grep -E 'Unix file attributes \(12[0-7]{4} octal\)' >/dev/null
;;
*.tar.gz|*.tgz|*.tar.xz)
tar -tvf "${archive_path}" 2>/dev/null | awk '$1 ~ /^[lh]/ { found=1 } END { exit found ? 0 : 1 }'
;;
*)
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
if [[ -z "${entries}" ]]; then
log_error "Archive is empty: ${archive_path}"
return 1
fi
if archive_contains_symlinks_or_hardlinks "${archive_path}"; then
log_error "Archive contains symlinks or hardlinks; refusing to install."
return 1
fi
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 [[ -f "${install_dir}/manifest.json" ]]; then
return 0
fi
log_error "${install_dir} exists but is not a Qwen Code standalone install."
log_error "Refusing to overwrite it. Move or remove it manually, then rerun the installer."
return 1
}
write_unix_wrapper() {
local wrapper_path="$1"
local qwen_bin="$2"
local quoted_qwen_bin
quoted_qwen_bin=$(shell_quote "${qwen_bin}")
cat > "${wrapper_path}" <<EOF
#!/usr/bin/env sh
exec ${quoted_qwen_bin} "\$@"
EOF
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 base_url
base_url=$(standalone_base_url)
local archive_url="${base_url}/${archive_name}"
checksum_source="${base_url}/SHA256SUMS"
if [[ "${METHOD}" == "detect" ]] && ! url_exists "${archive_url}"; then
log_warning "Standalone archive not found: ${archive_name}"
return 2
fi
temp_dir=$(mktemp -d)
register_temp_dir "${temp_dir}"
archive_path="${temp_dir}/${archive_name}"
log_info "Downloading ${archive_url}"
if ! download_file "${archive_url}" "${archive_path}"; then
rm -rf "${temp_dir}"
log_warning "Failed to download standalone archive."
return 2
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
rm -rf "${new_install_dir}" "${old_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}"
}
install_npm() {
require_node || return 1
require_npm || return 1
if command_exists qwen; then
local qwen_version
qwen_version=$(qwen --version 2>/dev/null || echo "unknown")
log_info "Existing Qwen Code detected: ${qwen_version}"
log_info "Upgrading to the latest version."
fi
local install_cmd=(
npm
install
-g
@qwen-code/qwen-code@latest
--registry
"${NPM_REGISTRY}"
)
log_info "Running: npm install -g @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}"
if "${install_cmd[@]}"; then
log_success "Qwen Code installed successfully."
create_source_json
return 0
fi
log_error "Failed to install Qwen Code."
echo ""
echo "This installer does not change your npm prefix or shell profile."
echo "If the failure is a permission error, install Node.js with a user-owned"
echo "Node version manager or fix your npm global package directory, then run:"
echo " npm install -g @qwen-code/qwen-code@latest --registry ${NPM_REGISTRY}"
return 1
}
print_final_instructions() {
local install_bin_dir="${1:-}"
if [[ -n "${install_bin_dir}" ]]; then
export PATH="${install_bin_dir}:${PATH}"
fi
echo ""
echo "=========================================="
echo "Installation completed!"
echo "=========================================="
echo ""
if command_exists qwen; then
local qwen_version
qwen_version=$(qwen --version 2>/dev/null || echo "unknown")
log_success "Qwen Code is ready to use: ${qwen_version}"
echo ""
echo "You can now run: qwen"
echo ""
log_info "Run qwen in your project directory to start an interactive session."
return 0
fi
log_warning "Qwen Code was installed, but qwen is not on PATH in this shell."
echo ""
echo "Restart your terminal, then run: qwen"
if [[ -n "${install_bin_dir}" ]]; then
echo ""
echo "Or run this in the current shell:"
echo " export PATH=\"${install_bin_dir}:\$PATH\""
echo " qwen"
fi
}
main() {
if [[ -z "${HOME:-}" ]]; then
log_error "HOME is not set; cannot determine where to install Qwen Code."
exit 1
fi
print_header
case "${METHOD}" in
standalone)
install_standalone
print_final_instructions "${INSTALL_BIN_DIR}"
;;
npm)
install_npm
print_final_instructions "$(get_npm_global_bin)"
;;
detect)
# Try the standalone archive first; fall back only when unavailable.
if install_standalone; then
print_final_instructions "${INSTALL_BIN_DIR}"
else
standalone_status=$?
if [[ "${standalone_status}" -eq 2 ]]; then
log_warning "Falling back to npm installation."
if install_npm; then
print_final_instructions "$(get_npm_global_bin)"
else
log_warning "Standalone archive was unavailable before npm fallback; npm fallback also failed."
log_warning "Retry with --method standalone to debug the standalone failure, or install Node.js 20+ and rerun --method npm."
exit 1
fi
else
log_warning "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug the standalone failure."
exit "${standalone_status}"
fi
fi
;;
esac
}
main "$@"