#!/bin/sh # SayDo 一键安装(macOS / Linux)。 # curl -fsSL https://saydo.octoooo.com/install.sh | sh # 只在用户目录内安装,不需要 sudo,不改系统 Node: # ~/.saydo/toolchain/ 本脚本下载的 Node 22(仅当本机没有 Node 22 时)与 saydo 包 # ~/.saydo/bin/saydo 启动器 # 可选环境变量: # SAYDO_INSTALL_NO_MODIFY_PATH=1 不改 shell 启动文件(默认追加一行 PATH,带标记、可重复执行) # SAYDO_INSTALL_RUN=1 安装完成后直接 `saydo up` # SAYDO_HOME= 数据与安装根目录(默认 ~/.saydo;必须在 $HOME 之下,除非 SAYDO_INSTALL_ALLOW_OUTSIDE_HOME=1) # SAYDO_INSTALL_MIRROR=1 直接用官网镜像下载 SayDo 包(默认先 GitHub Release,连不上时自动改用镜像;两者 SHA-256 相同) # SAYDO_INSTALL_NPM_REGISTRY= 安装依赖用的 npm registry(默认 registry.npmjs.org;国内可设 https://registry.npmmirror.com) # 卸载:rm -rf ~/.saydo/toolchain ~/.saydo/bin 并删除 shell 启动文件里带 "# saydo" 标记的那一行。 set -eu SAYDO_VERSION="0.1.0-rc.13" SAYDO_TGZ_SHA256="541193d2289831874cf52ac8ef587a0347d07b95daf802802f4ffb1382e80c22" SAYDO_TGZ_URL="https://github.com/Octo-o-o-o/SayDo/releases/download/v${SAYDO_VERSION}/saydo-cli-${SAYDO_VERSION}.tgz" SAYDO_TGZ_MIRROR_URL="https://dl.saydo.octoooo.com/releases/v${SAYDO_VERSION}/saydo-cli-${SAYDO_VERSION}.tgz" BETTER_SQLITE3_MIRROR="https://npmmirror.com/mirrors/better-sqlite3" NODE_MAJOR="22" NODE_DIST="https://nodejs.org/dist" # Node 官方源连不上时的镜像(目录结构与 nodejs.org/dist 相同;仍按官方 SHASUMS256 校验)。SAYDO_INSTALL_MIRROR=1 直接用镜像。 NODE_DIST_MIRROR="https://npmmirror.com/mirrors/node" ok() { printf '[ok] %s\n' "$*"; } info() { printf '[..] %s\n' "$*"; } fail() { printf '[fail] %s\n' "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令 $1(需要 curl 与 tar)"; } need curl need tar [ -n "${HOME:-}" ] || fail "缺少 HOME 环境变量,无法确定用户目录" case "$HOME" in /*) ;; *) fail "HOME 必须是绝对路径:$HOME" ;; esac SAYDO_HOME="${SAYDO_HOME:-$HOME/.saydo}" case "$SAYDO_HOME" in /*) ;; *) fail "SAYDO_HOME 必须是绝对路径:$SAYDO_HOME" ;; esac # 只在用户目录内写文件:安装根目录必须位于 $HOME 之下;确需其他位置须显式 SAYDO_INSTALL_ALLOW_OUTSIDE_HOME=1。 # 词法约束在任何写入之前做:含 ".." 的路径一律拒绝(否则 $HOME/../../var 之类会绕过前缀判断); # 再用 POSIX 自带的 `cd -P && pwd -P`(不依赖 realpath)按解析后的真实路径复核一次:symlink 越出 HOME 同样拒绝。 case "/$SAYDO_HOME/" in */../*) fail "SAYDO_HOME 不得包含 .. 路径段:$SAYDO_HOME" ;; esac case "/$HOME/" in */../*) fail "HOME 不得包含 .. 路径段:$HOME" ;; esac if [ "${SAYDO_INSTALL_ALLOW_OUTSIDE_HOME:-0}" != "1" ]; then case "$SAYDO_HOME" in "$HOME"|"$HOME"/*) ;; *) fail "SAYDO_HOME 不在用户目录 $HOME 之下:$SAYDO_HOME(本脚本只在用户目录内安装;确需其他位置请设 SAYDO_INSTALL_ALLOW_OUTSIDE_HOME=1)" ;; esac [ -d "$HOME" ] || fail "HOME 不是目录:$HOME" real_home="$(cd -P -- "$HOME" 2>/dev/null && pwd -P)" || fail "无法解析 HOME 的真实路径:$HOME" existing="$SAYDO_HOME" while [ ! -d "$existing" ] && [ "$existing" != "/" ]; do existing="$(dirname "$existing")"; done real_existing="$(cd -P -- "$existing" 2>/dev/null && pwd -P)" || fail "无法解析 SAYDO_HOME 已存在祖先的真实路径:$existing" case "$real_existing" in "$real_home"|"$real_home"/*) ;; *) fail "SAYDO_HOME 解析后不在用户目录之下:$real_existing(symlink 越出 $real_home)" ;; esac fi TOOLCHAIN="$SAYDO_HOME/toolchain" PREFIX="$TOOLCHAIN/prefix" BIN_DIR="$SAYDO_HOME/bin" mkdir -p "$TOOLCHAIN" "$BIN_DIR" os="$(uname -s)" arch="$(uname -m)" case "$os" in Darwin) node_os="darwin" ;; Linux) node_os="linux" ;; *) fail "不支持的系统:$os(Windows 请用 install.ps1)" ;; esac case "$arch" in arm64|aarch64) node_arch="arm64" ;; x86_64|amd64) node_arch="x64" ;; *) fail "不支持的 CPU 架构:$arch" ;; esac sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 else fail "缺少 sha256sum / shasum,无法校验下载"; fi } node_major_of() { "$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true; } # 1. 选 Node:本机已有 Node 22 直接用;否则下载官方 Node 22 到 toolchain(用户目录内)。 NODE_BIN="" if command -v node >/dev/null 2>&1 && [ "$(node_major_of "$(command -v node)")" = "$NODE_MAJOR" ]; then NODE_BIN="$(command -v node)" ok "使用本机 Node $("$NODE_BIN" -v)($NODE_BIN)" else for d in "$TOOLCHAIN"/node-v"$NODE_MAJOR".*; do [ -x "$d/bin/node" ] && [ "$(node_major_of "$d/bin/node")" = "$NODE_MAJOR" ] && NODE_BIN="$d/bin/node" done if [ -z "$NODE_BIN" ]; then info "本机没有 Node $NODE_MAJOR,下载官方 Node $NODE_MAJOR 到 $TOOLCHAIN(不改系统;约 50 MB)" # 先官方源,连不上(10 秒连接超时)自动改用镜像;SAYDO_INSTALL_MIRROR=1 直接用镜像。 node_src="$NODE_DIST" if [ "${SAYDO_INSTALL_MIRROR:-0}" = "1" ] || ! curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$NODE_DIST/index.json"; then node_src="$NODE_DIST_MIRROR" info "nodejs.org 连不上或已指定镜像,改用 $NODE_DIST_MIRROR" fi node_ver="$(curl -fsSL --connect-timeout 10 --max-time 60 "$node_src/index.json" | tr -d ' \n' | grep -o '"version":"v'"$NODE_MAJOR"'\.[0-9]*\.[0-9]*"' | head -1 | cut -d'"' -f4)" [ -n "$node_ver" ] || fail "无法从 $node_src 解析 Node $NODE_MAJOR 版本(检查网络,或先自行安装 Node 22 再重跑)" node_pkg="node-${node_ver}-${node_os}-${node_arch}.tar.gz" tmp="$(mktemp -d "${TMPDIR:-/tmp}/saydo-node.XXXXXX")" # -# 显示进度条;--connect-timeout 避免无提示地挂住;--retry 应对瞬断。 curl -fL -# --connect-timeout 15 --retry 2 -o "$tmp/$node_pkg" "$node_src/$node_ver/$node_pkg" || fail "Node 下载失败:$node_src/$node_ver/$node_pkg(可先自行安装 Node 22 再重跑)" curl -fsSL --connect-timeout 15 --retry 2 -o "$tmp/SHASUMS256.txt" "$node_src/$node_ver/SHASUMS256.txt" || fail "Node 校验文件下载失败" expected="$(grep " $node_pkg\$" "$tmp/SHASUMS256.txt" | cut -d' ' -f1)" [ -n "$expected" ] || fail "SHASUMS256.txt 里没有 $node_pkg" actual="$(sha256_of "$tmp/$node_pkg")" [ "$actual" = "$expected" ] || fail "Node 下载校验失败:$actual != $expected" tar -xzf "$tmp/$node_pkg" -C "$TOOLCHAIN" rm -rf "$tmp" NODE_BIN="$TOOLCHAIN/node-${node_ver}-${node_os}-${node_arch}/bin/node" [ -x "$NODE_BIN" ] || fail "Node 解压后未找到 $NODE_BIN" fi ok "使用 toolchain Node $("$NODE_BIN" -v)($NODE_BIN)" fi NODE_DIR="$(cd "$(dirname "$NODE_BIN")" && pwd)" NPM_CLI="$NODE_DIR/../lib/node_modules/npm/bin/npm-cli.js" [ -f "$NPM_CLI" ] || NPM_CLI="$(cd "$NODE_DIR/.." && pwd)/lib/node_modules/npm/bin/npm-cli.js" [ -f "$NPM_CLI" ] || fail "找不到与 Node 配套的 npm:$NPM_CLI" # 2. 下载 SayDo CLI 包(固定版本 + SHA-256 校验),装进用户目录内的独立 prefix。 tgz="$TOOLCHAIN/saydo-cli-${SAYDO_VERSION}.tgz" used_mirror=0 if [ ! -f "$tgz" ] || [ "$(sha256_of "$tgz")" != "$SAYDO_TGZ_SHA256" ]; then rm -f "$tgz.part" if [ "${SAYDO_INSTALL_MIRROR:-0}" != "1" ] && curl -fsSL --connect-timeout 10 -o "$tgz.part" "$SAYDO_TGZ_URL"; then info "已从 GitHub Release 下载 SayDo CLI ${SAYDO_VERSION}" else info "GitHub Release 不可达或已指定镜像,改用官网镜像下载 SayDo CLI ${SAYDO_VERSION}" rm -f "$tgz.part" curl -fsSL --connect-timeout 20 -o "$tgz.part" "$SAYDO_TGZ_MIRROR_URL" used_mirror=1 fi mv "$tgz.part" "$tgz" fi actual="$(sha256_of "$tgz")" [ "$actual" = "$SAYDO_TGZ_SHA256" ] || fail "SayDo 包校验失败:$actual != $SAYDO_TGZ_SHA256" ok "SayDo CLI ${SAYDO_VERSION} 包校验通过" info "安装到 $PREFIX(npm install --global --prefix,不需要 sudo)" # 依赖里的 better-sqlite3 预构建二进制默认从 GitHub 下载;GitHub 不可达(已回退镜像)时改用 npmmirror 的同名镜像。 if [ "$used_mirror" = "1" ] && [ -z "${npm_config_better_sqlite3_binary_host_mirror:-}" ]; then export npm_config_better_sqlite3_binary_host_mirror="$BETTER_SQLITE3_MIRROR" fi if [ -n "${SAYDO_INSTALL_NPM_REGISTRY:-}" ]; then export npm_config_registry="$SAYDO_INSTALL_NPM_REGISTRY" fi PATH="$NODE_DIR:$PATH" "$NODE_BIN" "$NPM_CLI" install --global --prefix "$PREFIX" --no-fund --no-audit --loglevel=error "$tgz" CLI_MJS="$PREFIX/lib/node_modules/@saydo/cli/dist/cli.mjs" [ -f "$CLI_MJS" ] || fail "安装后未找到 $CLI_MJS" # 3. 启动器:固定用上面选定的 Node,不依赖当时 PATH 上是哪个 node。 cat > "$BIN_DIR/saydo" </dev/null 2>&1 || true ok "已安装:$BIN_DIR/saydo" # 4. PATH:默认在 shell 启动文件追加一行(带 "# saydo" 标记;整行精确匹配已存在则跳过,可重复执行); # SAYDO_INSTALL_NO_MODIFY_PATH=1 跳过。路径一律加引号,HOME 含空格也安全。 append_path_line() { rc_file="$1" rc_line="$2" if [ -f "$rc_file" ] && grep -Fxq -- "$rc_line" "$rc_file"; then return 0 fi mkdir -p "$(dirname "$rc_file")" printf '\n%s\n' "$rc_line" >> "$rc_file" ok "已把 $BIN_DIR 写入 $rc_file(下次打开终端生效)" } if [ "${SAYDO_INSTALL_NO_MODIFY_PATH:-0}" != "1" ]; then shell_name="$(basename "${SHELL:-sh}")" posix_line="export PATH=\"$BIN_DIR:\$PATH\" # saydo" case "$shell_name" in zsh) append_path_line "$HOME/.zshrc" "$posix_line" ;; bash) append_path_line "$HOME/.bashrc" "$posix_line"; append_path_line "$HOME/.bash_profile" "$posix_line" ;; fish) append_path_line "$HOME/.config/fish/config.fish" "set -gx PATH \"$BIN_DIR\" \$PATH # saydo" ;; *) append_path_line "$HOME/.profile" "$posix_line" ;; esac fi printf '\n' ok "安装完成。下一步:" printf ' 1. 准备一个 AI 供给(二选一;第一次打开控制台时向导会检测):\n' printf ' - 本机 AI CLI 已安装并登录,例如 codex login / claude auth login / cursor-agent login\n' printf ' - 或一个 OpenAI 兼容 API key(OpenRouter / OpenAI / Anthropic / DeepSeek),在向导里填\n' printf ' 2. 启动:新终端里运行 saydo up(浏览器会打开 http://localhost:47100 的控制台)\n' printf ' 本终端可直接:%s up\n' "$BIN_DIR/saydo" printf ' 不想自动开浏览器加 --no-open,之后用 saydo open;Ctrl+C 优雅停止。\n' printf ' 该包只含 daemon 与 Web 控制台;语音 pipeline 与系统常驻不在其中(打字与浏览器系统语音可用)。\n' if [ "${SAYDO_INSTALL_RUN:-0}" = "1" ]; then exec "$BIN_DIR/saydo" up fi