mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
1624 lines
49 KiB
Bash
1624 lines
49 KiB
Bash
#!/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 <<EOF
|
|
Qwen Code Installer
|
|
|
|
Usage: $0 [OPTIONS]
|
|
|
|
Options:
|
|
--method METHOD Install method: detect, standalone, or npm (default: detect)
|
|
--mirror MIRROR Mirror: auto, github, or aliyun (default: auto)
|
|
--base-url URL Override standalone archive base URL
|
|
--archive PATH Install from a local standalone archive
|
|
--version VERSION Release version (default: latest)
|
|
--registry URL npm registry (default: https://registry.npmmirror.com)
|
|
--no-modify-path Do not modify shell rc file
|
|
-s, --source SOURCE Record installation source
|
|
-h, --help Show this help message
|
|
|
|
Example:
|
|
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash
|
|
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 ""
|
|
echo "Installing Qwen Code version: $(display_install_version)"
|
|
echo ""
|
|
}
|
|
|
|
print_node_help() {
|
|
echo ""
|
|
echo "Node.js 22 or newer is required. Install from https://nodejs.org/ then rerun."
|
|
echo " brew install node"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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" <<EOF
|
|
{
|
|
"source": "${escaped_source}"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
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
|
|
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:-<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 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}"
|
|
|
|
log_info "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."
|
|
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
|
|
|
|
if ! verify_checksum "${archive_path}" "${checksum_source}" "${archive_name}"; then
|
|
rm -rf "${temp_dir}"
|
|
return 1
|
|
fi
|
|
|
|
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
|
|
|
|
# Suppress INT/TERM during the critical mv swap to avoid leaving
|
|
# INSTALL_LIB_DIR absent if the user presses Ctrl+C between the two moves.
|
|
trap '' INT TERM
|
|
if [[ -e "${INSTALL_LIB_DIR}" ]]; then
|
|
mv "${INSTALL_LIB_DIR}" "${old_install_dir}"
|
|
fi
|
|
|
|
if ! mv "${new_install_dir}" "${INSTALL_LIB_DIR}"; then
|
|
if [[ -e "${old_install_dir}" ]]; then
|
|
mv "${old_install_dir}" "${INSTALL_LIB_DIR}"
|
|
fi
|
|
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT
|
|
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM
|
|
rm -rf "${temp_dir}" "${wrapper_tmp}"
|
|
log_error "Failed to install standalone archive to ${INSTALL_LIB_DIR}."
|
|
return 1
|
|
fi
|
|
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 130' INT
|
|
trap 'restore_cursor >&2; kill_active_download; cleanup_temp_dirs; exit 143' TERM
|
|
|
|
if ! mv -f "${wrapper_tmp}" "${INSTALL_BIN_DIR}/qwen"; then
|
|
rm -rf "${INSTALL_LIB_DIR}" "${wrapper_tmp}"
|
|
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 <project> ${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 "$@"
|