#!/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' MUTED='\033[0;2m' NC='\033[0m' BRAND_ORANGE='\033[38;5;214m' supports_truecolor() { [[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] } if supports_truecolor; then BRAND_BLUE='\033[38;2;71;150;228m' BRAND_PURPLE='\033[38;2;132;122;206m' else BRAND_BLUE='\033[38;5;68m' BRAND_PURPLE='\033[38;5;140m' fi log_info() { printf '%bINFO:%b %s\n' "${BLUE}" "${NC}" "$1" } 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 } is_terminal() { [ -t 2 ] } print_progress() { local bytes="$1" local length="$2" [ "$length" -gt 0 ] || return 0 local width=50 local percent=$(( bytes * 100 / length )) [ "$percent" -gt 100 ] && percent=100 local on=$(( percent * width / 100 )) local off=$(( width - on )) local filled=$(printf "%*s" "$on" "") filled=${filled// /■} local empty=$(printf "%*s" "$off" "") empty=${empty// /・} printf "\r${BRAND_ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&2 } finish_progress() { print_progress 1 1 echo "" >&2 } TEMP_DIRS=() ACTIVE_DOWNLOAD_PID="" PATH_UPDATE_APPLIED=0 # PATH as inherited from the invoking shell. The script later prepends the # install dir to its own PATH, but that never propagates to the parent shell # (a piped `curl | bash` runs in a child process), so we keep the original # value to decide whether the user must reload their shell rc file. ORIGINAL_PATH="${PATH:-}" 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}") } restore_cursor() { printf "\033[?25h" } kill_active_download() { if [[ -n "${ACTIVE_DOWNLOAD_PID}" ]]; then kill "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null || true ACTIVE_DOWNLOAD_PID="" fi } shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } display_install_version() { if [[ "${VERSION}" == "latest" ]]; then echo "latest" return 0 fi echo "${VERSION#v}" } trap cleanup_temp_dirs EXIT trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM print_usage() { cat </dev/null || 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 } require_npm() { if command_exists npm; then return 0 fi log_error "npm was not found. Install Node.js with npm from https://nodejs.org/" return 1 } get_npm_global_bin() { local prefix prefix=$(npm prefix -g 2>/dev/null || true) if [[ -z "${prefix}" ]]; then return 0 fi case "$(uname -s 2>/dev/null || echo unknown)" in MINGW*|MSYS*|CYGWIN*) echo "${prefix}" ;; *) echo "${prefix}/bin" ;; esac } get_npm_global_root() { npm root -g 2>/dev/null || true } create_source_json() { if [[ "${SOURCE}" == "unknown" ]]; then return 0 fi local qwen_dir="${HOME}/.qwen" mkdir -p "${qwen_dir}" local escaped_source escaped_source=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') cat > "${qwen_dir}/source.json" </dev/null || echo unknown) local arch arch=$(uname -m 2>/dev/null || echo unknown) case "${os}" in Darwin) os="darwin" ;; Linux) os="linux" ;; *) return 1 ;; esac case "${arch}" in x86_64|amd64) arch="x64" ;; arm64|aarch64) arch="arm64" ;; *) return 1 ;; esac echo "${os}-${arch}" } archive_extension_for_target() { case "$1" in darwin-*|linux-*) echo "tar.gz" ;; *) return 1 ;; esac } release_version_path() { if [[ "${VERSION}" == "latest" ]]; then echo "latest" return 0 fi case "${VERSION}" in v*) echo "${VERSION}" ;; *) echo "v${VERSION}" ;; esac } # When a shadowing 'qwen' is detected, append a PATH prepend to the user's # shell rc file at the very end. Putting it at the END means our prepend runs # AFTER any earlier PATH munging in the rc file (e.g., other tools' shell # init), so our installed_bin wins. Idempotent via a marker comment. maybe_update_shell_path() { local install_bin_dir="$1" [[ "${NO_MODIFY_PATH:-0}" == "1" ]] && return 0 [[ -z "${install_bin_dir}" ]] && return 0 [[ -z "${HOME:-}" ]] && return 0 local rc_file="" case "${SHELL:-}" in */zsh) rc_file="${HOME}/.zshrc" ;; */bash) if [[ -f "${HOME}/.bashrc" ]]; then rc_file="${HOME}/.bashrc" elif [[ -f "${HOME}/.bash_profile" ]]; then rc_file="${HOME}/.bash_profile" else rc_file="${HOME}/.bashrc" fi ;; */fish) rc_file="${HOME}/.config/fish/config.fish" ;; *) log_warning "Unsupported shell for automatic PATH update: ${SHELL:-unknown}. Add ${install_bin_dir} to PATH manually." return 0 ;; esac [[ -z "${rc_file}" ]] && return 0 local begin_marker="# Qwen Code PATH block begin" local end_marker="# Qwen Code PATH block end" local quoted_install_bin_dir quoted_install_bin_dir=$(shell_quote "${install_bin_dir}") local export_line if [[ "${rc_file}" == *config.fish ]]; then export_line="set -gx PATH ${quoted_install_bin_dir} \$PATH" else export_line="export PATH=${quoted_install_bin_dir}:\$PATH" fi if [[ -f "${rc_file}" ]] && grep -qxF "${export_line}" "${rc_file}" 2>/dev/null; then local current_tail current_tail=$(tail -n 3 "${rc_file}" 2>/dev/null || true) if [[ "${current_tail}" == "${begin_marker}"$'\n'"${export_line}"$'\n'"${end_marker}" ]]; then PATH_UPDATE_APPLIED=1 PATH_UPDATE_RC_FILE="${rc_file}" return 0 fi 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 } PATH_UPDATE_APPLIED=1 PATH_UPDATE_RC_FILE="${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 : # resolved to ${resolved_version_path} 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 selected="github" 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}" } get_content_length() { local url="$1" curl -fsSLI --retry 1 --connect-timeout 10 --max-time 15 "${url}" 2>/dev/null \ | grep -i '^content-length:' | tail -1 | tr -d '\r' | awk '{print $2}' } download_with_progress() { local url="$1" local output="$2" if ! command_exists curl || ! is_terminal; then download_file_simple "$url" "$output" return $? fi local content_length content_length=$(get_content_length "${url}") if [[ -z "${content_length}" ]] || [[ "${content_length}" -le 0 ]] 2>/dev/null; then download_file_simple "$url" "$output" return $? fi # Skip progress bar for small files (e.g. SHA256SUMS) if [[ "${content_length}" -lt 102400 ]] 2>/dev/null; then curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" return $? fi printf "\033[?25l" >&2 print_progress 0 "${content_length}" curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 "${url}" -o "${output}" & ACTIVE_DOWNLOAD_PID=$! while kill -0 "${ACTIVE_DOWNLOAD_PID}" 2>/dev/null; do if [[ -f "${output}" ]]; then local file_size file_size=$(wc -c < "${output}" 2>/dev/null | tr -d ' ') if [[ -n "${file_size}" && "${file_size}" -gt 0 ]] 2>/dev/null; then print_progress "${file_size}" "${content_length}" fi fi sleep 1 done wait "${ACTIVE_DOWNLOAD_PID}" local exit_code=$? ACTIVE_DOWNLOAD_PID="" printf "\033[?25h" >&2 if [[ $exit_code -eq 0 ]]; then finish_progress else echo "" >&2 fi return $exit_code } download_file_simple() { 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}" & ACTIVE_DOWNLOAD_PID=$! wait "${ACTIVE_DOWNLOAD_PID}" local exit_code=$? ACTIVE_DOWNLOAD_PID="" return "${exit_code}" else wget "${wget_args[@]}" "${url}" -O "${destination}" & ACTIVE_DOWNLOAD_PID=$! wait "${ACTIVE_DOWNLOAD_PID}" local exit_code=$? ACTIVE_DOWNLOAD_PID="" return "${exit_code}" fi fi log_error "curl or wget is required to download the standalone archive." return 1 } download_file() { local url="$1" local destination="$2" download_with_progress "${url}" "${destination}" } url_exists() { local url="$1" probe_url_available "${url}" 30 } sha256_file() { local file_path="$1" if command_exists sha256sum; then LC_ALL=C LANG=C sha256sum "${file_path}" | awk '{print $1}' return 0 fi if command_exists shasum; then LC_ALL=C LANG=C 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 } validate_archive_entry_path() { local entry="$1" entry="${entry//\\//}" while [[ "${entry}" == ./* ]]; do entry="${entry#./}" done # Reject entries containing CR/LF so a `..\r` or `..\n` entry cannot # bypass the literal `..` glob below. case "${entry}" in *$'\r'*|*$'\n'*) log_error "Archive contains unsafe path with control character: ${entry}" return 1 ;; esac case "${entry}" in ""|/*|..|../*|*/..|*/../*) log_error "Archive contains unsafe path: ${entry:-}" return 1 ;; esac } 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 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}" <&2; kill_active_download; cleanup_temp_dirs; exit 130' INT trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM rm -rf "${temp_dir}" "${wrapper_tmp}" log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}." return 1 fi trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}" 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}" } 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) local install_cmd=( npm install -g "${package_spec}" --registry "${NPM_REGISTRY}" ) if "${install_cmd[@]}"; then create_source_json return 0 fi log_error "Failed to install. Try: npm install -g ${package_spec} --registry ${NPM_REGISTRY}" return 1 } gradient_line() { local text="$1" local r1=$2 g1=$3 b1=$4 local r2=$5 g2=$6 b2=$7 local r3=$8 g3=$9 b3=${10} local len=${#text} [ "$len" -eq 0 ] && return if ! supports_truecolor; then printf "%b%s%b\n" "${BRAND_PURPLE}" "${text}" "${NC}" return fi local i=0 local half=$(( len / 2 )) while [ $i -lt $len ]; do local char="${text:$i:1}" local r g b if [ $i -lt $half ]; then local t=$(( i * 1000 / half )) r=$(( (r1 * (1000 - t) + r2 * t) / 1000 )) g=$(( (g1 * (1000 - t) + g2 * t) / 1000 )) b=$(( (b1 * (1000 - t) + b2 * t) / 1000 )) else local t=$(( (i - half) * 1000 / (len - half) )) r=$(( (r2 * (1000 - t) + r3 * t) / 1000 )) g=$(( (g2 * (1000 - t) + g3 * t) / 1000 )) b=$(( (b2 * (1000 - t) + b3 * t) / 1000 )) fi if [ "$char" = " " ]; then printf " " else printf "\033[38;2;%d;%d;%dm%s" "$r" "$g" "$b" "$char" fi i=$(( i + 1 )) done printf "\033[0m\n" } print_logo() { # Per-character gradient matching CLI's ink-gradient rendering # Direction: #4796E4 (blue) → #847ACE (purple) → #C3677F (rose) gradient_line " ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄" 71 150 228 132 122 206 195 103 127 gradient_line "██╔═══██╗██║ ██║██╔════╝████╗ ██║" 71 150 228 132 122 206 195 103 127 gradient_line "██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║" 71 150 228 132 122 206 195 103 127 gradient_line "██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║" 71 150 228 132 122 206 195 103 127 gradient_line "╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║" 71 150 228 132 122 206 195 103 127 gradient_line " ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝" 71 150 228 132 122 206 195 103 127 } print_final_instructions() { local install_bin_dir="${1:-}" local install_dir="${2:-}" local install_method="${3:-standalone}" local installed_bin="" 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" export PATH="${install_bin_dir}:${PATH}" fi # Detect shadowing qwen executables 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}" && "${NO_MODIFY_PATH:-0}" != "1" ]]; then PATH_UPDATE_APPLIED=0 PATH_UPDATE_RC_FILE="" maybe_update_shell_path "${install_bin_dir}" fi 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 # Display the rc file maybe_update_shell_path actually wrote to (e.g. bash # may fall back to ~/.bash_profile), so the success message and the reload # hint can never point at a different file than the one that was modified. local rc_name="${PATH_UPDATE_RC_FILE:-}" if [[ -n "${rc_name}" && -n "${HOME:-}" && "${rc_name}" == "${HOME}"/* ]]; then rc_name="~${rc_name#"${HOME}"}" fi if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" && -n "${rc_name}" ]]; then echo -e "${MUTED}Successfully added${NC} qwen ${MUTED}to \$PATH in${NC} ${rc_name}" fi # The invoking shell keeps its original PATH (and possibly an older qwen # resolved from it) until the rc file is reloaded. Detect both cases and # tell the user exactly what to run instead of letting `qwen` silently # launch a stale version. local shell_reload_needed=0 if [[ -n "${install_bin_dir}" ]]; then case ":${ORIGINAL_PATH}:" in *":${install_bin_dir}:"*) ;; *) shell_reload_needed=1 ;; esac fi if [[ -n "${other_qwens}" ]]; then shell_reload_needed=1 log_warning "Other qwen executables were found and may shadow the new install in this shell:" local shadow_path while IFS= read -r shadow_path; do [[ -z "${shadow_path}" ]] && continue printf ' %s\n' "${shadow_path}" done <<< "${other_qwens}" fi local reload_cmd="" if [[ "${shell_reload_needed}" == "1" ]]; then if [[ "${PATH_UPDATE_APPLIED:-0}" == "1" && -n "${rc_name}" ]]; then reload_cmd="source ${rc_name}" elif [[ -n "${install_bin_dir}" ]]; then log_warning "Make sure ${install_bin_dir} comes first on your PATH, then open a new terminal." fi fi echo "" echo -e "${MUTED}Qwen Code ${installed_version} installed successfully, to start:${NC}" echo "" if [[ -n "${reload_cmd}" ]]; then echo -e "${reload_cmd} ${MUTED}# Load new PATH (or open a new terminal)${NC}" fi echo -e "cd ${MUTED}# Open directory${NC}" echo -e "qwen ${MUTED}# Run command${NC}" echo "" echo -e "${MUTED}For more information visit ${NC}https://github.com/QwenLM/qwen-code" echo "" } 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) 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_error "Standalone archive was unavailable; npm fallback also failed." exit 1 fi else log_error "Standalone install failed. Retry with --method npm to use npm, or --method standalone to debug." exit "${standalone_status}" fi fi ;; esac } main "$@"