#!/usr/bin/env bash # ============================================================= # DFCode · 一键安装(macOS / Linux) # 自动:检测/安装 Node.js -> 解析目标版本 -> 安装 -> 校验 # 用法:(dfcode_installer="$(mktemp "${TMPDIR:-/tmp}/dfcode-install.XXXXXX")" && trap 'rm -f "$dfcode_installer"' EXIT && curl -fsSL --connect-timeout 10 --max-time 120 http://dfcode.codes:8091/install.sh -o "$dfcode_installer" && test -s "$dfcode_installer" && bash "$dfcode_installer") # # ★ 维护者必读:本脚本的校验必须证明 dfcode「能跑」,而不是「文件存在」。 # 旧版这里是 `dfcode --version 2>/dev/null || echo '未知'`,它会为一个彻底 # 损坏的安装打印绿色的 [OK] DFCode 版本:未知,然后继续宣布「环境就绪」。 # 任何让校验更容易通过的改动,都是在把失败藏起来。 # ============================================================= # 守卫:sh/dash 跑本脚本会在 pipefail 处报晦涩错误,这里先给出友好提示 if [ -z "${BASH_VERSION:-}" ]; then echo "[X] 请用 bash 运行本脚本:bash install_mac.sh" exit 1 fi set -uo pipefail exec 3>&1 # Keep progress visible while npm stdout/stderr are captured in logs. SCRIPT_VERSION='2026.09.23.1' NODE_MIN_MAJOR=18 LOG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dfcode-install.XXXXXX")" || exit 1 chmod 700 "$LOG_DIR" || exit 1 NVM_LOG="$LOG_DIR/dfcode_nvm.log" NPM_LOG="$LOG_DIR/dfcode_npm.log" # npm's user cache may contain files created by root or an older installer. # Keep this run isolated so a damaged ~/.npm cannot block metadata lookup or # make a valid release look missing from the mirror. NPM_CACHE="$LOG_DIR/npm-cache" mkdir -m 700 "$NPM_CACHE" || exit 1 export NPM_CONFIG_CACHE="$NPM_CACHE" BLUE='\033[0;34m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' say() { printf "%b[*] %s%b\n" "$BLUE" "$1" "$NC"; } ok() { printf "%b[OK] %s%b\n" "$GREEN" "$1" "$NC"; } warn() { printf "%b[!] %s%b\n" "$YELLOW" "$1" "$NC"; } die() { printf "%b[X] %s%b\n" "$RED" "$1" "$NC"; exit 1; } # Node 就绪后使用独立进程组;超时回收整个 npm/CLI 进程树。 run_with_timeout() { local seconds="${DFCODE_TIMEOUT_SECONDS:-$1}"; shift node - "$seconds" "$@" <<'NODE_TIMEOUT' const { spawn } = require("node:child_process"); const fs = require("node:fs"); const seconds = Number(process.argv[2]); if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600) process.exit(2); const phase = process.env.DFCODE_INSTALL_PHASE || process.argv[3]; const started = performance.now(); process.stderr.write(`[phase] ${phase} timeout_ms=${seconds * 1000}\n`); const progress = setInterval(() => { try { fs.writeSync(3, `[*] ${phase}:已等待 ${Math.floor((performance.now() - started) / 1000)} 秒,仍在处理。\n`); } catch { /* Progress must not make installation fail if stdout was closed. */ } }, 15000); const child = spawn(process.argv[3], process.argv.slice(4), { detached: true, stdio: ["ignore", "inherit", "inherit"], }); let stopping = false; const killGroup = (signal) => { if (!child.pid) return; try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } }; const stop = (code) => { if (stopping) return; stopping = true; clearTimeout(timer); clearInterval(progress); process.stderr.write(`[exit] code=${code} elapsed_ms=${Math.round(performance.now() - started)} reason=${code === 124 ? "timeout" : "cancelled"}\n`); killGroup("SIGTERM"); setTimeout(() => { killGroup("SIGKILL"); process.exit(code); }, 1000); }; const timer = setTimeout(() => stop(124), seconds * 1000); process.on("SIGINT", () => stop(130)); process.on("SIGTERM", () => stop(143)); child.on("error", (error) => { clearTimeout(timer); clearInterval(progress); process.stderr.write(`${error.message}\n`); process.exit(1); }); child.on("exit", (code) => { if (stopping) return; clearTimeout(timer); clearInterval(progress); process.stderr.write(`[exit] code=${code} elapsed_ms=${Math.round(performance.now() - started)} reason=completed\n`); process.exit(code === null ? 1 : code); }); NODE_TIMEOUT } # 下载到文件:curl 优先,没有就用 wget(精简容器/内网镜像常常只有其中一个) fetch_to() { local url="$1" out="$2" if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 8 --max-time 30 -o "$out" "$url" /dev/null 2>&1; then wget -q --timeout=30 --tries=1 -O "$out" "$url" >"$profile" <<'NVM_PROFILE_INIT' # DFCode Node environment export NVM_DIR="$HOME/.nvm" if ! command -v nvm >/dev/null 2>&1 && [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh" fi NVM_PROFILE_INIT then die "无法写入 Node 启动配置:$profile" fi fi } echo "============================================================" echo " DFCode 一键安装(macOS / Linux) 脚本版本 $SCRIPT_VERSION" echo " 自动检查/安装 Node.js,并安装指定的 DFCode 版本" echo "============================================================" NODE_VIA_NVM=0 NVM_PROFILE='' # ---------- 1. 检查 / 自动安装 Node.js ---------- say "第 1 步:检查 Node.js" if ! command -v node >/dev/null 2>&1; then warn "未检测到 Node.js,尝试自动安装..." # Apple Silicon:brew 装在 /opt/homebrew,若用户没跑过 shellenv,command -v brew 会找不到, # 于是旧版直接跳到 nvm,白白装出第二个 Node 运行时。显式探测两个标准位置。 if ! command -v brew >/dev/null 2>&1; then for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$b" ]; then eval "$("$b" shellenv /dev/null 2>&1; then say "使用 Homebrew 安装 Node.js..." brew install node /dev/null || true fi # Linux 且有 root/sudo:发行版包管理器比 nvm 更省事,内网也更容易通 if ! command -v node >/dev/null 2>&1 && [ "$(uname -s)" = "Linux" ]; then SUDO='' if [ "$(id -u)" -eq 0 ]; then SUDO='' elif command -v sudo >/dev/null 2>&1; then SUDO='sudo' fi if [ -n "$SUDO" ]; then if [ -r /dev/tty ]; then # shellcheck disable=SC2024 if ! sudo -v /dev/null 2>&1; then say "使用 apt 安装 Node.js..."; $SUDO apt-get update -qq /dev/null 2>&1; then say "使用 dnf 安装 Node.js..."; $SUDO dnf install -y nodejs /dev/null 2>&1; then say "使用 yum 安装 Node.js..."; $SUDO yum install -y nodejs /dev/null || true fi fi # 最后回退 nvm(无需 sudo) if ! command -v node >/dev/null 2>&1; then say "使用 nvm 安装 Node.js(无需 sudo)..." export NVM_DIR="$HOME/.nvm" mkdir -p "$NVM_DIR" || die "无法创建 nvm 目录:$NVM_DIR" case "${SHELL:-}" in */bash) NVM_PROFILE="$HOME/.bashrc" ;; */zsh) NVM_PROFILE="$HOME/.zshrc" ;; *) NVM_PROFILE="$(login_profile)" ;; esac touch "$NVM_PROFILE" || die "无法创建或更新 shell 启动文件:$NVM_PROFILE" installer="$LOG_DIR/dfcode_nvm_install.sh" # ★ 先下载、再执行 ★ 旧版是 `curl ... | bash 2>&1 | tail -n 2 || {回退 gitee}`: # 在 pipefail 下,curl 成功但 nvm 安装脚本失败也会触发「github 拉取失败」这句谎报; # 而 gitee 分支末尾的 `|| true` 会把彻底失败也吞掉,然后带着不存在的 nvm.sh 继续。 # 拆开之后,失败原因才对得上,也才有日志可看。 if fetch_to "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh" "$installer"; then : else rc=$? [ "$rc" -eq 127 ] && die "缺少 curl 和 wget,无法自动安装。请先装其中之一(Ubuntu/Debian: sudo apt-get install -y curl)后重跑。" warn "github 拉取 nvm 失败,改用 gitee 镜像..." fetch_to "https://gitee.com/mirrors/nvm/raw/v0.40.0/install.sh" "$installer" \ || die "nvm 安装脚本下载失败(github 与 gitee 均不可达)。请检查网络或按文档手动安装 Node.js。" fi # ★ 全量日志 ★ 旧版 `2>/dev/null | tail -n 2` 把真正的报错冲掉了,DNS 失败时零诊断输出。 if ! PROFILE="$NVM_PROFILE" bash "$installer" "$NVM_LOG" 2>&1; then warn "nvm 安装脚本执行失败,完整日志:$NVM_LOG" tail -n 5 "$NVM_LOG" 2>/dev/null || true fi set +u if [ -s "$NVM_DIR/nvm.sh" ]; then # shellcheck disable=SC1091 . "$NVM_DIR/nvm.sh" else warn "在 $NVM_DIR 找不到 nvm.sh —— 安装器可能写到了别的路径,详见 $NVM_LOG" fi # Node 二进制走国内镜像,内网/国内直连 nodejs.org 常不可达 export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node" if command -v nvm >/dev/null 2>&1; then if nvm install 24 >"$NVM_LOG" 2>&1; then NODE_VIA_NVM=1; else warn "nvm install 24 失败,详见 $NVM_LOG"; fi fi set -u hash -r 2>/dev/null || true if [ "$NODE_VIA_NVM" -eq 1 ]; then persist_nvm_profile "$NVM_PROFILE" case "${SHELL:-}" in */bash) # Bash login shells read only the first existing file in this order. # Preserve an existing .profile/.bash_login instead of shadowing it. nvm_login_profile="$HOME/.bash_profile" for candidate in .bash_profile .bash_login .profile; do if [ -f "$HOME/$candidate" ]; then nvm_login_profile="$HOME/$candidate"; break; fi done persist_nvm_profile "$nvm_login_profile" ;; esac fi fi fi command -v node >/dev/null 2>&1 || die "Node.js 自动安装失败。请手动安装 LTS:https://nodejs.org 后重跑。" NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' /dev/null || echo 0)" # 旧版这里只 warn 不 die,于是 Node 14 会一路走到 npm install 然后报一堆看不懂的错。 if ! printf '%s' "$NODE_MAJOR" | grep -Eq '^[0-9]+$'; then NODE_MAJOR=0; fi if [ "$NODE_MAJOR" -lt "$NODE_MIN_MAJOR" ]; then die "Node.js 版本过低($(node -v /dev/null 2>&1 || die "未检测到 npm(应随 Node 一起安装)。" # 同一用户的两个安装窗口不能同时改 npm 全局目录。 LOCK_BASE="$HOME/.local/state/dfcode" LOCK_DIR="$LOCK_BASE/npm-installer.lock" LOCK_OWNED=0 KEEP_LOCK=0 release_install_lock() { if [ "$LOCK_OWNED" -eq 1 ] && [ "$KEEP_LOCK" -eq 0 ]; then rm -f "$LOCK_DIR/owner.json" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true fi rm -rf "$NPM_CACHE" 2>/dev/null || true } trap release_install_lock EXIT # 中断时不能证明所有子进程都已退出,保留锁供人工核对。 trap 'KEEP_LOCK=1; exit 130' INT trap 'KEEP_LOCK=1; exit 143' TERM mkdir -p "$LOCK_BASE" || die "无法创建安装状态目录:$LOCK_BASE" if ! mkdir "$LOCK_DIR" 2>/dev/null; then die "另一个 DFCode 安装可能正在运行。请先关闭它;如上次被中断,确认没有安装进程后再移除 ${LOCK_DIR}。" fi LOCK_OWNED=1 printf '{"pid":%s,"started":"%s"}\n' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$LOCK_DIR/owner.json" \ || die "无法记录安装锁:$LOCK_DIR" # ---------- 2. 确定目标版本 ---------- say "第 2 步:确定 DFCode 版本" OFFICIAL_REGISTRY='https://registry.npmjs.org' MIRROR_REGISTRIES=( 'https://repo.huaweicloud.com/repository/npm' 'https://mirrors.cloud.tencent.com/npm' ) REQUESTED_VERSION="${DFCODE_VERSION:-}" DIST_TAG="${DFCODE_DIST_TAG:-latest}" # Allow large official platform downloads time to finish. Only a failed or # bounded-out attempt opens the ordered mirror route; never race registries. NPM_OPTIONS=(--cache "$NPM_CACHE" --no-audit --no-fund) OFFICIAL_FETCH_OPTIONS=(--fetch-retries=2 --fetch-retry-factor=10 --fetch-retry-mintimeout=10000 --fetch-retry-maxtimeout=60000) MIRROR_FETCH_OPTIONS=(--fetch-retries=1 --fetch-retry-mintimeout=1000 --fetch-retry-maxtimeout=3000) INSTALL_TIMEOUT=600 MIRROR_METADATA_TIMEOUT=45 MIRROR_INSTALL_TIMEOUT=180 now_seconds() { node -e 'console.log(Number(process.hrtime.bigint() / 1000000000n))'; } OFFICIAL_STARTED=$(now_seconds) official_remaining() { local remaining=$((INSTALL_TIMEOUT - ($(now_seconds) - OFFICIAL_STARTED))) [ "$remaining" -gt 0 ] || remaining=0 printf '%s' "$remaining" } say "官方源优先:版本查询、安装和重试合计最多等待 10 分钟。" LAST_NPM_LOG="$LOG_DIR/npm-attempt.log" LAST_NPM_RC=0 PREFIX_OPTIONS=() USER_PREFIX=0 USER_PREFIX_PATH='' # 只接受精确 SemVer;不能把 range、tag 或任意文本交给 npm 当包名。 VERSION_HELPER="$LOG_DIR/version.cjs" cat >"$VERSION_HELPER" <<'NODE_VERSION' const fs = require("node:fs"); function parts(value) { if (typeof value !== "string") return null; const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value); if (!match || match[0] !== value) return null; const pre = match[4] === undefined ? [] : match[4].split("."); if (pre.some((item) => /^0\d+$/.test(item))) return null; return { core: match.slice(1, 4), pre }; } function integrity(metadata) { const sri = metadata && metadata.dist && metadata.dist.integrity; if (typeof sri !== "string" || !sri.length || sri.trim() !== sri) return null; for (const item of sri.split(/\s+/)) { const match = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/.exec(item); if (!match) return null; const bytes = Buffer.from(match[2], "base64"); if (bytes.length !== Number(match[1].slice(3)) / 8 || bytes.toString("base64") !== match[2]) return null; } return sri; } function numeric(a, b) { return a.length === b.length ? (a === b ? 0 : a < b ? -1 : 1) : a.length < b.length ? -1 : 1; } function compare(a, b) { for (let i = 0; i < 3; i++) { const result = numeric(a.core[i], b.core[i]); if (result) return result; } if (!a.pre.length || !b.pre.length) return a.pre.length === b.pre.length ? 0 : a.pre.length ? -1 : 1; for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { if (a.pre[i] === undefined || b.pre[i] === undefined) return a.pre[i] === undefined ? -1 : 1; if (a.pre[i] === b.pre[i]) continue; const an = /^\d+$/.test(a.pre[i]), bn = /^\d+$/.test(b.pre[i]); return an && bn ? numeric(a.pre[i], b.pre[i]) : an !== bn ? (an ? -1 : 1) : a.pre[i] < b.pre[i] ? -1 : 1; } return 0; } try { const [mode, input, other] = process.argv.slice(2); if (mode === "failure") { const text = fs.readFileSync(input, "utf8").replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); const network = "ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|ESOCKETTIMEDOUT|ERR_SOCKET_TIMEOUT|EAI_AGAIN|EAI_FAIL|ENETUNREACH|EHOSTUNREACH|ENOTFOUND|FETCH_ERROR|E408|E429|E50[0-9]"; const code = [...text.matchAll(/^\s*npm (?:error|ERR!) code (\S+)\s*$/gm)].at(-1)?.[1]; let kind = "other"; if (code) { kind = new RegExp("^(?:" + network + ")$").test(code) ? "network" : ["E404", "ETARGET"].includes(code) ? "missing" : "local"; } else if (new RegExp("^(?:" + network + ")\\b", "m").test(text)) kind = "network"; else if (/^(?:EBUSY|EPERM|EACCES|ENOSPC)\b/m.test(text)) kind = "local"; process.stdout.write(kind); process.exit(0); } const metadata = ["json", "platforms", "integrity"].includes(mode) ? JSON.parse(fs.readFileSync(input, "utf8")) : null; const value = metadata === null ? input : typeof metadata === "string" ? metadata : metadata.version; const parsed = parts(value); if (!parsed) process.exit(1); if (mode === "platforms" || mode === "integrity") { const mirror = JSON.parse(fs.readFileSync(process.argv[5], "utf8")); const expectedName = mode === "platforms" ? "dfcode" : process.argv[6]; if (value !== other || mirror.version !== other || metadata.name !== expectedName || mirror.name !== expectedName || !integrity(metadata) || integrity(mirror) !== integrity(metadata)) process.exit(1); if (mode === "integrity") process.exit(0); if (!metadata.optionalDependencies || typeof metadata.optionalDependencies !== "object") process.exit(1); if (!mirror.optionalDependencies || JSON.stringify(Object.entries(metadata.optionalDependencies).sort()) !== JSON.stringify(Object.entries(mirror.optionalDependencies).sort())) process.exit(1); const prefix = `dfcode-${process.platform}-${process.arch}`; const candidates = Object.entries(metadata.optionalDependencies).filter(([name]) => name === prefix || name.startsWith(`${prefix}-`)); if (!candidates.length || candidates.some(([name, version]) => !/^dfcode-(?:darwin|linux|win32)-(?:x64|arm64)(?:-baseline)?(?:-musl)?$/.test(name) || version !== other)) process.exit(1); process.stdout.write(candidates.map(([name]) => name).join("\n") + "\n"); } else if (mode === "compare") { const second = parts(other); if (!second) process.exit(1); process.stdout.write(String(compare(parsed, second))); } else process.stdout.write(value); } catch { process.exit(1); } NODE_VERSION npm_view_version() { local spec="$1" registry="$2" output="$3" mode="${4:-phase}" rc printf '\n[npm view] %s registry=%s\n' "$spec" "$registry" >>"$NPM_LOG" local timeout="$MIRROR_METADATA_TIMEOUT" fetch_timeout=20000 phase="镜像安全校验 $spec" local fetch_options=("${MIRROR_FETCH_OPTIONS[@]}") if [ "$registry" = "$OFFICIAL_REGISTRY" ] && [ "$mode" = phase ]; then timeout=$(official_remaining) [ "$timeout" -gt 0 ] || { printf '[timeout] official budget exhausted\n' >>"$NPM_LOG"; return 124; } fetch_timeout=$((timeout * 1000)); [ "$fetch_timeout" -le 300000 ] || fetch_timeout=300000 fetch_options=("${OFFICIAL_FETCH_OPTIONS[@]}") phase="官方源版本查询 $spec" fi DFCODE_INSTALL_PHASE="$phase" run_with_timeout "$timeout" npm view "$spec" --json --registry "$registry" --fetch-timeout="$fetch_timeout" "${fetch_options[@]}" "${NPM_OPTIONS[@]}" >"$output" 2>"$LOG_DIR/metadata-attempt.log" rc=$? cat "$LOG_DIR/metadata-attempt.log" >>"$NPM_LOG" printf '[exit] %s\n' "$rc" >>"$NPM_LOG" case "$rc" in 130|143) exit "$rc" ;; esac if [ "$rc" -ne 0 ] && [ "$(node "$VERSION_HELPER" failure "$LOG_DIR/metadata-attempt.log")" = local ]; then tail -n 15 "$LOG_DIR/metadata-attempt.log" >&3 die "版本查询遇到本地文件或运行环境错误,已停止,不会继续换源。完整日志:$NPM_LOG" fi return "$rc" } TARGET_METADATA="$LOG_DIR/target.json" if [ -n "$REQUESTED_VERSION" ]; then TARGET_VERSION="$(node "$VERSION_HELPER" validate "$REQUESTED_VERSION")" \ || die "DFCODE_VERSION 必须是精确版本,例如 3.3.17 或 3.3.18-beta.4。" else case "$DIST_TAG" in latest|stable|beta) ;; *) die "DFCODE_DIST_TAG 只支持 latest、stable、beta。" ;; esac npm_view_version "dfcode@$DIST_TAG" "$OFFICIAL_REGISTRY" "$TARGET_METADATA" \ || die "无法从 npm 官方源确认 ${DIST_TAG} 版本,未尝试安装。完整日志:$NPM_LOG" TARGET_VERSION="$(node "$VERSION_HELPER" json "$TARGET_METADATA")" \ || die "npm 返回的版本无效,未尝试安装。完整日志:$NPM_LOG" fi ok "目标版本:$TARGET_VERSION" TARGET_SPEC="dfcode@$TARGET_VERSION" EXPECTED_VERSION="$TARGET_VERSION" EXISTING_VERSION='' if command -v dfcode >/dev/null 2>&1; then if run_with_timeout 30 dfcode --version >"$LOG_DIR/existing-version.txt" 2>>"$NPM_LOG"; then EXISTING_VERSION="$(node "$VERSION_HELPER" validate "$(cat "$LOG_DIR/existing-version.txt")")" || EXISTING_VERSION='' fi fi SKIP_INSTALL=0 if [ -n "$EXISTING_VERSION" ]; then comparison="$(node "$VERSION_HELPER" compare "$EXISTING_VERSION" "$TARGET_VERSION")" if [ "$comparison" = 0 ] && [ "$EXISTING_VERSION" = "$TARGET_VERSION" ]; then SKIP_INSTALL=1 ok "当前版本已是 $TARGET_VERSION" elif [ "$comparison" = 1 ] && [ -z "$REQUESTED_VERSION" ]; then SKIP_INSTALL=1 EXPECTED_VERSION="$EXISTING_VERSION" ok "保留本机较新版本:$EXISTING_VERSION" fi fi # ---------- 3. 安装同一个精确版本 ---------- say "第 3 步:安装 DFCode" npm_try() { local registry="$1" timeout="$2" rc fetch_timeout=30000 phase="镜像安装 $1" local fetch_options=("${MIRROR_FETCH_OPTIONS[@]}") : >"$LAST_NPM_LOG" if [ "$registry" = "$OFFICIAL_REGISTRY" ]; then timeout=$(official_remaining) if [ "$timeout" -le 0 ]; then printf '[timeout] official budget exhausted\n' >>"$NPM_LOG" LAST_NPM_RC=124 return 124 fi fetch_timeout=$((timeout * 1000)); [ "$fetch_timeout" -le 300000 ] || fetch_timeout=300000 fetch_options=("${OFFICIAL_FETCH_OPTIONS[@]}") phase="官方源安装" fi printf '\n[npm install] %s registry=%s\n' "$TARGET_SPEC" "$registry" >>"$NPM_LOG" DFCODE_INSTALL_PHASE="$phase" run_with_timeout "$timeout" npm install -g "$TARGET_SPEC" --include=optional --registry "$registry" --fetch-timeout="$fetch_timeout" "${fetch_options[@]}" "${NPM_OPTIONS[@]}" ${PREFIX_OPTIONS[@]+"${PREFIX_OPTIONS[@]}"} >"$LAST_NPM_LOG" 2>&1 rc=$? LAST_NPM_RC=$rc cat "$LAST_NPM_LOG" >>"$NPM_LOG" printf '[exit] %s\n' "$rc" >>"$NPM_LOG" case "$rc" in 130|143) exit "$rc" ;; esac return "$rc" } network_failure() { local kind kind=$(node "$VERSION_HELPER" failure "$LAST_NPM_LOG") [ "$kind" = network ] || { [ "$kind" = other ] && [ "$LAST_NPM_RC" -eq 124 ]; } } stop_local_failure() { if [ "$(node "$VERSION_HELPER" failure "$LAST_NPM_LOG")" = local ]; then warn "安装未完成:本地文件占用、权限或磁盘空间问题,切换镜像无法解决。" install_failed fi } mirror_platforms_ready() { local registry="$1" mirror_metadata="$2" # 镜像可能已有 dfcode 主包,但缺 launcher 按 CPU/libc 选择的平台包。 # Always compare against independent official metadata, never the mirror itself. local baseline="$TARGET_METADATA" if [ ! -s "$baseline" ]; then npm_view_version "$TARGET_SPEC" "$OFFICIAL_REGISTRY" "$baseline" verify || return 1 fi node "$VERSION_HELPER" platforms "$baseline" "$TARGET_VERSION" "$mirror_metadata" >"$LOG_DIR/platforms.txt" || return 1 local package while IFS= read -r package; do npm_view_version "$package@$TARGET_VERSION" "$OFFICIAL_REGISTRY" "$LOG_DIR/official-platform.json" verify || return 1 npm_view_version "$package@$TARGET_VERSION" "$registry" "$LOG_DIR/mirror-platform.json" || return 1 node "$VERSION_HELPER" integrity "$LOG_DIR/official-platform.json" "$TARGET_VERSION" "$LOG_DIR/mirror-platform.json" "$package" || return 1 done <"$LOG_DIR/platforms.txt" } use_user_prefix() { # 只改变本次安装位置,不改 ~/.npmrc 中影响其它 Node 环境的全局 prefix。 USER_PREFIX=1 local prefix="$HOME/.npm-global" local existing="$prefix/lib/node_modules/dfcode" if [ "${1:-}" = "clean" ] || { [ -e "$existing" ] && [ ! -w "$existing" ]; }; then prefix="$HOME/.local/share/dfcode/npm-global" warn "检测到旧 DFCode 安装目录不可写,改用独立用户目录:$prefix" fi mkdir -p "$prefix/bin" || die "无法创建用户安装目录:$prefix" USER_PREFIX_PATH="$prefix" PREFIX_OPTIONS=(--prefix "$prefix") export PATH="$prefix/bin:$PATH" hash -r 2>/dev/null || true warn "使用用户目录安装:$prefix" } install_failed() { if [ -n "$EXISTING_VERSION" ]; then warn "目标 $TARGET_VERSION 安装未完成,请先运行 dfcode --version 检查原有安装。" fi if grep -Eq 'compatible dfcode platform package|expected [^,]+, found ' "$NPM_LOG"; then warn "已有 DFCode 平台包版本不一致,安装器已尝试独立用户目录;请查看完整日志。" fi if grep -Eq 'EEXIST.*cache|Invalid response body.*cache|permission denied.*_cacache' "$NPM_LOG"; then warn "检测到 npm 缓存不可用;本次安装使用了独立缓存,请保留日志供排查。" fi tail -n 15 "$NPM_LOG" 2>/dev/null || true die "DFCode 安装失败。完整日志:$NPM_LOG" } if [ "$SKIP_INSTALL" -eq 0 ]; then done_flag=0 # A previous sudo install may leave a root-owned package tree in the # configured user prefix. Avoid invoking npm there and use a clean prefix. for stale in "$HOME/.npm-global/lib/node_modules/dfcode" "$HOME/.npm-global/node_modules/dfcode"; do if [ -e "$stale" ] && [ ! -w "$stale" ]; then use_user_prefix break fi done if [ "$USER_PREFIX" -eq 0 ]; then configured_prefix="$(npm prefix -g 2>/dev/null || true)" configured_tree="$configured_prefix/lib/node_modules/dfcode" if [ -n "$configured_prefix" ] && [ -e "$configured_tree" ] && [ ! -w "$configured_tree" ]; then use_user_prefix fi fi say "从 npm 官方源安装 $TARGET_VERSION ..." npm_try "$OFFICIAL_REGISTRY" "$INSTALL_TIMEOUT" && done_flag=1 if [ "$done_flag" -eq 0 ] && [ "$USER_PREFIX" -eq 0 ] && grep -Eq 'compatible dfcode platform package|expected [^,]+, found ' "$LAST_NPM_LOG"; then use_user_prefix clean npm_try "$OFFICIAL_REGISTRY" "$INSTALL_TIMEOUT" && done_flag=1 fi if [ "$done_flag" -eq 0 ] && [ "$USER_PREFIX" -eq 0 ] && grep -q 'EACCES' "$LAST_NPM_LOG"; then use_user_prefix npm_try "$OFFICIAL_REGISTRY" "$INSTALL_TIMEOUT" && done_flag=1 fi if [ "$done_flag" -eq 0 ]; then stop_local_failure if network_failure && [ "$LAST_NPM_RC" -ne 124 ] && [ "$(official_remaining)" -gt 0 ]; then warn "官方源暂时出现网络错误,将在剩余时间内重试一次;不会重新计算 10 分钟。" npm_try "$OFFICIAL_REGISTRY" "$INSTALL_TIMEOUT" && done_flag=1 fi fi if [ "$done_flag" -eq 0 ]; then stop_local_failure if ! network_failure; then warn "安装遇到非网络错误,未切换镜像。请检查下面的 npm 错误。" install_failed fi warn "官方源安装失败或超时,按顺序检查华为云、腾讯云镜像的同一版本 $TARGET_VERSION ..." mirror_index=0 for mirror in "${MIRROR_REGISTRIES[@]}"; do mirror_index=$((mirror_index + 1)) mirror_metadata="$LOG_DIR/mirror-${mirror_index}.json" if ! npm_view_version "$TARGET_SPEC" "$mirror" "$mirror_metadata"; then warn "镜像 $mirror 暂时不可达,继续下一个保底源。" continue fi MIRROR_VERSION="$(node "$VERSION_HELPER" json "$mirror_metadata")" || MIRROR_VERSION='' if [ "$MIRROR_VERSION" != "$TARGET_VERSION" ]; then warn "镜像 $mirror 没有目标版本 $TARGET_VERSION,继续下一个保底源。" continue fi if ! mirror_platforms_ready "$mirror" "$mirror_metadata"; then warn "镜像 $mirror 的目标版本或本机平台包未完整同步,继续下一个保底源。" continue fi say "从镜像 $mirror 安装 $TARGET_VERSION ..." npm_try "$mirror" "$MIRROR_INSTALL_TIMEOUT" && done_flag=1 if [ "$done_flag" -eq 0 ] && [ "$USER_PREFIX" -eq 0 ] && grep -q 'EACCES' "$LAST_NPM_LOG"; then use_user_prefix npm_try "$mirror" "$MIRROR_INSTALL_TIMEOUT" && done_flag=1 fi if [ "$done_flag" -eq 0 ]; then stop_local_failure network_failure || install_failed fi [ "$done_flag" -eq 1 ] && break done fi [ "$done_flag" -eq 1 ] || install_failed fi # ---------- 4. 校验实际可运行的版本 ---------- say "第 4 步:校验" hash -r 2>/dev/null || true command -v dfcode >/dev/null 2>&1 || die "dfcode 不在 PATH。请检查 npm 安装目录;完整日志:$NPM_LOG" run_with_timeout 30 dfcode --version >"$LOG_DIR/installed-version.txt" 2>>"$NPM_LOG" DF_RC=$? DF_VER="$(cat "$LOG_DIR/installed-version.txt")" if [ "$DF_RC" -ne 0 ] || ! node "$VERSION_HELPER" validate "$DF_VER" >/dev/null; then printf '%s\n' "$DF_VER" die "DFCode 无法正常运行或版本输出无效(退出码 ${DF_RC})。完整日志:$NPM_LOG" fi [ "$DF_VER" = "$EXPECTED_VERSION" ] \ || die "DFCode 版本不符:应为 ${EXPECTED_VERSION},实际为 ${DF_VER}。请检查 PATH 中的旧安装。完整日志:$NPM_LOG" if [ "$USER_PREFIX" -eq 1 ]; then profile="$(login_profile)" if ! grep -Fq "$USER_PREFIX_PATH/bin" "$profile" 2>/dev/null; then # shellcheck disable=SC2016 printf '\n# added by DFCode installer\nexport PATH="%s/bin:$PATH"\n' "$USER_PREFIX_PATH" >>"$profile" \ || die "安装已完成,但无法写入 ${profile}。请将 $USER_PREFIX_PATH/bin 加入 PATH。" fi warn "请执行 source ${profile},或重开终端后使用 dfcode。" fi ok "DFCode 版本:$DF_VER" echo "" echo "============================================================" ok "环境就绪!下一步:" if [ "$NODE_VIA_NVM" -eq 1 ]; then warn "Node 由 nvm 安装:请先执行 source $(printf '%q' "$NVM_PROFILE"),或重开终端后再用 dfcode。" fi echo " 1) 进入你的项目目录:cd /path/to/your/project" echo " 2) 启动: dfcode" echo " 3) 输入 /login,在企业门户完成登录和设备绑定" echo " 4) 输入 /models 选择模型,用 /effort 调整推理深度" echo " 5) 输入 /init,生成或更新项目的 AGENTS.md" echo " 安装日志:$NPM_LOG" echo "============================================================"