diff --git a/crews/main/HEARTBEAT.md b/crews/main/HEARTBEAT.md index 54b0f3b5..78fb9d3d 100644 --- a/crews/main/HEARTBEAT.md +++ b/crews/main/HEARTBEAT.md @@ -26,7 +26,7 @@ 任何平台的取数端登录失效(`SESSION_EXPIRED` / 探活失败 / 浏览器跳登录页 / `get-xhs-user-id.sh` exit 2 等)时,**必须**: - 立即**跳过该平台**本轮取数,不再尝试任何取数动作; - - 把平台名记入 `EXPIRED_PLATFORMS`,在 Step 5 统一汇报,由用户**白天**用 login-manager 重新登录; + - 把平台名记入 `EXPIRED_PLATFORMS`,在 Step 5 统一汇报,由用户**白天**重新登录; - **不得**在凌晨心跳里扫码登录、不得唤醒用户。 **严禁的"硬行恢复"动作**(任一都可能触发平台风控/限流/封号): @@ -106,7 +106,7 @@ ##### 通用规则 - **必须传 `--id `**(脚本类平台):`` 取自 Step 1 查询结果里的 `id` 字段。同一 `source_folder` 可能对应多条记录(同内容重复发布到不同帖子),按 `--id` 逐条抓取/写库才能让每次发布各自独立统计;若只传 `--source-folder`,脚本会只抓一行指标却批量写进所有同 folder 行,造成重复发布之间互相污染。 -- **SESSION_EXPIRED**:脚本返回 `ok=false, error=SESSION_EXPIRED`(exit 2)时,**跳过该平台**本轮取数,记入 `EXPIRED_PLATFORMS`,Step 5 统一汇报,由用户白天用 login-manager 重新登录。**凌晨不唤醒用户、不扫码登录、不私拉会话**(见约束 4/5)。 +- **SESSION_EXPIRED**:脚本返回 `ok=false, error=SESSION_EXPIRED`(exit 2)时,**跳过该平台**本轮取数,记入 `EXPIRED_PLATFORMS`,Step 5 统一汇报,由用户白天重新登录。**凌晨不唤醒用户、不扫码登录、不私拉会话**(见约束 4/5)。 - **xhs 风控显著高于其他平台**:xhs 任何登录失效迹象 → 立刻整段跳过 xhs,不尝试任何恢复。取数只走 `xhs-browse`,**禁止**探测/使用 `xhs-publish` creator 域 cookie。 - **⛔ 取数失败时必须原样报告脚本 stderr + exit code,禁止自行归因**:脚本的 stderr 是排查的唯一可靠依据。Agent 不得根据 DB 字段(如 `publish_url` 是否为空)脑补错误原因、不得改写/概括 stderr 成自己的话。例:`wx-mp-engagement fetch` exit 1 stderr=`error: 发表记录页未找到标题匹配的 row id=3`,就报这个原文,不要脑补成 "publish_url 无效"。错误归因错误会误导排查方向。 @@ -180,9 +180,7 @@ > ⚠️ 以下**取数端**Cookie 已失效,数据未能更新。请白天使用 login-manager 技能重新登录: > - douyin(抖音) > - xhs-browse(小红书浏览端) - > - wechat-channel(微信视频号) - > - > 列出的名字即 `login-manager login ` 要用的平台名(非 published-track 的 `xhs`)。 + > - wechat-channel(微信视频号) > > **只报告取数端 cookie**。**不要报告、也不要探测 `xhs-publish`(小红书发布端 / creator.xiaohongshu.com)**: > 复盘/取数完全不依赖发布端 cookie,探测它只会给 creator 域增加风控概率且结论与取数无关。 diff --git a/scripts/install-atomgit.ps1 b/scripts/install-atomgit.ps1 index 4bbee92e..1e19e71a 100755 --- a/scripts/install-atomgit.ps1 +++ b/scripts/install-atomgit.ps1 @@ -1,45 +1,56 @@ -# install-atomgit.ps1 - wiseflow 一键首装脚本(Windows,预构建 tarball 路线,atomgit 国内镜像专线) +# install-atomgit.ps1 - wiseflow one-click install script (Windows, prebuilt tarball route, atomgit mirror) # -# 与 install.ps1 区别:本脚本走 atomgit 国内镜像(tarball 走 atomgit.com → GitCode CDN, -# tag 解析走 api.atomgit.com/api/v5),不经 GitHub;适合国内网络环境。 -# 为对 `irm | iex` 友好(小白一条命令跑通),去掉 [CmdletBinding]param() 头, -# 所有可选参数走环境变量: -# $env:XIAOBEI_HOME 程序目录覆盖(默认 ~\xiaobei) -# $env:XIAOBEI_TAG 指定 release tag(默认拉最新) -# $env:XIAOBEI_TARBALL 本地已下好的 tarball 路径,跳过下载 -# $env:XIAOBEI_FORCE =1 强覆盖已有运行数据(~\.openclaw) -# $env:XIAOBEI_SKIP_BIND =1 跳过末尾微信扫码绑定 -# $env:XIAOBEI_SKIP_BROWSER=1 跳过 camoufox-cli 浏览器二进制(冒烟/CI) -# $env:XIAOBEI_NO_PROMPT =1 跳过所有交互提示(CI/自动化) +# Difference from install.ps1: this script uses the atomgit mirror (tarball via atomgit.com -> +# GitCode CDN, tag resolution via api.atomgit.com/api/v5), bypassing GitHub; suitable for networks +# inside mainland China. GitHub-accessible users should use install.ps1 (GitHub direct) instead. +# To be friendly to `irm | iex` (so a one-liner works for beginners), the [CmdletBinding]param() +# header is removed; all optional params go through environment variables: +# $env:XIAOBEI_HOME Program directory override (default ~\xiaobei) +# $env:XIAOBEI_TAG Specify a release tag (default pulls latest) +# $env:XIAOBEI_TARBALL Path to a locally downloaded tarball, skips download +# $env:XIAOBEI_FORCE =1 force overwrite existing runtime data (~\.openclaw) +# $env:XIAOBEI_SKIP_BIND =1 skip the WeChat scan-to-bind step at the end +# $env:XIAOBEI_SKIP_BROWSER=1 skip camoufox-cli browser binary (smoke/CI) +# $env:XIAOBEI_NO_PROMPT =1 skip all interactive prompts (CI/automation) # -# 用法(PowerShell,需 Git Bash 或 WSL): +# Usage (PowerShell, requires Git Bash or WSL): # irm https://raw.atomgit.com/wiseflow/xiaobei/raw/master/scripts/install-atomgit.ps1 | iex -# # 或本地: +# # or locally: # powershell -ExecutionPolicy Bypass -File install-atomgit.ps1 # -# ⚠️ 本文件必须保持 UTF-8 **带 BOM**:Windows PowerShell 5.1 对无 BOM 的 .ps1 按系统 -# ANSI 代码页(中文系统 GBK)解析,中文字符串会乱码并产生解析错误,脚本无法运行。 +# WARNING: this file MUST be kept UTF-8 **without BOM**: under `irm | iex`, irm does not strip +# the BOM, and iex treats the 3 BOM bytes as the first chars of a command name, so the first +# line `#` is parsed as an unknown command and errors (though it does not block the install). +# The local `powershell -File` scenario also works fine without BOM (PowerShell 5.1+ defaults +# to UTF-8 for parsing). All comments in this file are in English (ASCII) to avoid GBK/UTF-8 +# mojibake parsing errors under Windows PowerShell 5.1 with a non-BOM file. # -# 与 install.sh 同构(方案 B 瘦 tarball): -# 1. 拉 xiaobei-{tag}-win-x64.tar.gz(atomgit CDN,Windows bsdtar 原生支持 gzip,免装 zstd) -# 2. 解压到 $XIAOBEI_HOME(默认 $env:USERPROFILE\xiaobei,程序目录) -# 3. portable node + pnpm install --prod --frozen-lockfile(在 openclaw\ 下) -# 4. pip install --user(skills 的 python deps,有 python 才跑) -# 5. 放 config-templates\openclaw.json → $OPENCLAW_HOME\openclaw.json + 预填微信 binding -# 6. setup-crew.sh(需 bash:Git Bash 或 WSL;无则警告并跳过,用户后续手动跑) -# 7. camoufox-cli:.cmd shim + camoufox-cli install 下 Firefox -# 8. openclaw-weixin 插件:openclaw plugins install ... --pin(npmmirror) -# 9. 交互问 AWK_API_KEY → 写 daemon.env + setx 用户环境变量 → 尝试 openclaw daemon install +# Structurally identical to install.sh (Plan B slim tarball): +# 1. Pull xiaobei-{tag}-win-x64.tar.gz (atomgit CDN; Windows bsdtar natively supports gzip, no +# need to install zstd) +# 2. Extract to $XIAOBEI_HOME (default $env:USERPROFILE\xiaobei, the program directory) +# 3. portable node + pnpm install --prod --frozen-lockfile (under openclaw\) +# 4. pip install --user (python deps for skills; only runs if python is present) +# 5. Place config-templates\openclaw.json -> $OPENCLAW_HOME\openclaw.json + prefill WeChat binding +# 6. setup-crew.sh (requires bash: Git Bash or WSL; if absent, warn and skip; user can run it +# manually later) +# 7. camoufox-cli: .cmd shim + camoufox-cli install downloads Firefox +# 8. openclaw-weixin plugin: openclaw plugins install ... --pin (npmmirror) +# 9. Interactive prompt for AWK_API_KEY -> write daemon.env + setx user env var -> attempt +# openclaw daemon install # -# 目录职责:$XIAOBEI_HOME(~\xiaobei)= 程序;$OPENCLAW_HOME(~\.openclaw)= 运行数据。 -# Windows 原生 wrapper:$XIAOBEI_HOME\bin\openclaw.cmd(WSL/Git Bash 用户也可用 bin\openclaw)。 +# Directory responsibilities: $XIAOBEI_HOME (~\xiaobei) = program; $OPENCLAW_HOME (~\.openclaw) +# = runtime data. +# Windows native wrapper: $XIAOBEI_HOME\bin\openclaw.cmd (WSL/Git Bash users can also use bin\openclaw). $ErrorActionPreference = "Stop" -# ─── 常量 / 目录 ─────────────────────────────────────────────── -# atomgit 专线常量(host/版本与 GitHub 不同,不依赖运行时开关): -# tarball 直链 host = atomgit.com(redirect 到 file-cdn.gitcode.com 签名 CDN,匿名 GET 可下,~140MB) -# 解 latest tag 走 api.atomgit.com/api/v5(NOT Gitea v1,host/版本都不同) +# --- Constants / directories --- +# atomgit mirror constants (host/version differ from GitHub, do not depend on runtime switches): +# tarball direct host = atomgit.com (redirects to a signed file-cdn.gitcode.com CDN; anonymous +# GET works, ~140MB) +# latest tag resolution goes through api.atomgit.com/api/v5 (NOT Gitea v1; both host and version +# differ) $Repo = "wiseflow/xiaobei" $AtomgitMirror = "https://atomgit.com/wiseflow/xiaobei" $AtomgitApi = "https://api.atomgit.com/api/v5/repos/wiseflow/xiaobei" @@ -47,7 +58,7 @@ $AtomgitApi = "https://api.atomgit.com/api/v5/repos/wiseflow/xiaobei" $Root = if ($env:XIAOBEI_HOME) { $env:XIAOBEI_HOME } else { Join-Path $env:USERPROFILE "xiaobei" } $OpenclawHome = if ($env:OPENCLAW_HOME) { $env:OPENCLAW_HOME } else { Join-Path $env:USERPROFILE ".openclaw" } -# 行为开关(环境变量,=1 启用) +# Behavior switches (env vars, =1 to enable) $Force = ($env:XIAOBEI_FORCE -eq "1" -or $env:XIAOBEI_FORCE -eq "true") $SkipBind = ($env:XIAOBEI_SKIP_BIND -eq "1" -or $env:XIAOBEI_SKIP_BIND -eq "true") $SkipBrowser = ($env:XIAOBEI_SKIP_BROWSER -eq "1" -or $env:XIAOBEI_SKIP_BROWSER -eq "true") @@ -64,10 +75,12 @@ function Write-Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor G function Write-Warn([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow } function Write-Err([string]$msg) { Write-Host " [X] $msg" -ForegroundColor Red } -# 跑原生命令并按行回显 stdout+stderr。pip/npm/camoufox-cli/openclaw 正常会把进度和警告写 stderr, -# 而本脚本顶部 $ErrorActionPreference="Stop" 会把 2>&1 合并进管道的 stderr 行当 terminating error -# 抛 NativeCommandError/RemoteException(用户 Win 实测卡在 pip install 这步)。这里临时切到 Continue -# 跑原生命令,$LASTEXITCODE 仍由调用方据以判成败。 +# Run a native command and echo stdout+stderr line by line. pip/npm/camoufox-cli/openclaw normally +# write progress and warnings to stderr, and the $ErrorActionPreference="Stop" at the top of this +# script would treat stderr lines merged via 2>&1 as terminating errors, throwing +# NativeCommandError/RemoteException (a user's Windows run actually got stuck at pip install this +# way). Here we temporarily switch to Continue to run the native command; $LASTEXITCODE is still +# used by the caller to judge success/failure. function Invoke-Streamed([scriptblock]$sb) { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' @@ -75,8 +88,9 @@ function Invoke-Streamed([scriptblock]$sb) { finally { $ErrorActionPreference = $prev } } -# 同 Invoke-Streamed,但把 stdout+stderr 合并成字符串返回(供调用方 -&match)。`2>$null` 在 -# Windows PowerShell 5.1 + Stop 下压不住 .cmd 的 stderr 仍抛 NativeCommandError,这里用 Continue + 2>&1 纳。 +# Same as Invoke-Streamed, but merges stdout+stderr into a single string and returns it (for callers +# that need to -match against the output). `2>$null` under Windows PowerShell 5.1 + Stop does not +# suppress a .cmd's stderr and still throws NativeCommandError; using Continue + 2>&1 is stable. function Capture-Streamed([scriptblock]$sb) { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' @@ -84,123 +98,152 @@ function Capture-Streamed([scriptblock]$sb) { finally { $ErrorActionPreference = $prev } } -# ─── 1. 解析最新 release tag(atomgit v5 API)──────────────────── +# --- 1. Resolve latest release tag (atomgit v5 API) --- function Resolve-Tag { if ($env:XIAOBEI_TAG) { return $env:XIAOBEI_TAG } try { $rel = Invoke-RestMethod "$AtomgitApi/releases/latest" -Headers @{ "User-Agent" = "xiaobei-install" } if ($rel.tag_name) { return $rel.tag_name } - } catch { Write-Warn "atomgit v5 API 拉取失败,请手动设 `$env:XIAOBEI_TAG=` 后重跑" } - throw "无法解析最新 release tag(atomgit v5 API)" + } catch { Write-Warn "atomgit v5 API fetch failed, please set `$env:XIAOBEI_TAG=` manually and re-run" } + throw "unable to resolve the latest release tag (atomgit v5 API)" } -# ─── 2. 下载 tarball(atomgit CDN)────────────────────────────── +# --- 2. Download tarball (atomgit CDN) --- +# Version-level cache: same tag + plat re-uses ~/.xiaobei/cache/, +# skipping the 120MB re-download on rerun (incl. failed retry). +# Atomic write: download to .part first, then Move-Item -Force to , +# so a broken network never leaves a half-written file that the next run would +# mistake for a complete cache hit. function Download-Tarball([string]$tag) { $asset = "xiaobei-$tag-win-x64.tar.gz" if ($env:XIAOBEI_TARBALL -and (Test-Path $env:XIAOBEI_TARBALL)) { - Write-Ok "用本地 tarball:$env:XIAOBEI_TARBALL" + Write-Ok "using local tarball: $env:XIAOBEI_TARBALL" return $env:XIAOBEI_TARBALL } - $tmp = New-TemporaryFile + + $cacheDir = if ($env:XIAOBEI_CACHE_DIR) { $env:XIAOBEI_CACHE_DIR } else { Join-Path $env:USERPROFILE ".xiaobei\cache" } + $cached = Join-Path $cacheDir $asset + + if (Test-Path $cached -PathType Leaf) { + Write-Ok "using cached tarball: $cached" + return $cached + } + $url = "$AtomgitMirror/releases/download/$tag/$asset" - Write-Host " 下载 $url" - Invoke-WebRequest -Uri $url -OutFile $tmp -Headers @{ "User-Agent" = "xiaobei-install" } - return $tmp.FullName + Write-Host " downloading $url" + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + $part = "$cached.part" + if (Test-Path $part) { Remove-Item $part -Force } + try { + Invoke-WebRequest -Uri $url -OutFile $part -Headers @{ "User-Agent" = "xiaobei-install" } + Move-Item -Path $part -Destination $cached -Force + } catch { + if (Test-Path $part) { Remove-Item $part -Force } + throw "download failed: $url`n$($_.Exception.Message)" + } + return $cached } -# ─── 3. 解压 ─────────────────────────────────────────────────── +# --- 3. Extract --- function Extract-Tarball([string]$tarball) { if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - throw "未找到 tar(Windows 10 1803+ 自带 bsdtar;请升级 Windows 或装 Git Bash)" + throw "tar not found (Windows 10 1803+ ships bsdtar; please upgrade Windows or install Git Bash)" } New-Item -ItemType Directory -Force -Path $Root | Out-Null - Write-Host " 解压到 $Root" + Write-Host " extracting to $Root" & tar -xzf $tarball -C $Root - if ($LASTEXITCODE -ne 0) { throw "tar 解压失败 (exit $LASTEXITCODE)" } + if ($LASTEXITCODE -ne 0) { throw "tar extraction failed (exit $LASTEXITCODE)" } } -# ─── 4. pnpm install --prod ──────────────────────────────────── +# --- 4. pnpm install --prod --- function Install-Deps { Write-Stage "Installing dependencies (pnpm install --prod)" $openclawDir = Join-Path $Root "openclaw" - if (-not (Test-Path $NodeExe)) { throw "portable node 未找到:$NodeExe" } + if (-not (Test-Path $NodeExe)) { throw "portable node not found: $NodeExe" } Push-Location $openclawDir try { - # postinstall 子进程(esbuild/protobufjs 等)按 PATH 找 node;目标机器多半没装全局 - # Node('node' is not recognized 实测),把 portable node 前置进 PATH + # postinstall subprocesses (esbuild/protobufjs etc.) look up node via PATH; the target + # machine most likely has no global Node installed ('node' is not recognized, observed in + # practice), so we prepend the portable node directory to PATH. $env:PATH = "$(Split-Path $NodeExe);$env:PATH" - # pnpm 11 不认 npm_config_registry 环境变量(实测仍走 registry.npmjs.org),用显式 --registry + # pnpm 11 does not honor the npm_config_registry env var (in practice it still hits + # registry.npmjs.org), so we pass an explicit --registry. $env:npm_config_registry = "https://registry.npmmirror.com" & $NodeExe $PnpmMjs install --prod --frozen-lockfile --registry https://registry.npmmirror.com - if ($LASTEXITCODE -ne 0) { throw "pnpm install 失败 (exit $LASTEXITCODE)" } + if ($LASTEXITCODE -ne 0) { throw "pnpm install failed (exit $LASTEXITCODE)" } Write-Ok "deps installed" } finally { Pop-Location } } -# ─── 5. python skill deps ────────────────────────────────────── +# --- 5. python skill deps --- function Install-PythonDeps { Write-Stage "Installing python skill deps" $py = Get-Command python -ErrorAction SilentlyContinue - if (-not $py) { Write-Warn "未找到 python,跳过 skill python deps"; return } + if (-not $py) { Write-Warn "python not found, skipping skill python deps"; return } $reqs = @() $reqs += Get-ChildItem -Path (Join-Path $Root "skills") -Filter "requirements.txt" -Recurse -ErrorAction SilentlyContinue $reqs += Get-ChildItem -Path (Join-Path $Root "crews") -Filter "requirements.txt" -Recurse -ErrorAction SilentlyContinue $reqs += Get-ChildItem -Path $Root -Filter "requirements.txt" -ErrorAction SilentlyContinue $reqs = $reqs | Select-Object -ExpandProperty FullName -Unique - if (-not $reqs) { Write-Ok "无 requirements.txt"; return } - # 各行作为 pip 命令行参数传入(非 -r 文件),必须剥掉行内注释(pip 只在 - # requirements 文件里认注释,参数里 `Pillow # 说明` 直接 Invalid requirement 报错); - # 文件是 UTF-8,显式指定编码防中文注释被 ANSI 误读产生乱码参数。 + if (-not $reqs) { Write-Ok "no requirements.txt"; return } + # Each line is passed as a pip command-line argument (not via -r file), so inline comments must + # be stripped (pip only recognizes comments inside a requirements file; `Pillow # desc` passed + # as an arg directly errors with Invalid requirement). The file is UTF-8; we explicitly specify + # the encoding to prevent Chinese comments from being misread as ANSI and producing garbled args. $merged = ($reqs | ForEach-Object { Get-Content $_ -Encoding UTF8 -ErrorAction SilentlyContinue } | ForEach-Object { ($_ -replace '#.*$', '').Trim() } | Where-Object { $_ } | Sort-Object -Unique) - if (-not $merged) { Write-Ok "无 python 依赖"; return } + if (-not $merged) { Write-Ok "no python deps"; return } Invoke-Streamed { python -m pip install --user $merged } - if ($LASTEXITCODE -ne 0) { Write-Warn "pip install 非零退出;可后续手动补装 skills 的 python 依赖" } + if ($LASTEXITCODE -ne 0) { Write-Warn "pip install returned non-zero; you can manually install the skills' python deps later" } else { Write-Ok "python deps installed" } } -# ─── 6. 放 config template(对齐 install.sh place_config_template)──── -# template 已预置 channels.openclaw-weixin / bindings / session.dmScope,不再运行时 mutate -# (sh 版曾因运行时 mutate 把 plugins 顶层写坏致 "Invalid input",已同构移除)。 -# 健康检查:现有 config 缺 models/agents.defaults(多半是 Install-WeixinPlugin 先跑时 -# openclaw plugins install 自建的极简 config,或 openclaw 首启自动生成)→ 备份后用 -# template 覆盖,否则 channels/bindings 永远缺失、小贝起不来。 +# --- 6. Place config template (mirrors install.sh place_config_template) --- +# The template already pre-populates channels.openclaw-weixin / bindings / session.dmScope; we no +# longer mutate it at runtime (the sh version once broke the plugins top-level via runtime mutation, +# causing "Invalid input"; that mutation has been removed in parallel here). +# Health check: if the existing config is missing models/agents.defaults (most likely a minimal config +# auto-created by `openclaw plugins install` when Install-WeixinPlugin ran first, or by openclaw's +# first launch), back it up and overwrite with the template; otherwise channels/bindings would be +# missing forever and xiaobei could never start. function Place-Config { Write-Stage "Placing config template" New-Item -ItemType Directory -Force -Path $OpenclawHome | Out-Null $cfg = Join-Path $OpenclawHome "openclaw.json" $tmpl = Join-Path $Root "config-templates\openclaw.json" - if (-not (Test-Path $tmpl)) { Write-Err "config template 缺失:$tmpl(tarball 损坏?)"; return } + if (-not (Test-Path $tmpl)) { Write-Err "config template missing: $tmpl (tarball corrupted?)"; return } $needPlace = $false; $reason = "" - if (-not (Test-Path $cfg)) { $needPlace = $true; $reason = "不存在" } + if (-not (Test-Path $cfg)) { $needPlace = $true; $reason = "not present" } elseif ($Force) { $needPlace = $true; $reason = "XIAOBEI_FORCE=1" } else { try { - # 必须显式按 UTF-8 读:openclaw 写的 config 是 UTF-8 无 BOM,PS 5.1 的 - # Get-Content 默认按 ANSI 代码页读,中文路径旁的 \\ 转义会被双字节吞掉, - # 有效 config 被误判"解析失败"遭覆盖(本机实测)。 + # Must explicitly read as UTF-8: openclaw writes its config as UTF-8 without BOM, while + # PS 5.1's Get-Content defaults to the ANSI code page; a \\ escape next to a Chinese + # path can get swallowed by a double-byte char, causing a valid config to be misjudged + # as "parse failed" and overwritten (observed in practice). $j = [System.IO.File]::ReadAllText($cfg) | ConvertFrom-Json -ErrorAction Stop - if (-not ($j.models -and $j.agents -and $j.agents.defaults)) { $needPlace = $true; $reason = "缺 models/agents.defaults(疑似被极简化)" } - } catch { $needPlace = $true; $reason = "解析失败" } + if (-not ($j.models -and $j.agents -and $j.agents.defaults)) { $needPlace = $true; $reason = "missing models/agents.defaults (likely minimized)" } + } catch { $needPlace = $true; $reason = "parse failed" } } if ($needPlace) { if (Test-Path $cfg) { $bak = "$cfg.bak.$([int][double]::Parse((Get-Date -UFormat %s)))" Copy-Item $cfg $bak - Write-Warn "openclaw.json $reason → 用 template 覆盖(旧文件备份到 $bak)" + Write-Warn "openclaw.json $reason -> overwriting with template (old file backed up to $bak)" } Copy-Item $tmpl $cfg -Force Write-Ok "placed openclaw.json template" } else { - Write-Ok "openclaw.json 已存在且健康(有 models + agents.defaults),保留" + Write-Ok "openclaw.json already present and healthy (has models + agents.defaults), keeping" } - # 把 plugins.load.paths 里的 ${XIAOBEI_HOME} env ref 解析成绝对路径,避免 CLI 上下文没 - # XIAOBEI_HOME 时 "plugin path not found" 误报;AWK_API_KEY 是 secret 保持 env ref 不动。 - # 文本级替换(template 中 ${XIAOBEI_HOME} 仅出现在 plugins.load.paths);JSON 字符串里 - # 反斜杠是转义符,路径统一写正斜杠。幂等:已解析则 no-op。 + # Resolve the ${XIAOBEI_HOME} env ref inside plugins.load.paths to an absolute path, to avoid a + # "plugin path not found" false alarm when the CLI context lacks XIAOBEI_HOME; AWK_API_KEY is a + # secret so its env ref is left untouched. This is a file-level replacement (the ${XIAOBEI_HOME} + # template token only appears in plugins.load.paths); inside a JSON string a backslash is an + # escape char, so we normalize paths to forward slashes. Idempotent: a no-op if already resolved. $raw = [System.IO.File]::ReadAllText($cfg) if ($raw.Contains('${XIAOBEI_HOME}')) { $rootFwd = $Root -replace '\\', '/' @@ -209,10 +252,11 @@ function Place-Config { } } -# ─── 7. setup-crew(需 bash)────────────────────────────────── -# 找一个真正可用的 bash:优先 Git Bash(吃得了 C:/ 魔格路径);PATH 里的 bash 可能是 -# C:\Windows\system32\bash.exe(WSL 启动器)——没装发行版时直接报错退出,且 WSL bash -# 对 Windows 路径参数不做转换,都不能直接用。每个候选实测 `bash -c echo` 通过才算数。 +# --- 7. setup-crew (requires bash) --- +# Find a genuinely usable bash: prefer Git Bash (it handles C:/ style paths well); the bash on PATH +# might be C:\Windows\system32\bash.exe (the WSL launcher) which errors out immediately when no distro +# is installed, and WSL bash does not convert Windows path arguments either, so neither can be used +# directly. Each candidate is actually tested with `bash -c echo` before being accepted. function Find-WorkingBash { $candidates = @() $git = Get-Command git -ErrorAction SilentlyContinue @@ -224,7 +268,8 @@ function Find-WorkingBash { $candidates += "$env:ProgramFiles\Git\bin\bash.exe" $candidates += "${env:ProgramFiles(x86)}\Git\bin\bash.exe" $inPath = Get-Command bash -ErrorAction SilentlyContinue - # system32 的 WSL 启动器排最后(仅当其可用且无 Git Bash 时兜底) + # The system32 WSL launcher goes last (only used as a fallback when it is available and no Git + # Bash exists). if ($inPath) { $candidates += $inPath.Source } foreach ($b in $candidates) { if ($b -and (Test-Path $b)) { @@ -238,60 +283,77 @@ function Find-WorkingBash { function Run-SetupCrew { Write-Stage "Setting up crew templates (needs bash)" $sh = Join-Path $Root "scripts\setup-crew.sh" - if (-not (Test-Path $sh)) { Write-Warn "setup-crew.sh 不在 tarball 内,跳过"; return } + if (-not (Test-Path $sh)) { Write-Warn "setup-crew.sh is not inside the tarball, skipping"; return } $bashExe = Find-WorkingBash if (-not $bashExe) { - Write-Warn "未找到可用的 bash(setup-crew.sh 是 bash 暑本;注意 system32\bash.exe 是 WSL 启动器,没装发行版不可用)" - Write-Host " 请装 Git Bash(https://git-scm.com)或 WSL 发行版,然后手动跑:" + Write-Warn "no usable bash found (setup-crew.sh is a bash script; note system32\bash.exe is the WSL launcher and is unusable without a distro installed)" + Write-Host " Please install Git Bash (https://git-scm.com) or a WSL distro, then run manually:" Write-Host " set OPENCLAW_HOME=$OpenclawHome" Write-Host " set XIAOBEI_BIN_DIR=$(Join-Path $Root 'bin')" Write-Host " bash `"$sh`"" return } Write-Host " bash: $bashExe" - # bash 不认反斜杠(会被当转义),传正斜杠路径给 setup-crew.sh - # ⚠️ 用 OPENCLAW_STATE_DIR 而非 OPENCLAW_HOME:引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir - # 再 append /.openclaw(见 openclaw/src/config/paths.ts),若设 OPENCLAW_HOME=~/.openclaw 会产出 - # ~/.openclaw/.openclaw 嵌套层——后续 weixin/gateway CLI 调用都把 config/npm/openclaw.sqlite 写嵌套层, - # 外层正解路径反而空(本机实测真 bug)。OPENCLAW_STATE_DIR 引擎当直接 state dir、不 append。 + # bash does not understand backslashes (it would treat them as escapes), so we pass forward-slash + # paths to setup-crew.sh. + # WARNING: use OPENCLAW_STATE_DIR rather than OPENCLAW_HOME: the engine's resolveStateDir treats + # OPENCLAW_HOME as a homedir and then appends /.openclaw (see openclaw/src/config/paths.ts); if + # OPENCLAW_HOME is set to ~/.openclaw this produces a nested ~/.openclaw/.openclaw layer, and all + # subsequent weixin/gateway CLI calls write config/npm/openclaw.sqlite into that nested layer while + # the correct outer path stays empty (a real bug observed on a local machine). OPENCLAW_STATE_DIR + # is treated by the engine as a direct state dir and is not appended to. $env:OPENCLAW_STATE_DIR = ($OpenclawHome -replace '\\', '/') $env:XIAOBEI_BIN_DIR = ((Join-Path $Root "bin") -replace '\\', '/') + # setup-crew.sh uses OPENCLAW_HOME internally to resolve CONFIG_PATH (it does NOT read + # OPENCLAW_STATE_DIR), so we must explicitly pass a forward-slash path; otherwise bash falls back + # to $HOME/.openclaw, and under Git Bash's MSYS path conversion + a Chinese username this yields a + # bogus path like C:\c\Users\\.openclaw (ENOENT). + $env:OPENCLAW_HOME = ($OpenclawHome -replace '\\', '/') Invoke-Streamed { & $bashExe ($sh -replace '\\', '/') } - if ($LASTEXITCODE -ne 0) { Write-Warn "setup-crew.sh 非零退出(可后续手动 --force 修复)" } + # After setup-crew.sh finishes, clear OPENCLAW_HOME so subsequent install phases (gateway/daemon) + # do not inherit this value - the engine would treat OPENCLAW_HOME as a homedir and append + # /.openclaw, producing a nested layer. + Remove-Item Env:OPENCLAW_HOME -ErrorAction SilentlyContinue + if ($LASTEXITCODE -ne 0) { Write-Warn "setup-crew.sh exited non-zero (you can manually re-run with --force later to fix)" } else { Write-Ok "crew templates set up" } } -# ─── 8. camoufox-cli ─────────────────────────────────────────── +# --- 8. camoufox-cli --- function Install-CamoufoxCli { if ($SkipBrowser) { - Write-Host " [i] 跳过 camoufox-cli 浏览器二进制(XIAOBEI_SKIP_BROWSER=1);后续手动:camoufox-cli install" -ForegroundColor Yellow + Write-Host " [i] skipping camoufox-cli browser binary (XIAOBEI_SKIP_BROWSER=1); run manually later: camoufox-cli install" -ForegroundColor Yellow return } Write-Stage "Installing camoufox-cli browser" $fork = Join-Path $Root "camoufox-cli" - if (-not (Test-Path $fork)) { Write-Warn "camoufox-cli fork 不在 tarball 内:$fork;跳过"; return } - # $Root\bin 放 camoufox-cli.cmd shim,前置进 PATH 让下面 `camoufox-cli install` 找得到 + if (-not (Test-Path $fork)) { Write-Warn "camoufox-cli fork not in tarball: $fork; skipping"; return } + # Place a camoufox-cli.cmd shim in $Root\bin and prepend it to PATH so the `camoufox-cli install` + # below can be found. $env:PATH = "$(Join-Path $Root 'bin');$(Split-Path $NodeExe);$env:PATH" $env:npm_config_registry = "https://registry.npmmirror.com" $camoufoxBin = Join-Path $Root "bin\camoufox-cli.cmd" $cliJs = Join-Path $fork "dist\cli.js" $hasDeps = Test-Path (Join-Path $fork "node_modules\camoufox-js") - # fork 运行时依赖:dist 是 tsc 编译,camoufox-js/playwright-core/pdf-lib 是 external import, - # 运行时靠 fork 目录下的 node_modules 解析。tarball 为瘦身 ship 的 fork 不带 node_modules。 + # Runtime deps of the fork: dist is a tsc build, and camoufox-js/playwright-core/pdf-lib are + # external imports resolved at runtime via the node_modules under the fork directory. The fork + # shipped in the slim tarball does not carry node_modules. if (-not $hasDeps) { Push-Location $fork try { & $NpmCmd install --omit=dev - if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli fork deps install 失败"; return } + if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli fork deps install failed"; return } } finally { Pop-Location } } - # Windows 不走 `npm install -g $fork`:npm 把 fork 装进 $prefix\node_modules 时撞 fork 源目录 - # 本身(EEXIST: file already exists $Root\camoufox-cli)——Windows npm 不能像 Linux 那样 symlink - # 到源目录。改仿 openclaw.cmd 写 .cmd shim 直接跑 node $fork\dist\cli.js,fork 的 node_modules - # 已就位,Node 从 cli.js 向上解析依赖;camoufox-cli JS 用 __dirname 定位自身包根,行为与全局装一致。 - if (-not (Test-Path $cliJs)) { Write-Warn "camoufox-cli 入口未找到:$cliJs;跳过"; return } + # Windows does not go through `npm install -g $fork`: when npm installs the fork into + # $prefix\node_modules it collides with the fork's own source directory (EEXIST: file already + # exists $Root\camoufox-cli) - Windows npm cannot symlink to the source dir the way Linux does. + # Instead, mirroring openclaw.cmd, we write a .cmd shim that directly runs + # node $fork\dist\cli.js; the fork's node_modules is already in place and Node resolves deps + # upward from cli.js. camoufox-cli's JS uses __dirname to locate its own package root, so the + # behavior matches a global install. + if (-not (Test-Path $cliJs)) { Write-Warn "camoufox-cli entry not found: $cliJs; skipping"; return } $shimLines = @( '@echo off' 'setlocal' @@ -301,37 +363,37 @@ function Install-CamoufoxCli { Set-Content -Path $camoufoxBin -Value $shimLines -Encoding ASCII Write-Ok "camoufox-cli shim written: $camoufoxBin" - Write-Host " 下 Firefox binary(首次 ~557MB)..." + Write-Host " downloading Firefox binary (first time ~557MB)..." Invoke-Streamed { camoufox-cli install } - if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli install 失败;可后续手动:camoufox-cli install" } + if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli install failed; you can run manually later: camoufox-cli install" } else { Write-Ok "camoufox-cli ready" } } -# ─── 9. openclaw-weixin 插件 ────────────────────────────────── +# --- 9. openclaw-weixin plugin --- function Install-WeixinPlugin { Write-Stage "Installing WeChat plugin" - if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper 未找到:$ClawCmd;跳过"; return } + if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper not found: $ClawCmd; skipping"; return } $pkg = "@tencent-weixin/openclaw-weixin"; $ver = "2.4.6" $pin = Join-Path $Root "openclaw-weixin.version.json" if (Test-Path $pin) { try { $j = Get-Content $pin -Raw | ConvertFrom-Json $pkg = $j.'openclaw-weixin'.package; $ver = $j.'openclaw-weixin'.version - } catch { Write-Warn "pin 文件解析失败,用默认 $pkg@$ver" } + } catch { Write-Warn "pin file parse failed, using default $pkg@$ver" } } $env:npm_config_registry = "https://registry.npmmirror.com" $listOut = Capture-Streamed { & $ClawCmd plugins list } if ($listOut -match "openclaw-weixin") { Write-Ok "openclaw-weixin plugin already installed"; return } Invoke-Streamed { & $ClawCmd plugins install "$pkg@$ver" --pin } if ($LASTEXITCODE -eq 0) { Write-Ok "openclaw-weixin plugin installed" } - else { Write-Warn "插件安装失败;可后续手动:$ClawCmd plugins install $pkg@$ver --pin" } + else { Write-Warn "plugin install failed; you can run manually later: $ClawCmd plugins install $pkg@$ver --pin" } } -# ─── 10. awada 本地插件 deps(ws + zod)────────────────────── +# --- 10. awada local plugin deps (ws + zod) --- function Install-AwadaPlugin { Write-Stage "Installing awada plugin deps" $awada = Join-Path $Root "awada" - if (-not (Test-Path $awada)) { Write-Warn "awada 不在 tarball 内:$awada;跳过"; return } + if (-not (Test-Path $awada)) { Write-Warn "awada not in tarball: $awada; skipping"; return } $hasWs = Test-Path (Join-Path $awada "node_modules\ws") $hasZod = Test-Path (Join-Path $awada "node_modules\zod") if ($hasWs -and $hasZod) { Write-Ok "awada deps already installed"; return } @@ -340,16 +402,18 @@ function Install-AwadaPlugin { try { & $NpmCmd install --omit=dev if ($LASTEXITCODE -eq 0) { Write-Ok "awada deps installed" } - else { Write-Warn "awada deps install 失败;可后续手动:cd $awada ; npm install --omit=dev" } + else { Write-Warn "awada deps install failed; you can run manually later: cd $awada ; npm install --omit=dev" } } finally { Pop-Location } } -# ─── 10.5 修复 daemon 启动脚本编码(openclaw 上游 bug 的 workaround)── -# openclaw daemon install 写出的 gateway.cmd / gateway.vbs 是 UTF-8(无 BOM), -# 而 cmd.exe / WScript 按系统 ANSI 代码页读取脚本文件——安装路径含非 ASCII 字符 -# (如中文用户名)时路径变乱码,node.exe 找不到,gateway 永远起不来(本机实测)。 -# workaround:把长路径替换成 8.3 矫路径(纯 ASCII)后以 ASCII 重写;无 8.3 名 -# 可用时退回按 ANSI 代码页写(cmd.exe 按 ANSI 读,编码一致即可)。幂等。 +# --- 10.5 Fix daemon launch-script encoding (workaround for an openclaw upstream bug) --- +# The gateway.cmd / gateway.vbs written out by `openclaw daemon install` are UTF-8 (no BOM), but +# cmd.exe / WScript read script files using the system ANSI code page - so when the install path +# contains non-ASCII chars (e.g. a Chinese username) the path turns into mojibake, node.exe cannot +# be found, and the gateway never starts (observed on a local machine). +# Workaround: replace long paths with their 8.3 short paths (pure ASCII) and rewrite as ASCII; if no +# 8.3 name is available, fall back to writing in the ANSI code page (cmd.exe reads it as ANSI, so the +# encodings line up). Idempotent. function Repair-GatewayCmd { $files = @((Join-Path $OpenclawHome "gateway.cmd"), (Join-Path $OpenclawHome "gateway.vbs")) $fso = $null @@ -368,51 +432,56 @@ function Repair-GatewayCmd { } else { [System.IO.File]::WriteAllText($f, $txt, [System.Text.ASCIIEncoding]::new()) } - Write-Ok "修复 $(Split-Path $f -Leaf) 编码(非 ASCII 路径 → 8.3 矫路径)" + Write-Ok "fixed $(Split-Path $f -Leaf) encoding (non-ASCII path -> 8.3 short path)" } } -# ─── 11. 交互收 AWK_API_KEY + 起 gateway ────────────────────── -# env 分工(对齐 install.sh / 上游踩坑经验): -# ~/.openclaw/.env ← 业务变量(AWK_API_KEY/XIAOBEI_HOME/OPENCLAW_STATE_DIR),openclaw CLI 裸跑用 -# ~/.openclaw/daemon.env ← gateway service 用,只放 3 个固定值(OPENCLAW_BROWSER_TIMEOUT_MS/ -# OPENCLAW_DISABLE_BONJOUR/PATH)+ OPENCLAW_STATE_DIR -# 原因:Linux systemd 那套 EnvironmentFile= 加载器对含特殊字符的业务变量(PATH 带分号、 -# AWK_API_KEY 带连字符)有踩坑历史,业务变量挪 .env 让 systemd 只加载稳的固定值。 -# Windows 不走 systemd,gateway.cmd 的 `call daemon.env` 没这个坑,但为风格统一照此分工。 +# --- 11. Interactive AWK_API_KEY prompt + start gateway --- +# Env division (mirrors install.sh / upstream lessons learned): +# ~/.openclaw/.env <- business vars (AWK_API_KEY/XIAOBEI_HOME/OPENCLAW_STATE_DIR), sourced by the +# openclaw CLI when run bare +# ~/.openclaw/daemon.env <- used by the gateway service, holds only 3 fixed values +# (OPENCLAW_BROWSER_TIMEOUT_MS/OPENCLAW_DISABLE_BONJOUR/PATH) + OPENCLAW_STATE_DIR +# Reason: the Linux systemd EnvironmentFile= loader has a history of choking on business vars that +# contain special chars (PATH with semicolons, AWK_API_KEY with hyphens), so business vars were moved +# to .env and systemd only loads the stable fixed values. Windows does not use systemd and +# gateway.cmd's `call daemon.env` does not have this pitfall, but we keep the same division for +# consistency. function Install-GatewayAndEnv { Write-Stage "Configuring API key and gateway" New-Item -ItemType Directory -Force -Path $OpenclawHome | Out-Null $dotEnv = Join-Path $OpenclawHome ".env" $daemonEnv = Join-Path $OpenclawHome "daemon.env" - # ─── 检测/清掉错误的 OPENCLAW_HOME 用户环境变量 ─────────────── - # 引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir 再 append /.openclaw(见 - # openclaw/src/config/paths.ts),故若 OPENCLAW_HOME 已被设成 ~/.openclaw,会产出 - # ~/.openclaw/.openclaw 嵌套路径——openclaw.json、daemon.env、lastTouch 全落嵌套层, - # 外层正解路径反而空(本机实测过的真 bug)。state dir 应该用 OPENCLAW_STATE_DIR 显式 - # 覆盖(引擎把它当直接 state dir、不 append),OPENCLAW_HOME 不该在用户环境里设。 + # --- Detect / clear a bogus OPENCLAW_HOME user env var --- + # The engine's resolveStateDir treats OPENCLAW_HOME as a homedir and then appends /.openclaw (see + # openclaw/src/config/paths.ts), so if OPENCLAW_HOME is set to ~/.openclaw this produces a nested + # ~/.openclaw/.openclaw path - openclaw.json, daemon.env and lastTouch all land in the nested + # layer while the correct outer path stays empty (a real bug observed on a local machine). The + # state dir should be explicitly overridden via OPENCLAW_STATE_DIR (the engine treats it as a + # direct state dir and does not append); OPENCLAW_HOME should not be set in the user environment. $badHome = $env:OPENCLAW_HOME if ($badHome -and ($badHome.TrimEnd('\').ToLower() -eq $OpenclawHome.TrimEnd('\').ToLower())) { - Write-Warn "检测到 OPENCLAW_HOME=$badHome 已设成 state dir 路径" - Write-Warn " 这会让引擎嵌套 append /.openclaw → ~/.openclaw/.openclaw/,config 全落错位置" - Write-Warn " 正解:清掉 OPENCLAW_HOME,改用 OPENCLAW_STATE_DIR 显式指定 state dir" + Write-Warn "detected OPENCLAW_HOME=$badHome is set to the state dir path" + Write-Warn " this makes the engine nest-append /.openclaw -> ~/.openclaw/.openclaw/, all config lands in the wrong place" + Write-Warn " fix: clear OPENCLAW_HOME and use OPENCLAW_STATE_DIR to explicitly specify the state dir" try { [Environment]::SetEnvironmentVariable("OPENCLAW_HOME", $null, "User") [Environment]::SetEnvironmentVariable("OPENCLAW_HOME", $null, "Process") Remove-Item Env:OPENCLAW_HOME -ErrorAction SilentlyContinue - Write-Ok "已从用户环境变量清掉 OPENCLAW_HOME(新终端生效)" - } catch { Write-Warn "清 OPENCLAW_HOME 失败,请手动删:[Environment]::SetEnvironmentVariable('OPENCLAW_HOME', `$null, 'User')" } + Write-Ok "cleared OPENCLAW_HOME from user env vars (takes effect in a new terminal)" + } catch { Write-Warn "failed to clear OPENCLAW_HOME, please delete manually: [Environment]::SetEnvironmentVariable('OPENCLAW_HOME', `$null, 'User')" } } - # ─── 交互收 AWK_API_KEY ─────────────────────────────────── + # --- Interactive AWK_API_KEY prompt --- $awkKey = $env:AWK_API_KEY if (-not $NoPrompt -and -not $awkKey) { $awkKey = Read-Host "Enter AWK_API_KEY (Volces ARK API key)" } - # ─── 写 .env(业务变量,export 格式给 bash/sh source 用,CLI 裸跑读这个)──── - # 幂等:先剥同 key 旧行再追加。AWK_API_KEY 是 secret,单引号裹 + '\'' 转义内置单引号。 + # --- Write .env (business vars, export format for bash/sh source, read by the bare CLI) --- + # Idempotent: strip old lines with the same key first, then append. AWK_API_KEY is a secret, so + # it is wrapped in single quotes with any embedded single quote escaped as '\''. $exportLines = @() if (Test-Path $dotEnv) { $exportLines = Get-Content $dotEnv } $exportLines = $exportLines | Where-Object { $_ -notmatch "^export AWK_API_KEY=" -and $_ -notmatch "^export XIAOBEI_HOME=" -and $_ -notmatch "^export OPENCLAW_STATE_DIR=" } @@ -425,10 +494,11 @@ function Install-GatewayAndEnv { $homeEsc = $OpenclawHome -replace "'", "'\''" $exportLines += "export OPENCLAW_STATE_DIR='$homeEsc'" Set-Content -Path $dotEnv -Value $exportLines -Encoding UTF8 - Write-Ok ".env written (业务变量,CLI 裸跑用)" + Write-Ok ".env written (business vars, used by the bare CLI)" - # ─── 写 daemon.env(gateway service 用,KEY=value 格式给 gateway.cmd call 用)── - # 只放 3 个固定值 + OPENCLAW_STATE_DIR(gateway 子进程需要 state dir 消歧嵌套)。 + # --- Write daemon.env (used by the gateway service, KEY=value format for gateway.cmd to call) --- + # Holds only 3 fixed values + OPENCLAW_STATE_DIR (the gateway subprocess needs the state dir to + # disambiguate the nested layer). $daemonLines = @() if (Test-Path $daemonEnv) { $daemonLines = Get-Content $daemonEnv } $daemonLines = $daemonLines | Where-Object { $_ -notmatch "^OPENCLAW_BROWSER_TIMEOUT_MS=" -and $_ -notmatch "^OPENCLAW_DISABLE_BONJOUR=" -and $_ -notmatch "^PATH=" -and $_ -notmatch "^OPENCLAW_STATE_DIR=" } @@ -438,36 +508,37 @@ function Install-GatewayAndEnv { $pathLine = "PATH=$(Join-Path $Root 'bin');$(Split-Path $NodeExe);$env:PATH" $daemonLines += $pathLine Set-Content -Path $daemonEnv -Value $daemonLines -Encoding UTF8 - Write-Ok "daemon.env written (gateway service 用,3 固定值 + PATH + OPENCLAW_STATE_DIR)" + Write-Ok "daemon.env written (used by the gateway service, 3 fixed values + PATH + OPENCLAW_STATE_DIR)" - # 把 .env 业务变量加载进当前 install shell,让后续 daemon install / gateway restart / channels login - # 校验 config 时能解析到 AWK_API_KEY / XIAOBEI_HOME(CLI 裸跑本会 . .env,install shell 也得有) + # Load the .env business vars into the current install shell so that subsequent daemon install / + # gateway restart / channels login config validation can resolve AWK_API_KEY / XIAOBEI_HOME (the + # bare CLI would normally `. .env`, and the install shell needs them too). if ($awkKey) { $env:AWK_API_KEY = $awkKey } $env:XIAOBEI_HOME = $Root $env:OPENCLAW_STATE_DIR = $OpenclawHome - # setx 用户环境变量(让新终端 / gateway 子进程继承 AWK_API_KEY) + # setx the user env var (so new terminals / gateway subprocesses inherit AWK_API_KEY) if ($awkKey) { & setx AWK_API_KEY "$awkKey" | Out-Null - Write-Ok "AWK_API_KEY set as user env var (新终端生效)" + Write-Ok "AWK_API_KEY set as user env var (takes effect in a new terminal)" } - # 尝试 daemon install(Windows 支持情况视 openclaw 版本而定) - Write-Host " 尝试 openclaw daemon install..." + # Attempt daemon install (Windows support depends on the openclaw version) + Write-Host " attempting openclaw daemon install..." Invoke-Streamed { & $ClawCmd daemon install } if ($LASTEXITCODE -eq 0) { Write-Ok "gateway daemon installed" Repair-GatewayCmd Invoke-Streamed { & $ClawCmd gateway restart } } else { - Write-Warn "openclaw daemon install 在 Windows 未就绪或失败" - Write-Host " 请开新 PowerShell 终端前台跑 gateway:" + Write-Warn "openclaw daemon install is not ready or failed on Windows" + Write-Host " please open a new PowerShell terminal and run gateway in the foreground:" Write-Host " set AWK_API_KEY=$awkKey" Write-Host " $ClawCmd gateway start" } } -# ─── 12. 自动出微信绑定二维码(首装末尾)────────────────────── +# --- 12. Auto-show WeChat binding QR code (end of first install) --- function Test-WeixinBound { $paths = @( (Join-Path $OpenclawHome "openclaw-weixin\accounts.json"), @@ -479,38 +550,39 @@ function Test-WeixinBound { function Bind-WeixinChannel { if ($SkipBind -or $NoPrompt) { - Write-Host " [i] 跳过微信扫码绑定(XIAOBEI_SKIP_BIND / XIAOBEI_NO_PROMPT=1);后续手动跑:openclaw channels login --channel openclaw-weixin" -ForegroundColor Yellow + Write-Host " [i] skipping WeChat scan-to-bind (XIAOBEI_SKIP_BIND / XIAOBEI_NO_PROMPT=1); run manually later: openclaw channels login --channel openclaw-weixin" -ForegroundColor Yellow return } - if (Test-WeixinBound) { Write-Ok "检测到微信账号已绑定,跳过扫码"; return } - if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper 未找到($ClawCmd),跳过微信绑定"; return } - Write-Stage "绑定微信 channel(用手机扫码)" - Write-Host " 接下来会出二维码,用微信扫一下、点确认,小贝就能用了。" - Write-Host " 扫码慢没关系,二维码会自动刷新;扫完即继续。" + if (Test-WeixinBound) { Write-Ok "WeChat account already bound, skipping scan"; return } + if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper not found ($ClawCmd), skipping WeChat binding"; return } + Write-Stage "Binding WeChat channel (scan with your phone)" + Write-Host " A QR code will appear next; scan it with WeChat and tap confirm, then xiaobei is ready to use." + Write-Host " Take your time scanning - the QR code auto-refreshes; just continue once scanned." Write-Host "" for ($i = 1; $i -le 5; $i++) { & $ClawCmd channels login --channel openclaw-weixin - if (Test-WeixinBound) { Write-Ok "微信账号绑定成功"; return } - if ($i -lt 5) { Write-Warn "本轮未检测到绑定,重出二维码(第 $($i + 1) 次)..." } + if (Test-WeixinBound) { Write-Ok "WeChat account bound successfully"; return } + if ($i -lt 5) { Write-Warn "no binding detected this round, re-showing QR code (attempt $($i + 1))..." } } - Write-Warn "多次扫码未完成绑定。可后续手动跑:$ClawCmd channels login --channel openclaw-weixin" + Write-Warn "binding not completed after multiple scans. You can run manually later: $ClawCmd channels login --channel openclaw-weixin" } -# ─── main ───────────────────────────────────────────────────── +# --- main --- function Main { - Write-Host "wiseflow installer (Windows, atomgit) — 预构建 tarball 路线" -ForegroundColor Magenta + Write-Host "wiseflow installer (Windows, atomgit) - prebuilt tarball route" -ForegroundColor Magenta Write-Host " Program dir : $Root" Write-Host " Runtime dir : $OpenclawHome" Write-Host " Repo : $Repo (atomgit)" - # 检测是否已装(决定走 update 还是 fresh install) + # Detect whether already installed (decides update vs fresh install) $cfgExisting = Join-Path $OpenclawHome "openclaw.json" $isUpdate = (Test-Path $cfgExisting) -and -not $Force if ($isUpdate) { - Write-Warn "检测到已有安装($cfgExisting)→ 走更新路线,保留运行数据(XIAOBEI_FORCE=1 可强覆盖)" - # 先停 gateway(对齐 install.sh stop_gateway_if_running):否则 tar 覆盖 - # tools\node\node.exe、pnpm 重写 node_modules 会撞运行中 gateway 的文件锁 - # ("Can't unlink already-existing object: Permission denied",本机实测) + Write-Warn "detected existing install ($cfgExisting) -> taking the update route, preserving runtime data (XIAOBEI_FORCE=1 to force overwrite)" + # Stop the gateway first (mirrors install.sh stop_gateway_if_running): otherwise tar + # overwriting tools\node\node.exe and pnpm rewriting node_modules will hit the running + # gateway's file locks ("Can't unlink already-existing object: Permission denied", observed + # in practice). Write-Stage "Stopping gateway before update" if (Test-Path $ClawCmd) { Invoke-Streamed { & $ClawCmd gateway stop } } Get-Process node -ErrorAction SilentlyContinue | @@ -535,7 +607,8 @@ function Main { Install-WeixinPlugin if ($isUpdate) { - # 更新路线也自愈 config(对齐 install.sh:openclaw.json 若被极简化则用 template 修复) + # The update route also self-heals the config (mirrors install.sh: if openclaw.json was + # minimized, repair it with the template). Place-Config Write-Stage "Refreshing gateway env and restarting" $envFile = Join-Path $OpenclawHome "daemon.env" @@ -564,7 +637,7 @@ function Main { Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan $binDir = Join-Path $Root 'bin' - Write-Host " 把 $binDir 加到用户 PATH(安全方式,勿用 setx %PATH% 会截断):" + Write-Host " Add $binDir to the user PATH (safe way - do NOT use setx %PATH%, it truncates):" Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$binDir;`" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')" Write-Host "" Write-Host " Dashboard: http://127.0.0.1:18789" diff --git a/scripts/install-atomgit.sh b/scripts/install-atomgit.sh index 158942a0..c6487f8e 100755 --- a/scripts/install-atomgit.sh +++ b/scripts/install-atomgit.sh @@ -389,9 +389,19 @@ download_and_extract_tarball() { ui_kv "Asset" "$asset" ui_kv "URL" "$url" mkdir -p "$cache_dir" + # 原子写:先下到 .part 临时文件,成功后 rename 到 cached, + # 避免断网留半截损坏文件导致下次重跑命中损坏缓存。 + local part="$cached.part" + rm -f "$part" ui_info "Downloading $asset (~120MB, atomgit CDN) → $cached ..." - download_file "$url" "$cached" - ui_success "Downloaded" + if download_file "$url" "$part"; then + mv -f "$part" "$cached" + ui_success "Downloaded" + else + rm -f "$part" + ui_error "下载失败:$url" + exit 1 + fi tmp="$cached" fi @@ -466,12 +476,41 @@ install_python_deps() { ui_info "Installing python skill deps via $PY_BIN (--user)" # --no-warn-script-location 抑制 'script installed in .../bin which is not on PATH'(小白噪音) # --disable-pip-version-check 抑制 pip 升级提示 - printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ - "$PY_BIN" -m pip install --user \ - --no-warn-script-location --disable-pip-version-check \ - -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /dev/stdin || \ + # PEP 668 兜底:Homebrew/Debian 等 externally-managed 环境下,pip install --user 仍被拒, + # 需加 --break-system-packages 才能写用户目录(--user 限目标,不碰系统 site-packages,安全)。 + local pep668_flag="" + local pyver; pyver="$("$PY_BIN" -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null || echo "")" + if [ -n "$pyver" ]; then + local inc_dir + inc_dir="$("$PY_BIN" -c 'import sysconfig;print(sysconfig.get_path("include"))' 2>/dev/null || echo "")" + local sys_prefix + sys_prefix="$("$PY_BIN" -c 'import sys;print(sys.prefix)' 2>/dev/null || echo "")" + # EXTERNALLY-MANAGED 标记文件在 stdlib 目录(python3.x/)下 + if [ -n "$inc_dir" ] && [ -f "$(dirname "$inc_dir")/EXTERNALLY-MANAGED" ]; then + pep668_flag="--break-system-packages" + ui_info "检测到 PEP 668 externally-managed 环境,加 --break-system-packages" + fi + fi + # 兜底:上面探测失败时,pip 报 externally-managed-environment 退出码 1,重试加 flag + local pip_args=(--user --no-warn-script-location --disable-pip-version-check + -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com) + [ -n "$pep668_flag" ] && pip_args+=("$pep668_flag") + if ! printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ + "$PY_BIN" -m pip install "${pip_args[@]}" -r /dev/stdin; then + # 首次没加 flag 且报 externally-managed,重试加 flag + if [ -z "$pep668_flag" ]; then + ui_warn "pip install 失败,尝试加 --break-system-packages 重试..." + if printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ + "$PY_BIN" -m pip install --user --break-system-packages \ + --no-warn-script-location --disable-pip-version-check \ + -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + -r /dev/stdin; then + ui_success "Python deps done (with --break-system-packages)" + return 0 + fi + fi ui_warn "pip install 部分失败,可稍后手动补" + fi ui_success "Python deps done" } @@ -1357,14 +1396,16 @@ install_gateway_and_env() { local systemd_env="$OPENCLAW_HOME/daemon.env" local macos_env="$OPENCLAW_HOME/service-env/ai.openclaw.gateway.env" - # ─── 检测/清掉错误的 OPENCLAW_HOME 用户环境变量 ───────────── + # ─── 提示用户清掉错误的 OPENCLAW_HOME 环境变量 ───────────── # 引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir 再 append /.openclaw(见 - # openclaw/src/config/paths.ts),故若 OPENCLAW_HOME 已被设成 ~/.openclaw,会产出嵌套路径。 + # openclaw/src/config/paths.ts),故若用户 shell export 了 OPENCLAW_HOME=~/.openclaw, + # 会产出嵌套路径。本脚本顶部已把 OPENCLAW_HOME 设成内部变量且未 export,不会传给引擎子进程, + # 所以这里只打 warning 提示用户清理 shell 环境,不能 unset——unset 会误伤脚本内部变量, + # 导致后续 ${OPENCLAW_HOME//...} 在 set -u 下报 unbound variable。 if [ -n "${OPENCLAW_HOME:-}" ] && [ "$(cd "${OPENCLAW_HOME}" && pwd)" = "$(cd "$HOME/.openclaw" && pwd)" ]; then ui_warn "检测到 OPENCLAW_HOME=$OPENCLAW_HOME 已设成 state dir 路径" ui_warn " 这会让引擎嵌套 append /.openclaw → ~/.openclaw/.openclaw/,config 全落错位置" ui_warn " 正解:清掉 OPENCLAW_HOME,改用 OPENCLAW_STATE_DIR 显式指定 state dir" - unset OPENCLAW_HOME fi # redirect stdin from /dev/tty so interactive read 工作于 curl|bash diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6dc7e1c6..f6b78e0f 100755 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,57 +1,68 @@ -# install.ps1 - wiseflow 一键首装脚本(Windows,预构建 tarball 路线,GitHub 专线) +# install.ps1 - wiseflow one-click install script (Windows, prebuilt tarball route, GitHub direct) # -# 与 install-atomgit.ps1 区别:本脚本走 GitHub release(tarball + tag API 都不经镜像),适合能正常访问 GitHub 的网络环境。 -# 国内用户请改用 install-atomgit.ps1(atomgit 镜像专线)。 -# 为对 `irm | iex` 友好(小白一条命令跑通),去掉 [CmdletBinding]param() 头, -# 所有可选参数走环境变量(=1 启用行为开关): -# $env:XIAOBEI_REPO GitHub 仓(owner/repo,默认 TeamWiseFlow/xiaobei;测试可指 bigbrother666sh/wiseflow) -# $env:XIAOBEI_HOME 程序目录覆盖(默认 ~\xiaobei) -# $env:XIAOBEI_TAG 指定 release tag(默认拉最新) -# $env:XIAOBEI_TARBALL 本地已下好的 tarball 路径,跳过下载 -# $env:XIAOBEI_FORCE =1 强覆盖已有运行数据(~\.openclaw) -# $env:XIAOBEI_SKIP_BIND =1 跳过末尾微信扫码绑定 -# $env:XIAOBEI_SKIP_BROWSER=1 跳过 camoufox-cli 浏览器二进制(冒烟/CI) -# $env:XIAOBEI_NO_PROMPT =1 跳过所有交互提示(CI/自动化) +# Difference from install-atomgit.ps1: this script uses GitHub release (tarball + tag API both +# go direct, no mirror), suitable for networks with normal GitHub access. +# For users in mainland China, use install-atomgit.ps1 (atomgit mirror direct line) instead. +# To be friendly to `irm | iex` (so a one-liner works for beginners), the [CmdletBinding]param() +# header is removed; all optional params go through environment variables (=1 to enable): +# $env:XIAOBEI_REPO GitHub repo (owner/repo, default TeamWiseFlow/xiaobei; for testing +# you can point to bigbrother666sh/wiseflow) +# $env:XIAOBEI_HOME Program directory override (default ~\xiaobei) +# $env:XIAOBEI_TAG Specify a release tag (default pulls latest) +# $env:XIAOBEI_TARBALL Path to a locally downloaded tarball, skips download +# $env:XIAOBEI_FORCE =1 force overwrite existing runtime data (~\.openclaw) +# $env:XIAOBEI_SKIP_BIND =1 skip the WeChat scan-to-bind step at the end +# $env:XIAOBEI_SKIP_BROWSER=1 skip camoufox-cli browser binary (smoke/CI) +# $env:XIAOBEI_NO_PROMPT =1 skip all interactive prompts (CI/automation) # -# 用法(PowerShell,需 Git Bash 或 WSL): +# Usage (PowerShell, requires Git Bash or WSL): # irm https://raw.githubusercontent.com/TeamWiseFlow/xiaobei/master/scripts/install.ps1 | iex -# # 或本地: +# # or locally: # powershell -ExecutionPolicy Bypass -File install.ps1 # -# ⚠️ 本文件必须保持 UTF-8 **带 BOM**:Windows PowerShell 5.1 对无 BOM 的 .ps1 按系统 -# ANSI 代码页(中文系统 GBK)解析,中文字符串会乱码并产生解析错误,脚本无法运行。 +# WARNING: this file MUST be kept UTF-8 **without BOM**: under `irm | iex`, irm does not strip +# the BOM, and iex treats the 3 BOM bytes as the first chars of a command name, so the first +# line `#` is parsed as an unknown command and errors (though it does not block the install). +# The local `powershell -File` scenario also works fine without BOM (PowerShell 5.1+ defaults +# to UTF-8 for parsing). All comments in this file are in English (ASCII) to avoid GBK/UTF-8 +# mojibake parsing errors under Windows PowerShell 5.1 with a non-BOM file. # -# 与 install.sh 同构(方案 B 瘦 tarball): -# 1. 拉 xiaobei-{tag}-win-x64.tar.gz(GitHub release,Windows bsdtar 原生支持 gzip,免装 zstd) -# 2. 解压到 $XIAOBEI_HOME(默认 $env:USERPROFILE\xiaobei,程序目录) -# 3. portable node + pnpm install --prod --frozen-lockfile(在 openclaw\ 下) -# 4. pip install --user(skills 的 python deps,有 python 才跑) -# 5. 放 config-templates\openclaw.json → $OPENCLAW_HOME\openclaw.json + 预填微信 binding -# 6. setup-crew.sh(需 bash:Git Bash 或 WSL;无则警告并跳过,用户后续手动跑) -# 7. camoufox-cli:.cmd shim + camoufox-cli install 下 Firefox -# 8. openclaw-weixin 插件:openclaw plugins install ... --pin(npmmirror) -# 9. 交互问 AWK_API_KEY → 写 daemon.env + setx 用户环境变量 → 尝试 openclaw daemon install +# Structurally identical to install.sh (Plan B slim tarball): +# 1. Pull xiaobei-{tag}-win-x64.tar.gz (GitHub release; Windows bsdtar natively supports gzip, +# no need to install zstd) +# 2. Extract to $XIAOBEI_HOME (default $env:USERPROFILE\xiaobei, the program directory) +# 3. portable node + pnpm install --prod --frozen-lockfile (under openclaw\) +# 4. pip install --user (python deps for skills; only runs if python is present) +# 5. Place config-templates\openclaw.json -> $OPENCLAW_HOME\openclaw.json + prefill WeChat binding +# 6. setup-crew.sh (requires bash: Git Bash or WSL; if absent, warn and skip; user can run it +# manually later) +# 7. camoufox-cli: .cmd shim + camoufox-cli install downloads Firefox +# 8. openclaw-weixin plugin: openclaw plugins install ... --pin (npmmirror) +# 9. Interactive prompt for AWK_API_KEY -> write daemon.env + setx user env var -> attempt +# openclaw daemon install # -# 目录职责:$XIAOBEI_HOME(~\xiaobei)= 程序;$OPENCLAW_HOME(~\.openclaw)= 运行数据。 -# Windows 原生 wrapper:$XIAOBEI_HOME\bin\openclaw.cmd(WSL/Git Bash 用户也可用 bin\openclaw)。 +# Directory responsibilities: $XIAOBEI_HOME (~\xiaobei) = program; $OPENCLAW_HOME (~\.openclaw) +# = runtime data. +# Windows native wrapper: $XIAOBEI_HOME\bin\openclaw.cmd (WSL/Git Bash users can also use bin\openclaw). $ErrorActionPreference = "Stop" -# ─── 常量 / 目录(GitHub 专线)─────────────────────────────── -# 可选参数走环境变量(为 `irm | iex` 友好,去掉 [CmdletBinding]param() 头): -# $env:XIAOBEI_REPO GitHub 仓(owner/repo,默认 TeamWiseFlow/xiaobei;测试可指 bigbrother666sh/wiseflow) -# $env:XIAOBEI_HOME 程序目录覆盖(默认 ~\xiaobei) -# $env:XIAOBEI_TAG 指定 release tag(默认拉最新) -# $env:XIAOBEI_TARBALL 本地已下好的 tarball 路径,跳过下载 -# $env:XIAOBEI_FORCE =1 强覆盖已有运行数据(~\.openclaw) -# $env:XIAOBEI_SKIP_BIND =1 跳过末尾微信扫码绑定 -# $env:XIAOBEI_SKIP_BROWSER=1 跳过 camoufox-cli 浏览器二进制(冒烟/CI) -# $env:XIAOBEI_NO_PROMPT =1 跳过所有交互提示(CI/自动化) +# --- Constants / directories (GitHub direct) --- +# Optional params go through env vars (friendly for `irm | iex`, [CmdletBinding]param() header removed): +# $env:XIAOBEI_REPO GitHub repo (owner/repo, default TeamWiseFlow/xiaobei; for testing +# you can point to bigbrother666sh/wiseflow) +# $env:XIAOBEI_HOME Program directory override (default ~\xiaobei) +# $env:XIAOBEI_TAG Specify a release tag (default pulls latest) +# $env:XIAOBEI_TARBALL Path to a locally downloaded tarball, skips download +# $env:XIAOBEI_FORCE =1 force overwrite existing runtime data (~\.openclaw) +# $env:XIAOBEI_SKIP_BIND =1 skip the WeChat scan-to-bind step at the end +# $env:XIAOBEI_SKIP_BROWSER=1 skip camoufox-cli browser binary (smoke/CI) +# $env:XIAOBEI_NO_PROMPT =1 skip all interactive prompts (CI/automation) $Repo = if ($env:XIAOBEI_REPO) { $env:XIAOBEI_REPO } else { "TeamWiseFlow/xiaobei" } $Root = if ($env:XIAOBEI_HOME) { $env:XIAOBEI_HOME } else { Join-Path $env:USERPROFILE "xiaobei" } $OpenclawHome = if ($env:OPENCLAW_HOME) { $env:OPENCLAW_HOME } else { Join-Path $env:USERPROFILE ".openclaw" } -# 行为开关(环境变量,=1 启用) +# Behavior switches (env vars, =1 to enable) $Force = ($env:XIAOBEI_FORCE -eq "1" -or $env:XIAOBEI_FORCE -eq "true") $SkipBind = ($env:XIAOBEI_SKIP_BIND -eq "1" -or $env:XIAOBEI_SKIP_BIND -eq "true") $SkipBrowser = ($env:XIAOBEI_SKIP_BROWSER -eq "1" -or $env:XIAOBEI_SKIP_BROWSER -eq "true") @@ -68,10 +79,12 @@ function Write-Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor G function Write-Warn([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow } function Write-Err([string]$msg) { Write-Host " [X] $msg" -ForegroundColor Red } -# 跑原生命令并按行回显 stdout+stderr。pip/npm/camoufox-cli/openclaw 正常会把进度和警告写 stderr, -# 而本脚本顶部 $ErrorActionPreference="Stop" 会把 2>&1 合并进管道的 stderr 行当 terminating error -# 抛 NativeCommandError/RemoteException(用户 Win 实测卡在 pip install 这步)。这里临时切到 Continue -# 跑原生命令,$LASTEXITCODE 仍由调用方据以判成败。 +# Run a native command and echo stdout+stderr line by line. pip/npm/camoufox-cli/openclaw normally +# write progress and warnings to stderr, and the $ErrorActionPreference="Stop" at the top of this +# script would treat stderr lines merged via 2>&1 as terminating errors, throwing +# NativeCommandError/RemoteException (a user's Windows run actually got stuck at pip install this +# way). Here we temporarily switch to Continue to run the native command; $LASTEXITCODE is still +# used by the caller to judge success/failure. function Invoke-Streamed([scriptblock]$sb) { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' @@ -79,8 +92,9 @@ function Invoke-Streamed([scriptblock]$sb) { finally { $ErrorActionPreference = $prev } } -# 同 Invoke-Streamed,但把 stdout+stderr 合并成字符串返回(供调用方 -&match)。`2>$null` 在 -# Windows PowerShell 5.1 + Stop 下压不住 .cmd 的 stderr 仍抛 NativeCommandError,这里用 Continue + 2>&1 稳。 +# Same as Invoke-Streamed, but merges stdout+stderr into a single string and returns it (for callers +# that need to -match against the output). `2>$null` under Windows PowerShell 5.1 + Stop does not +# suppress a .cmd's stderr and still throws NativeCommandError; using Continue + 2>&1 is stable. function Capture-Streamed([scriptblock]$sb) { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' @@ -88,7 +102,7 @@ function Capture-Streamed([scriptblock]$sb) { finally { $ErrorActionPreference = $prev } } -# ─── 1. 解析最新 release tag(GitHub API)────────────────────── +# --- 1. Resolve latest release tag (GitHub API) --- function Resolve-Tag { if ($env:XIAOBEI_TAG) { return $env:XIAOBEI_TAG } $api = "https://api.github.com/repos/$Repo/releases/latest" @@ -96,113 +110,142 @@ function Resolve-Tag { return $rel.tag_name } -# ─── 2. 下载 tarball(GitHub release)────────────────────────── +# --- 2. Download tarball (GitHub release) --- +# Version-level cache: same tag + plat re-uses ~/.xiaobei/cache/, +# skipping the 120MB re-download on rerun (incl. failed retry). +# Atomic write: download to .part first, then Move-Item -Force to , +# so a broken network never leaves a half-written file that the next run would +# mistake for a complete cache hit. function Download-Tarball([string]$tag) { $asset = "xiaobei-$tag-win-x64.tar.gz" if ($env:XIAOBEI_TARBALL -and (Test-Path $env:XIAOBEI_TARBALL)) { - Write-Ok "用本地 tarball:$env:XIAOBEI_TARBALL" + Write-Ok "using local tarball: $env:XIAOBEI_TARBALL" return $env:XIAOBEI_TARBALL } - $tmp = New-TemporaryFile + + $cacheDir = if ($env:XIAOBEI_CACHE_DIR) { $env:XIAOBEI_CACHE_DIR } else { Join-Path $env:USERPROFILE ".xiaobei\cache" } + $cached = Join-Path $cacheDir $asset + + if (Test-Path $cached -PathType Leaf) { + Write-Ok "using cached tarball: $cached" + return $cached + } + $url = "https://github.com/$Repo/releases/download/$tag/$asset" - Write-Host " 下载 $url" - Invoke-WebRequest -Uri $url -OutFile $tmp -Headers @{ "User-Agent" = "xiaobei-install" } - return $tmp.FullName + Write-Host " downloading $url" + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + $part = "$cached.part" + if (Test-Path $part) { Remove-Item $part -Force } + try { + Invoke-WebRequest -Uri $url -OutFile $part -Headers @{ "User-Agent" = "xiaobei-install" } + Move-Item -Path $part -Destination $cached -Force + } catch { + if (Test-Path $part) { Remove-Item $part -Force } + throw "download failed: $url`n$($_.Exception.Message)" + } + return $cached } -# ─── 3. 解压 ─────────────────────────────────────────────────── +# --- 3. Extract --- function Extract-Tarball([string]$tarball) { if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - throw "未找到 tar(Windows 10 1803+ 自带 bsdtar;请升级 Windows 或装 Git Bash)" + throw "tar not found (Windows 10 1803+ ships bsdtar; please upgrade Windows or install Git Bash)" } New-Item -ItemType Directory -Force -Path $Root | Out-Null - Write-Host " 解压到 $Root" + Write-Host " extracting to $Root" & tar -xzf $tarball -C $Root - if ($LASTEXITCODE -ne 0) { throw "tar 解压失败 (exit $LASTEXITCODE)" } + if ($LASTEXITCODE -ne 0) { throw "tar extraction failed (exit $LASTEXITCODE)" } } -# ─── 4. pnpm install --prod ──────────────────────────────────── +# --- 4. pnpm install --prod --- function Install-Deps { Write-Stage "Installing dependencies (pnpm install --prod)" $openclawDir = Join-Path $Root "openclaw" - if (-not (Test-Path $NodeExe)) { throw "portable node 未找到:$NodeExe" } + if (-not (Test-Path $NodeExe)) { throw "portable node not found: $NodeExe" } Push-Location $openclawDir try { - # postinstall 子进程(esbuild/protobufjs 等)按 PATH 找 node;目标机器多半没装全局 - # Node('node' is not recognized 实测),把 portable node 前置进 PATH + # postinstall subprocesses (esbuild/protobufjs etc.) look up node via PATH; the target + # machine most likely has no global Node installed ('node' is not recognized, observed in + # practice), so we prepend the portable node directory to PATH. $env:PATH = "$(Split-Path $NodeExe);$env:PATH" - # pnpm 11 不认 npm_config_registry 环境变量(实测仍走 registry.npmjs.org),用显式 --registry + # pnpm 11 does not honor the npm_config_registry env var (in practice it still hits + # registry.npmjs.org), so we pass an explicit --registry. $env:npm_config_registry = "https://registry.npmmirror.com" & $NodeExe $PnpmMjs install --prod --frozen-lockfile --registry https://registry.npmmirror.com - if ($LASTEXITCODE -ne 0) { throw "pnpm install 失败 (exit $LASTEXITCODE)" } + if ($LASTEXITCODE -ne 0) { throw "pnpm install failed (exit $LASTEXITCODE)" } Write-Ok "deps installed" } finally { Pop-Location } } -# ─── 5. python skill deps ────────────────────────────────────── +# --- 5. python skill deps --- function Install-PythonDeps { Write-Stage "Installing python skill deps" $py = Get-Command python -ErrorAction SilentlyContinue - if (-not $py) { Write-Warn "未找到 python,跳过 skill python deps"; return } + if (-not $py) { Write-Warn "python not found, skipping skill python deps"; return } $reqs = @() $reqs += Get-ChildItem -Path (Join-Path $Root "skills") -Filter "requirements.txt" -Recurse -ErrorAction SilentlyContinue $reqs += Get-ChildItem -Path (Join-Path $Root "crews") -Filter "requirements.txt" -Recurse -ErrorAction SilentlyContinue $reqs += Get-ChildItem -Path $Root -Filter "requirements.txt" -ErrorAction SilentlyContinue $reqs = $reqs | Select-Object -ExpandProperty FullName -Unique - if (-not $reqs) { Write-Ok "无 requirements.txt"; return } - # 各行作为 pip 命令行参数传入(非 -r 文件),必须剥掉行内注释(pip 只在 - # requirements 文件里认注释,参数里 `Pillow # 说明` 直接 Invalid requirement 报错); - # 文件是 UTF-8,显式指定编码防中文注释被 ANSI 误读产生乱码参数。 + if (-not $reqs) { Write-Ok "no requirements.txt"; return } + # Each line is passed as a pip command-line argument (not via -r file), so inline comments must + # be stripped (pip only recognizes comments inside a requirements file; `Pillow # desc` passed + # as an arg directly errors with Invalid requirement). The file is UTF-8; we explicitly specify + # the encoding to prevent Chinese comments from being misread as ANSI and producing garbled args. $merged = ($reqs | ForEach-Object { Get-Content $_ -Encoding UTF8 -ErrorAction SilentlyContinue } | ForEach-Object { ($_ -replace '#.*$', '').Trim() } | Where-Object { $_ } | Sort-Object -Unique) - if (-not $merged) { Write-Ok "无 python 依赖"; return } + if (-not $merged) { Write-Ok "no python deps"; return } Invoke-Streamed { python -m pip install --user $merged } - if ($LASTEXITCODE -ne 0) { Write-Warn "pip install 非零退出;可后续手动补装 skills 的 python 依赖" } + if ($LASTEXITCODE -ne 0) { Write-Warn "pip install returned non-zero; you can manually install the skills' python deps later" } else { Write-Ok "python deps installed" } } -# ─── 6. 放 config template(对齐 install.sh place_config_template)──── -# template 已预置 channels.openclaw-weixin / bindings / session.dmScope,不再运行时 mutate -# (sh 版曾因运行时 mutate 把 plugins 顶层写坏致 "Invalid input",已同构移除)。 -# 健康检查:现有 config 缺 models/agents.defaults(多半是 Install-WeixinPlugin 先跑时 -# openclaw plugins install 自建的极简 config,或 openclaw 首启自动生成)→ 备份后用 -# template 覆盖,否则 channels/bindings 永远缺失、小贝起不来。 +# --- 6. Place config template (mirrors install.sh place_config_template) --- +# The template already pre-populates channels.openclaw-weixin / bindings / session.dmScope; we no +# longer mutate it at runtime (the sh version once broke the plugins top-level via runtime mutation, +# causing "Invalid input"; that mutation has been removed in parallel here). +# Health check: if the existing config is missing models/agents.defaults (most likely a minimal config +# auto-created by `openclaw plugins install` when Install-WeixinPlugin ran first, or by openclaw's +# first launch), back it up and overwrite with the template; otherwise channels/bindings would be +# missing forever and xiaobei could never start. function Place-Config { Write-Stage "Placing config template" New-Item -ItemType Directory -Force -Path $OpenclawHome | Out-Null $cfg = Join-Path $OpenclawHome "openclaw.json" $tmpl = Join-Path $Root "config-templates\openclaw.json" - if (-not (Test-Path $tmpl)) { Write-Err "config template 缺失:$tmpl(tarball 损坏?)"; return } + if (-not (Test-Path $tmpl)) { Write-Err "config template missing: $tmpl (tarball corrupted?)"; return } $needPlace = $false; $reason = "" - if (-not (Test-Path $cfg)) { $needPlace = $true; $reason = "不存在" } + if (-not (Test-Path $cfg)) { $needPlace = $true; $reason = "not present" } elseif ($Force) { $needPlace = $true; $reason = "XIAOBEI_FORCE=1" } else { try { - # 必须显式按 UTF-8 读:openclaw 写的 config 是 UTF-8 无 BOM,PS 5.1 的 - # Get-Content 默认按 ANSI 代码页读,中文路径旁的 \\ 转义会被双字节吞掉, - # 有效 config 被误判"解析失败"遭覆盖(本机实测)。 + # Must explicitly read as UTF-8: openclaw writes its config as UTF-8 without BOM, while + # PS 5.1's Get-Content defaults to the ANSI code page; a \\ escape next to a Chinese + # path can get swallowed by a double-byte char, causing a valid config to be misjudged + # as "parse failed" and overwritten (observed in practice). $j = [System.IO.File]::ReadAllText($cfg) | ConvertFrom-Json -ErrorAction Stop - if (-not ($j.models -and $j.agents -and $j.agents.defaults)) { $needPlace = $true; $reason = "缺 models/agents.defaults(疑似被极简化)" } - } catch { $needPlace = $true; $reason = "解析失败" } + if (-not ($j.models -and $j.agents -and $j.agents.defaults)) { $needPlace = $true; $reason = "missing models/agents.defaults (likely minimized)" } + } catch { $needPlace = $true; $reason = "parse failed" } } if ($needPlace) { if (Test-Path $cfg) { $bak = "$cfg.bak.$([int][double]::Parse((Get-Date -UFormat %s)))" Copy-Item $cfg $bak - Write-Warn "openclaw.json $reason → 用 template 覆盖(旧文件备份到 $bak)" + Write-Warn "openclaw.json $reason -> overwriting with template (old file backed up to $bak)" } Copy-Item $tmpl $cfg -Force Write-Ok "placed openclaw.json template" } else { - Write-Ok "openclaw.json 已存在且健康(有 models + agents.defaults),保留" + Write-Ok "openclaw.json already present and healthy (has models + agents.defaults), keeping" } - # 把 plugins.load.paths 里的 ${XIAOBEI_HOME} env ref 解析成绝对路径,避免 CLI 上下文没 - # XIAOBEI_HOME 时 "plugin path not found" 误报;AWK_API_KEY 是 secret 保持 env ref 不动。 - # 文本级替换(template 中 ${XIAOBEI_HOME} 仅出现在 plugins.load.paths);JSON 字符串里 - # 反斜杠是转义符,路径统一写正斜杠。幂等:已解析则 no-op。 + # Resolve the ${XIAOBEI_HOME} env ref inside plugins.load.paths to an absolute path, to avoid a + # "plugin path not found" false alarm when the CLI context lacks XIAOBEI_HOME; AWK_API_KEY is a + # secret so its env ref is left untouched. This is a file-level replacement (the ${XIAOBEI_HOME} + # template token only appears in plugins.load.paths); inside a JSON string a backslash is an + # escape char, so we normalize paths to forward slashes. Idempotent: a no-op if already resolved. $raw = [System.IO.File]::ReadAllText($cfg) if ($raw.Contains('${XIAOBEI_HOME}')) { $rootFwd = $Root -replace '\\', '/' @@ -211,10 +254,11 @@ function Place-Config { } } -# ─── 7. setup-crew(需 bash)────────────────────────────────── -# 找一个真正可用的 bash:优先 Git Bash(吃得了 C:/ 风格路径);PATH 里的 bash 可能是 -# C:\Windows\system32\bash.exe(WSL 启动器)——没装发行版时直接报错退出,且 WSL bash -# 对 Windows 路径参数不做转换,都不能直接用。每个候选实测 `bash -c echo` 通过才算数。 +# --- 7. setup-crew (requires bash) --- +# Find a genuinely usable bash: prefer Git Bash (it handles C:/ style paths well); the bash on PATH +# might be C:\Windows\system32\bash.exe (the WSL launcher) which errors out immediately when no distro +# is installed, and WSL bash does not convert Windows path arguments either, so neither can be used +# directly. Each candidate is actually tested with `bash -c echo` before being accepted. function Find-WorkingBash { $candidates = @() $git = Get-Command git -ErrorAction SilentlyContinue @@ -226,7 +270,8 @@ function Find-WorkingBash { $candidates += "$env:ProgramFiles\Git\bin\bash.exe" $candidates += "${env:ProgramFiles(x86)}\Git\bin\bash.exe" $inPath = Get-Command bash -ErrorAction SilentlyContinue - # system32 的 WSL 启动器排最后(仅当其可用且无 Git Bash 时兜底) + # The system32 WSL launcher goes last (only used as a fallback when it is available and no Git + # Bash exists). if ($inPath) { $candidates += $inPath.Source } foreach ($b in $candidates) { if ($b -and (Test-Path $b)) { @@ -240,60 +285,77 @@ function Find-WorkingBash { function Run-SetupCrew { Write-Stage "Setting up crew templates (needs bash)" $sh = Join-Path $Root "scripts\setup-crew.sh" - if (-not (Test-Path $sh)) { Write-Warn "setup-crew.sh 不在 tarball 内,跳过"; return } + if (-not (Test-Path $sh)) { Write-Warn "setup-crew.sh is not inside the tarball, skipping"; return } $bashExe = Find-WorkingBash if (-not $bashExe) { - Write-Warn "未找到可用的 bash(setup-crew.sh 是 bash 脚本;注意 system32\bash.exe 是 WSL 启动器,没装发行版不可用)" - Write-Host " 请装 Git Bash(https://git-scm.com)或 WSL 发行版,然后手动跑:" + Write-Warn "no usable bash found (setup-crew.sh is a bash script; note system32\bash.exe is the WSL launcher and is unusable without a distro installed)" + Write-Host " Please install Git Bash (https://git-scm.com) or a WSL distro, then run manually:" Write-Host " set OPENCLAW_HOME=$OpenclawHome" Write-Host " set XIAOBEI_BIN_DIR=$(Join-Path $Root 'bin')" Write-Host " bash `"$sh`"" return } Write-Host " bash: $bashExe" - # bash 不认反斜杠(会被当转义),传正斜杠路径给 setup-crew.sh - # ⚠️ 用 OPENCLAW_STATE_DIR 而非 OPENCLAW_HOME:引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir - # 再 append /.openclaw(见 openclaw/src/config/paths.ts),若设 OPENCLAW_HOME=~/.openclaw 会产出 - # ~/.openclaw/.openclaw 嵌套层——后续 weixin/gateway CLI 调用都把 config/npm/openclaw.sqlite 写嵌套层, - # 外层正解路径反而空(本机实测真 bug)。OPENCLAW_STATE_DIR 引擎当直接 state dir、不 append。 + # bash does not understand backslashes (it would treat them as escapes), so we pass forward-slash + # paths to setup-crew.sh. + # WARNING: use OPENCLAW_STATE_DIR rather than OPENCLAW_HOME: the engine's resolveStateDir treats + # OPENCLAW_HOME as a homedir and then appends /.openclaw (see openclaw/src/config/paths.ts); if + # OPENCLAW_HOME is set to ~/.openclaw this produces a nested ~/.openclaw/.openclaw layer, and all + # subsequent weixin/gateway CLI calls write config/npm/openclaw.sqlite into that nested layer while + # the correct outer path stays empty (a real bug observed on a local machine). OPENCLAW_STATE_DIR + # is treated by the engine as a direct state dir and is not appended to. $env:OPENCLAW_STATE_DIR = ($OpenclawHome -replace '\\', '/') $env:XIAOBEI_BIN_DIR = ((Join-Path $Root "bin") -replace '\\', '/') + # setup-crew.sh uses OPENCLAW_HOME internally to resolve CONFIG_PATH (it does NOT read + # OPENCLAW_STATE_DIR), so we must explicitly pass a forward-slash path; otherwise bash falls back + # to $HOME/.openclaw, and under Git Bash's MSYS path conversion + a Chinese username this yields a + # bogus path like C:\c\Users\\.openclaw (ENOENT). + $env:OPENCLAW_HOME = ($OpenclawHome -replace '\\', '/') Invoke-Streamed { & $bashExe ($sh -replace '\\', '/') } - if ($LASTEXITCODE -ne 0) { Write-Warn "setup-crew.sh 非零退出(可后续手动 --force 修复)" } + # After setup-crew.sh finishes, clear OPENCLAW_HOME so subsequent install phases (gateway/daemon) + # do not inherit this value - the engine would treat OPENCLAW_HOME as a homedir and append + # /.openclaw, producing a nested layer. + Remove-Item Env:OPENCLAW_HOME -ErrorAction SilentlyContinue + if ($LASTEXITCODE -ne 0) { Write-Warn "setup-crew.sh exited non-zero (you can manually re-run with --force later to fix)" } else { Write-Ok "crew templates set up" } } -# ─── 8. camoufox-cli ─────────────────────────────────────────── +# --- 8. camoufox-cli --- function Install-CamoufoxCli { if ($SkipBrowser) { - Write-Host " [i] 跳过 camoufox-cli 浏览器二进制(XIAOBEI_SKIP_BROWSER=1);后续手动:camoufox-cli install" -ForegroundColor Yellow + Write-Host " [i] skipping camoufox-cli browser binary (XIAOBEI_SKIP_BROWSER=1); run manually later: camoufox-cli install" -ForegroundColor Yellow return } Write-Stage "Installing camoufox-cli browser" $fork = Join-Path $Root "camoufox-cli" - if (-not (Test-Path $fork)) { Write-Warn "camoufox-cli fork 不在 tarball 内:$fork;跳过"; return } - # $Root\bin 放 camoufox-cli.cmd shim,前置进 PATH 让下面 `camoufox-cli install` 找得到 + if (-not (Test-Path $fork)) { Write-Warn "camoufox-cli fork not in tarball: $fork; skipping"; return } + # Place a camoufox-cli.cmd shim in $Root\bin and prepend it to PATH so the `camoufox-cli install` + # below can be found. $env:PATH = "$(Join-Path $Root 'bin');$(Split-Path $NodeExe);$env:PATH" $env:npm_config_registry = "https://registry.npmmirror.com" $camoufoxBin = Join-Path $Root "bin\camoufox-cli.cmd" $cliJs = Join-Path $fork "dist\cli.js" $hasDeps = Test-Path (Join-Path $fork "node_modules\camoufox-js") - # fork 运行时依赖:dist 是 tsc 编译,camoufox-js/playwright-core/pdf-lib 是 external import, - # 运行时靠 fork 目录下的 node_modules 解析。tarball 为瘦身 ship 的 fork 不带 node_modules。 + # Runtime deps of the fork: dist is a tsc build, and camoufox-js/playwright-core/pdf-lib are + # external imports resolved at runtime via the node_modules under the fork directory. The fork + # shipped in the slim tarball does not carry node_modules. if (-not $hasDeps) { Push-Location $fork try { & $NpmCmd install --omit=dev - if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli fork deps install 失败"; return } + if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli fork deps install failed"; return } } finally { Pop-Location } } - # Windows 不走 `npm install -g $fork`:npm 把 fork 装进 $prefix\node_modules 时撞 fork 源目录 - # 本身(EEXIST: file already exists $Root\camoufox-cli)——Windows npm 不能像 Linux 那样 symlink - # 到源目录。改仿 openclaw.cmd 写 .cmd shim 直接跑 node $fork\dist\cli.js,fork 的 node_modules - # 已就位,Node 从 cli.js 向上解析依赖;camoufox-cli JS 用 __dirname 定位自身包根,行为与全局装一致。 - if (-not (Test-Path $cliJs)) { Write-Warn "camoufox-cli 入口未找到:$cliJs;跳过"; return } + # Windows does not go through `npm install -g $fork`: when npm installs the fork into + # $prefix\node_modules it collides with the fork's own source directory (EEXIST: file already + # exists $Root\camoufox-cli) - Windows npm cannot symlink to the source dir the way Linux does. + # Instead, mirroring openclaw.cmd, we write a .cmd shim that directly runs + # node $fork\dist\cli.js; the fork's node_modules is already in place and Node resolves deps + # upward from cli.js. camoufox-cli's JS uses __dirname to locate its own package root, so the + # behavior matches a global install. + if (-not (Test-Path $cliJs)) { Write-Warn "camoufox-cli entry not found: $cliJs; skipping"; return } $shimLines = @( '@echo off' 'setlocal' @@ -303,37 +365,37 @@ function Install-CamoufoxCli { Set-Content -Path $camoufoxBin -Value $shimLines -Encoding ASCII Write-Ok "camoufox-cli shim written: $camoufoxBin" - Write-Host " 下 Firefox binary(首次 ~557MB)..." + Write-Host " downloading Firefox binary (first time ~557MB)..." Invoke-Streamed { camoufox-cli install } - if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli install 失败;可后续手动:camoufox-cli install" } + if ($LASTEXITCODE -ne 0) { Write-Warn "camoufox-cli install failed; you can run manually later: camoufox-cli install" } else { Write-Ok "camoufox-cli ready" } } -# ─── 9. openclaw-weixin 插件 ────────────────────────────────── +# --- 9. openclaw-weixin plugin --- function Install-WeixinPlugin { Write-Stage "Installing WeChat plugin" - if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper 未找到:$ClawCmd;跳过"; return } + if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper not found: $ClawCmd; skipping"; return } $pkg = "@tencent-weixin/openclaw-weixin"; $ver = "2.4.6" $pin = Join-Path $Root "openclaw-weixin.version.json" if (Test-Path $pin) { try { $j = Get-Content $pin -Raw | ConvertFrom-Json $pkg = $j.'openclaw-weixin'.package; $ver = $j.'openclaw-weixin'.version - } catch { Write-Warn "pin 文件解析失败,用默认 $pkg@$ver" } + } catch { Write-Warn "pin file parse failed, using default $pkg@$ver" } } $env:npm_config_registry = "https://registry.npmmirror.com" $listOut = Capture-Streamed { & $ClawCmd plugins list } if ($listOut -match "openclaw-weixin") { Write-Ok "openclaw-weixin plugin already installed"; return } Invoke-Streamed { & $ClawCmd plugins install "$pkg@$ver" --pin } if ($LASTEXITCODE -eq 0) { Write-Ok "openclaw-weixin plugin installed" } - else { Write-Warn "插件安装失败;可后续手动:$ClawCmd plugins install $pkg@$ver --pin" } + else { Write-Warn "plugin install failed; you can run manually later: $ClawCmd plugins install $pkg@$ver --pin" } } -# ─── 10. awada 本地插件 deps(ws + zod)────────────────────── +# --- 10. awada local plugin deps (ws + zod) --- function Install-AwadaPlugin { Write-Stage "Installing awada plugin deps" $awada = Join-Path $Root "awada" - if (-not (Test-Path $awada)) { Write-Warn "awada 不在 tarball 内:$awada;跳过"; return } + if (-not (Test-Path $awada)) { Write-Warn "awada not in tarball: $awada; skipping"; return } $hasWs = Test-Path (Join-Path $awada "node_modules\ws") $hasZod = Test-Path (Join-Path $awada "node_modules\zod") if ($hasWs -and $hasZod) { Write-Ok "awada deps already installed"; return } @@ -342,16 +404,18 @@ function Install-AwadaPlugin { try { & $NpmCmd install --omit=dev if ($LASTEXITCODE -eq 0) { Write-Ok "awada deps installed" } - else { Write-Warn "awada deps install 失败;可后续手动:cd $awada ; npm install --omit=dev" } + else { Write-Warn "awada deps install failed; you can run manually later: cd $awada ; npm install --omit=dev" } } finally { Pop-Location } } -# ─── 10.5 修复 daemon 启动脚本编码(openclaw 上游 bug 的 workaround)── -# openclaw daemon install 写出的 gateway.cmd / gateway.vbs 是 UTF-8(无 BOM), -# 而 cmd.exe / WScript 按系统 ANSI 代码页读取脚本文件——安装路径含非 ASCII 字符 -# (如中文用户名)时路径变乱码,node.exe 找不到,gateway 永远起不来(本机实测)。 -# workaround:把长路径替换成 8.3 短路径(纯 ASCII)后以 ASCII 重写;无 8.3 名 -# 可用时退回按 ANSI 代码页写(cmd.exe 按 ANSI 读,编码一致即可)。幂等。 +# --- 10.5 Fix daemon launch-script encoding (workaround for an openclaw upstream bug) --- +# The gateway.cmd / gateway.vbs written out by `openclaw daemon install` are UTF-8 (no BOM), but +# cmd.exe / WScript read script files using the system ANSI code page - so when the install path +# contains non-ASCII chars (e.g. a Chinese username) the path turns into mojibake, node.exe cannot +# be found, and the gateway never starts (observed on a local machine). +# Workaround: replace long paths with their 8.3 short paths (pure ASCII) and rewrite as ASCII; if no +# 8.3 name is available, fall back to writing in the ANSI code page (cmd.exe reads it as ANSI, so the +# encodings line up). Idempotent. function Repair-GatewayCmd { $files = @((Join-Path $OpenclawHome "gateway.cmd"), (Join-Path $OpenclawHome "gateway.vbs")) $fso = $null @@ -370,51 +434,56 @@ function Repair-GatewayCmd { } else { [System.IO.File]::WriteAllText($f, $txt, [System.Text.ASCIIEncoding]::new()) } - Write-Ok "修复 $(Split-Path $f -Leaf) 编码(非 ASCII 路径 → 8.3 短路径)" + Write-Ok "fixed $(Split-Path $f -Leaf) encoding (non-ASCII path -> 8.3 short path)" } } -# ─── 11. 交互收 AWK_API_KEY + 起 gateway ────────────────────── -# env 分工(对齐 install.sh / 上游踩坑经验): -# ~/.openclaw/.env ← 业务变量(AWK_API_KEY/XIAOBEI_HOME/OPENCLAW_STATE_DIR),openclaw CLI 裸跑用 -# ~/.openclaw/daemon.env ← gateway service 用,只放 3 个固定值(OPENCLAW_BROWSER_TIMEOUT_MS/ -# OPENCLAW_DISABLE_BONJOUR/PATH)+ OPENCLAW_STATE_DIR -# 原因:Linux systemd 那套 EnvironmentFile= 加载器对含特殊字符的业务变量(PATH 带分号、 -# AWK_API_KEY 带连字符)有踩坑历史,业务变量挪 .env 让 systemd 只加载稳的固定值。 -# Windows 不走 systemd,gateway.cmd 的 `call daemon.env` 没这个坑,但为风格统一照此分工。 +# --- 11. Interactive AWK_API_KEY prompt + start gateway --- +# Env division (mirrors install.sh / upstream lessons learned): +# ~/.openclaw/.env <- business vars (AWK_API_KEY/XIAOBEI_HOME/OPENCLAW_STATE_DIR), sourced by the +# openclaw CLI when run bare +# ~/.openclaw/daemon.env <- used by the gateway service, holds only 3 fixed values +# (OPENCLAW_BROWSER_TIMEOUT_MS/OPENCLAW_DISABLE_BONJOUR/PATH) + OPENCLAW_STATE_DIR +# Reason: the Linux systemd EnvironmentFile= loader has a history of choking on business vars that +# contain special chars (PATH with semicolons, AWK_API_KEY with hyphens), so business vars were moved +# to .env and systemd only loads the stable fixed values. Windows does not use systemd and +# gateway.cmd's `call daemon.env` does not have this pitfall, but we keep the same division for +# consistency. function Install-GatewayAndEnv { Write-Stage "Configuring API key and gateway" New-Item -ItemType Directory -Force -Path $OpenclawHome | Out-Null $dotEnv = Join-Path $OpenclawHome ".env" $daemonEnv = Join-Path $OpenclawHome "daemon.env" - # ─── 检测/清掉错误的 OPENCLAW_HOME 用户环境变量 ─────────────── - # 引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir 再 append /.openclaw(见 - # openclaw/src/config/paths.ts),故若 OPENCLAW_HOME 已被设成 ~/.openclaw,会产出 - # ~/.openclaw/.openclaw 嵌套路径——openclaw.json、daemon.env、lastTouch 全落嵌套层, - # 外层正解路径反而空(本机实测过的真 bug)。state dir 应该用 OPENCLAW_STATE_DIR 显式 - # 覆盖(引擎把它当直接 state dir、不 append),OPENCLAW_HOME 不该在用户环境里设。 + # --- Detect / clear a bogus OPENCLAW_HOME user env var --- + # The engine's resolveStateDir treats OPENCLAW_HOME as a homedir and then appends /.openclaw (see + # openclaw/src/config/paths.ts), so if OPENCLAW_HOME is set to ~/.openclaw this produces a nested + # ~/.openclaw/.openclaw path - openclaw.json, daemon.env and lastTouch all land in the nested + # layer while the correct outer path stays empty (a real bug observed on a local machine). The + # state dir should be explicitly overridden via OPENCLAW_STATE_DIR (the engine treats it as a + # direct state dir and does not append); OPENCLAW_HOME should not be set in the user environment. $badHome = $env:OPENCLAW_HOME if ($badHome -and ($badHome.TrimEnd('\').ToLower() -eq $OpenclawHome.TrimEnd('\').ToLower())) { - Write-Warn "检测到 OPENCLAW_HOME=$badHome 已设成 state dir 路径" - Write-Warn " 这会让引擎嵌套 append /.openclaw → ~/.openclaw/.openclaw/,config 全落错位置" - Write-Warn " 正解:清掉 OPENCLAW_HOME,改用 OPENCLAW_STATE_DIR 显式指定 state dir" + Write-Warn "detected OPENCLAW_HOME=$badHome is set to the state dir path" + Write-Warn " this makes the engine nest-append /.openclaw -> ~/.openclaw/.openclaw/, all config lands in the wrong place" + Write-Warn " fix: clear OPENCLAW_HOME and use OPENCLAW_STATE_DIR to explicitly specify the state dir" try { [Environment]::SetEnvironmentVariable("OPENCLAW_HOME", $null, "User") [Environment]::SetEnvironmentVariable("OPENCLAW_HOME", $null, "Process") Remove-Item Env:OPENCLAW_HOME -ErrorAction SilentlyContinue - Write-Ok "已从用户环境变量清掉 OPENCLAW_HOME(新终端生效)" - } catch { Write-Warn "清 OPENCLAW_HOME 失败,请手动删:[Environment]::SetEnvironmentVariable('OPENCLAW_HOME', `$null, 'User')" } + Write-Ok "cleared OPENCLAW_HOME from user env vars (takes effect in a new terminal)" + } catch { Write-Warn "failed to clear OPENCLAW_HOME, please delete manually: [Environment]::SetEnvironmentVariable('OPENCLAW_HOME', `$null, 'User')" } } - # ─── 交互收 AWK_API_KEY ─────────────────────────────────── + # --- Interactive AWK_API_KEY prompt --- $awkKey = $env:AWK_API_KEY if (-not $NoPrompt -and -not $awkKey) { $awkKey = Read-Host "Enter AWK_API_KEY (Volces ARK API key)" } - # ─── 写 .env(业务变量,export 格式给 bash/sh source 用,CLI 裸跑读这个)──── - # 幂等:先剥同 key 旧行再追加。AWK_API_KEY 是 secret,单引号裹 + '\'' 转义内置单引号。 + # --- Write .env (business vars, export format for bash/sh source, read by the bare CLI) --- + # Idempotent: strip old lines with the same key first, then append. AWK_API_KEY is a secret, so + # it is wrapped in single quotes with any embedded single quote escaped as '\''. $exportLines = @() if (Test-Path $dotEnv) { $exportLines = Get-Content $dotEnv } $exportLines = $exportLines | Where-Object { $_ -notmatch "^export AWK_API_KEY=" -and $_ -notmatch "^export XIAOBEI_HOME=" -and $_ -notmatch "^export OPENCLAW_STATE_DIR=" } @@ -427,10 +496,11 @@ function Install-GatewayAndEnv { $homeEsc = $OpenclawHome -replace "'", "'\''" $exportLines += "export OPENCLAW_STATE_DIR='$homeEsc'" Set-Content -Path $dotEnv -Value $exportLines -Encoding UTF8 - Write-Ok ".env written (业务变量,CLI 裸跑用)" + Write-Ok ".env written (business vars, used by the bare CLI)" - # ─── 写 daemon.env(gateway service 用,KEY=value 格式给 gateway.cmd call 用)── - # 只放 3 个固定值 + OPENCLAW_STATE_DIR(gateway 子进程需要 state dir 消歧嵌套)。 + # --- Write daemon.env (used by the gateway service, KEY=value format for gateway.cmd to call) --- + # Holds only 3 fixed values + OPENCLAW_STATE_DIR (the gateway subprocess needs the state dir to + # disambiguate the nested layer). $daemonLines = @() if (Test-Path $daemonEnv) { $daemonLines = Get-Content $daemonEnv } $daemonLines = $daemonLines | Where-Object { $_ -notmatch "^OPENCLAW_BROWSER_TIMEOUT_MS=" -and $_ -notmatch "^OPENCLAW_DISABLE_BONJOUR=" -and $_ -notmatch "^PATH=" -and $_ -notmatch "^OPENCLAW_STATE_DIR=" } @@ -440,36 +510,37 @@ function Install-GatewayAndEnv { $pathLine = "PATH=$(Join-Path $Root 'bin');$(Split-Path $NodeExe);$env:PATH" $daemonLines += $pathLine Set-Content -Path $daemonEnv -Value $daemonLines -Encoding UTF8 - Write-Ok "daemon.env written (gateway service 用,3 固定值 + PATH + OPENCLAW_STATE_DIR)" + Write-Ok "daemon.env written (used by the gateway service, 3 fixed values + PATH + OPENCLAW_STATE_DIR)" - # 把 .env 业务变量加载进当前 install shell,让后续 daemon install / gateway restart / channels login - # 校验 config 时能解析到 AWK_API_KEY / XIAOBEI_HOME(CLI 裸跑本会 . .env,install shell 也得有) + # Load the .env business vars into the current install shell so that subsequent daemon install / + # gateway restart / channels login config validation can resolve AWK_API_KEY / XIAOBEI_HOME (the + # bare CLI would normally `. .env`, and the install shell needs them too). if ($awkKey) { $env:AWK_API_KEY = $awkKey } $env:XIAOBEI_HOME = $Root $env:OPENCLAW_STATE_DIR = $OpenclawHome - # setx 用户环境变量(让新终端 / gateway 子进程继承 AWK_API_KEY) + # setx the user env var (so new terminals / gateway subprocesses inherit AWK_API_KEY) if ($awkKey) { & setx AWK_API_KEY "$awkKey" | Out-Null - Write-Ok "AWK_API_KEY set as user env var (新终端生效)" + Write-Ok "AWK_API_KEY set as user env var (takes effect in a new terminal)" } - # 尝试 daemon install(Windows 支持情况视 openclaw 版本而定) - Write-Host " 尝试 openclaw daemon install..." + # Attempt daemon install (Windows support depends on the openclaw version) + Write-Host " attempting openclaw daemon install..." Invoke-Streamed { & $ClawCmd daemon install } if ($LASTEXITCODE -eq 0) { Write-Ok "gateway daemon installed" Repair-GatewayCmd Invoke-Streamed { & $ClawCmd gateway restart } } else { - Write-Warn "openclaw daemon install 在 Windows 未就绪或失败" - Write-Host " 请开新 PowerShell 终端前台跑 gateway:" + Write-Warn "openclaw daemon install is not ready or failed on Windows" + Write-Host " please open a new PowerShell terminal and run gateway in the foreground:" Write-Host " set AWK_API_KEY=$awkKey" Write-Host " $ClawCmd gateway start" } } -# ─── 12. 自动出微信绑定二维码(首装末尾)────────────────────── +# --- 12. Auto-show WeChat binding QR code (end of first install) --- function Test-WeixinBound { $paths = @( (Join-Path $OpenclawHome "openclaw-weixin\accounts.json"), @@ -481,38 +552,39 @@ function Test-WeixinBound { function Bind-WeixinChannel { if ($SkipBind -or $NoPrompt) { - Write-Host " [i] 跳过微信扫码绑定(XIAOBEI_SKIP_BIND / XIAOBEI_NO_PROMPT=1);后续手动跑:openclaw channels login --channel openclaw-weixin" -ForegroundColor Yellow + Write-Host " [i] skipping WeChat scan-to-bind (XIAOBEI_SKIP_BIND / XIAOBEI_NO_PROMPT=1); run manually later: openclaw channels login --channel openclaw-weixin" -ForegroundColor Yellow return } - if (Test-WeixinBound) { Write-Ok "检测到微信账号已绑定,跳过扫码"; return } - if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper 未找到($ClawCmd),跳过微信绑定"; return } - Write-Stage "绑定微信 channel(用手机扫码)" - Write-Host " 接下来会出二维码,用微信扫一下、点确认,小贝就能用了。" - Write-Host " 扫码慢没关系,二维码会自动刷新;扫完即继续。" + if (Test-WeixinBound) { Write-Ok "WeChat account already bound, skipping scan"; return } + if (-not (Test-Path $ClawCmd)) { Write-Warn "openclaw wrapper not found ($ClawCmd), skipping WeChat binding"; return } + Write-Stage "Binding WeChat channel (scan with your phone)" + Write-Host " A QR code will appear next; scan it with WeChat and tap confirm, then xiaobei is ready to use." + Write-Host " Take your time scanning - the QR code auto-refreshes; just continue once scanned." Write-Host "" for ($i = 1; $i -le 5; $i++) { & $ClawCmd channels login --channel openclaw-weixin - if (Test-WeixinBound) { Write-Ok "微信账号绑定成功"; return } - if ($i -lt 5) { Write-Warn "本轮未检测到绑定,重出二维码(第 $($i + 1) 次)..." } + if (Test-WeixinBound) { Write-Ok "WeChat account bound successfully"; return } + if ($i -lt 5) { Write-Warn "no binding detected this round, re-showing QR code (attempt $($i + 1))..." } } - Write-Warn "多次扫码未完成绑定。可后续手动跑:$ClawCmd channels login --channel openclaw-weixin" + Write-Warn "binding not completed after multiple scans. You can run manually later: $ClawCmd channels login --channel openclaw-weixin" } -# ─── main ───────────────────────────────────────────────────── +# --- main --- function Main { - Write-Host "wiseflow installer (Windows, GitHub) — 预构建 tarball 路线" -ForegroundColor Magenta + Write-Host "wiseflow installer (Windows, GitHub) - prebuilt tarball route" -ForegroundColor Magenta Write-Host " Program dir : $Root" Write-Host " Runtime dir : $OpenclawHome" Write-Host " Repo : $Repo (GitHub)" - # 检测是否已装(决定走 update 还是 fresh install) + # Detect whether already installed (decides update vs fresh install) $cfgExisting = Join-Path $OpenclawHome "openclaw.json" $isUpdate = (Test-Path $cfgExisting) -and -not $Force if ($isUpdate) { - Write-Warn "检测到已有安装($cfgExisting)→ 走更新路线,保留运行数据(XIAOBEI_FORCE=1 可强覆盖)" - # 先停 gateway(对齐 install.sh stop_gateway_if_running):否则 tar 覆盖 - # tools\node\node.exe、pnpm 重写 node_modules 会撞运行中 gateway 的文件锁 - # ("Can't unlink already-existing object: Permission denied",本机实测) + Write-Warn "detected existing install ($cfgExisting) -> taking the update route, preserving runtime data (XIAOBEI_FORCE=1 to force overwrite)" + # Stop the gateway first (mirrors install.sh stop_gateway_if_running): otherwise tar + # overwriting tools\node\node.exe and pnpm rewriting node_modules will hit the running + # gateway's file locks ("Can't unlink already-existing object: Permission denied", observed + # in practice). Write-Stage "Stopping gateway before update" if (Test-Path $ClawCmd) { Invoke-Streamed { & $ClawCmd gateway stop } } Get-Process node -ErrorAction SilentlyContinue | @@ -537,7 +609,8 @@ function Main { Install-WeixinPlugin if ($isUpdate) { - # 更新路线也自愈 config(对齐 install.sh:openclaw.json 若被极简化则用 template 修复) + # The update route also self-heals the config (mirrors install.sh: if openclaw.json was + # minimized, repair it with the template). Place-Config Write-Stage "Refreshing gateway env and restarting" $envFile = Join-Path $OpenclawHome "daemon.env" @@ -559,14 +632,14 @@ function Main { Write-Host "" if ($isUpdate) { - Write-Host "🦞 wiseflow updated successfully!" -ForegroundColor Green + Write-Host "wiseflow updated successfully!" -ForegroundColor Green } else { - Write-Host "🦞 wiseflow installed successfully!" -ForegroundColor Green + Write-Host "wiseflow installed successfully!" -ForegroundColor Green } Write-Host "" Write-Host "Next steps:" -ForegroundColor Cyan $binDir = Join-Path $Root 'bin' - Write-Host " 把 $binDir 加到用户 PATH(安全方式,勿用 setx %PATH% 会截断):" + Write-Host " Add $binDir to the user PATH (safe way - do NOT use setx %PATH%, it truncates):" Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$binDir;`" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')" Write-Host "" Write-Host " Dashboard: http://127.0.0.1:18789" diff --git a/scripts/install.sh b/scripts/install.sh index b8de9230..04eb20d3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -388,9 +388,19 @@ download_and_extract_tarball() { ui_kv "Asset" "$asset" ui_kv "URL" "$url" mkdir -p "$cache_dir" + # 原子写:先下到 .part 临时文件,成功后 rename 到 cached, + # 避免断网留半截损坏文件导致下次重跑命中损坏缓存。 + local part="$cached.part" + rm -f "$part" ui_info "Downloading $asset (~120MB) → $cached ..." - download_file "$url" "$cached" - ui_success "Downloaded" + if download_file "$url" "$part"; then + mv -f "$part" "$cached" + ui_success "Downloaded" + else + rm -f "$part" + ui_error "下载失败:$url" + exit 1 + fi tmp="$cached" fi @@ -465,12 +475,41 @@ install_python_deps() { ui_info "Installing python skill deps via $PY_BIN (--user)" # --no-warn-script-location 抑制 'script installed in .../bin which is not on PATH'(小白噪音) # --disable-pip-version-check 抑制 pip 升级提示 - printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ - "$PY_BIN" -m pip install --user \ - --no-warn-script-location --disable-pip-version-check \ - -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ - -r /dev/stdin || \ + # PEP 668 兜底:Homebrew/Debian 等 externally-managed 环境下,pip install --user 仍被拒, + # 需加 --break-system-packages 才能写用户目录(--user 限目标,不碰系统 site-packages,安全)。 + local pep668_flag="" + local pyver; pyver="$("$PY_BIN" -c 'import sys;print("%d.%d"%sys.version_info[:2])' 2>/dev/null || echo "")" + if [ -n "$pyver" ]; then + local inc_dir + inc_dir="$("$PY_BIN" -c 'import sysconfig;print(sysconfig.get_path("include"))' 2>/dev/null || echo "")" + local sys_prefix + sys_prefix="$("$PY_BIN" -c 'import sys;print(sys.prefix)' 2>/dev/null || echo "")" + # EXTERNALLY-MANAGED 标记文件在 stdlib 目录(python3.x/)下 + if [ -n "$inc_dir" ] && [ -f "$(dirname "$inc_dir")/EXTERNALLY-MANAGED" ]; then + pep668_flag="--break-system-packages" + ui_info "检测到 PEP 668 externally-managed 环境,加 --break-system-packages" + fi + fi + # 兜底:上面探测失败时,pip 报 externally-managed-environment 退出码 1,重试加 flag + local pip_args=(--user --no-warn-script-location --disable-pip-version-check + -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com) + [ -n "$pep668_flag" ] && pip_args+=("$pep668_flag") + if ! printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ + "$PY_BIN" -m pip install "${pip_args[@]}" -r /dev/stdin; then + # 首次没加 flag 且报 externally-managed,重试加 flag + if [ -z "$pep668_flag" ]; then + ui_warn "pip install 失败,尝试加 --break-system-packages 重试..." + if printf '%s' "$merged" | sort -u | grep -vE '^\s*#|^\s*$' | \ + "$PY_BIN" -m pip install --user --break-system-packages \ + --no-warn-script-location --disable-pip-version-check \ + -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + -r /dev/stdin; then + ui_success "Python deps done (with --break-system-packages)" + return 0 + fi + fi ui_warn "pip install 部分失败,可稍后手动补" + fi ui_success "Python deps done" } @@ -1681,14 +1720,16 @@ install_gateway_and_env() { local systemd_env="$OPENCLAW_HOME/daemon.env" local macos_env="$OPENCLAW_HOME/service-env/ai.openclaw.gateway.env" - # ─── 检测/清掉错误的 OPENCLAW_HOME 用户环境变量 ───────────── + # ─── 提示用户清掉错误的 OPENCLAW_HOME 环境变量 ───────────── # 引擎 resolveStateDir 把 OPENCLAW_HOME 当 homedir 再 append /.openclaw(见 - # openclaw/src/config/paths.ts),故若 OPENCLAW_HOME 已被设成 ~/.openclaw,会产出嵌套路径。 + # openclaw/src/config/paths.ts),故若用户 shell export 了 OPENCLAW_HOME=~/.openclaw, + # 会产出嵌套路径。本脚本顶部已把 OPENCLAW_HOME 设成内部变量且未 export,不会传给引擎子进程, + # 所以这里只打 warning 提示用户清理 shell 环境,不能 unset——unset 会误伤脚本内部变量, + # 导致后续 ${OPENCLAW_HOME//...} 在 set -u 下报 unbound variable。 if [ -n "${OPENCLAW_HOME:-}" ] && [ "$(cd "${OPENCLAW_HOME}" && pwd)" = "$(cd "$HOME/.openclaw" && pwd)" ]; then ui_warn "检测到 OPENCLAW_HOME=$OPENCLAW_HOME 已设成 state dir 路径" ui_warn " 这会让引擎嵌套 append /.openclaw → ~/.openclaw/.openclaw/,config 全落错位置" ui_warn " 正解:清掉 OPENCLAW_HOME,改用 OPENCLAW_STATE_DIR 显式指定 state dir" - unset OPENCLAW_HOME fi # redirect stdin from /dev/tty so interactive read 工作于 curl|bash