From d8209264914605f69ebab6628e4b1398534850fa Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Mon, 5 Oct 2026 19:27:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20install.sh=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=80=E8=A1=8C=E8=BF=9C=E7=A8=8B=E5=AE=89=E8=A3=85=EF=BC=88?= =?UTF-8?q?=E4=BB=8E=20GitHub=20=E5=8F=96=E8=84=9A=E6=9C=AC=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=8C=85=EF=BC=8C=E5=B9=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=93=88=E5=B8=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户侧只要一行: sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh)" 为让它成立改了四处(原来只支持"本地有脚本 + 本地有安装包"): - usage():读不到脚本文件时(bash -c "$(curl …)" / curl|bash)打印内置参数表, 不再去 awk 一个不存在的文件; - SCRIPT_PATH / SCRIPT_DIR:容忍非文件执行;与脚本同目录的模板本来也由安装包提供; - 交互问答统一走 /dev/tty(新增 read_prompt / can_prompt):原来判断 [ ! -t 0 ] 会让 curl|bash 下的所有提问被**静默跳过、直接按默认值装完**;并且刻意不退回 stdin —— 管道模式里 stdin 就是脚本本身,从 stdin 读会把脚本的下一行当成用户输入吃掉; - fetch_package:没给 --sha256 时自动取发布页上同名的 .sha256 来校验。一行安装的用户 不会自己去核哈希,不补这一步"校验"就只是把哈希记进日志;取不到时明确告警,而不是 假装校验过了。 文档:README 增加一行安装入口;docs/linux-deploy.md 的开头同样补上(该文件另有一处 改动不属于本次提交,故未纳入)。 --- README.md | 11 ++++ deploy/install.sh | 148 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1209783..5104d30 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/ | 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` | | 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `release-server/classhelper-server/` | +**Linux 服务器最快路径是一行命令**:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的 +`.sha256` 校验,再装成 systemd 服务并带上 `classhelper` 运维命令。完整步骤与验收清单见 +[docs/linux-deploy.md](docs/linux-deploy.md)。 + +```bash +sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh)" +``` + +> 用 `bash -c "$(curl …)"` 而不是 `curl … | bash`:后者的 stdin 被脚本内容占用,交互问答会读不到输入。 +> 只想先看看这台机器能不能装:把末尾换成 `--check` 并去掉 `sudo`(只读,不改动系统)。 + ## 3. 快速开始 ```bash diff --git a/deploy/install.sh b/deploy/install.sh index 8f62284..853b781 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -9,7 +9,21 @@ # native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL # docker —— 用随包的 Dockerfile 构建镜像 + compose 起容器(需要 docker) # -# 用法: +# 用法(一行安装,推荐给最终用户): +# bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh)" +# +# 脚本会自动从 GitHub Release 取**最新版**服务端安装包,并取发布页上同名的 .sha256 做校验, +# 所以整条链路只需要这一行。常见变体(都加在同一行末尾): +# … --version 1.1.0 装指定版本 … --yes 全部默认、不交互 +# … --check 只体检、不安装 … --port 8080 自定义端口 +# … --admin-password-stdin 从 stdin 读初始管理员密码(不进 argv / history) +# +# 为什么写 `bash -c "$(curl …)"` 而不是 `curl … | bash`:后者的 stdin 被脚本内容占用, +# 交互问答会读不到输入(本脚本会尽量兜到 /dev/tty,但前者从根上没有这个问题)。 +# 内网/离线:先把 tar.gz 与 install.sh 拷到机器上,再 +# sudo bash install.sh --package /path/to/classhelper-server-linux-x64-<版本>.tar.gz +# +# 本地已有的脚本用法: # sudo bash install.sh # 交互式(TUI 菜单 + 问答) # sudo bash install.sh --check # 只体检(不需要 root,不改动任何东西) # sudo bash install.sh --uninstall # 卸载(会问保留什么) @@ -83,8 +97,20 @@ INSTALL_DOCKER=0 FORCE=0 LOG_FILE="${LOG_DIR}/install.log" -SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" -SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" + +# 脚本自身路径。两种"一行脚本安装"方式下这里会不一样,必须都能活: +# bash -c "$(curl -fsSL .../install.sh)" → 没有脚本文件(BASH_SOURCE 为空) +# curl -fsSL .../install.sh | bash → 脚本内容是 stdin,同样没有文件 +# SCRIPT_DIR 只用来找与脚本同目录的模板(classhelper.service / nginx.conf), +# 从远端执行时找不到也不要紧 —— 安装包里带了同样的模板(见 place_program_files)。 +SELF_PATH="${BASH_SOURCE[0]:-}" +if [ -n "$SELF_PATH" ] && [ -f "$SELF_PATH" ]; then + SCRIPT_PATH="$(cd "$(dirname "$SELF_PATH")" && pwd)/$(basename "$SELF_PATH")" + SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" +else + SCRIPT_PATH="" + SCRIPT_DIR="" +fi # ---------------------------------------------------------------- 输出 @@ -123,11 +149,39 @@ ASCII printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET" } -# 打印文件头部注释里的用法(第一个分隔线之后、第二个分隔线之前) +# 用法:能读到脚本文件时直接打印文件头部注释(唯一真身,不会漂);从远端执行时没有文件可读, +# 退化成下面这份精简参数表。 usage() { - awk 'NR == 1 { next } - /^# -{10,}/ { seen++; if (seen == 2) exit; next } - seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH" + if [ -n "$SCRIPT_PATH" ] && [ -r "$SCRIPT_PATH" ]; then + awk 'NR == 1 { next } + /^# -{10,}/ { seen++; if (seen == 2) exit; next } + seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH" + return 0 + fi + cat <<'USAGE' +班级小助手 · 服务端 Linux 安装器 + +(本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数; + 完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/linux-deploy.md ) + + --mode native|docker 安装形态(默认 native:systemd + 内置 Node) + --dir /opt/classhelper 安装目录 --port 4000 服务端口 + --database sqlite|mysql 数据库(默认 sqlite) + --mysql-host/-port/-db/-user/-password-stdin MySQL 连接信息 + --admin-password-stdin 从 stdin 读一行作为初始管理员密码 + --package 服务端安装包(默认从 GitHub Release 取最新版) + --sha256 <哈希> 安装包校验值(不给时自动取发布页上的同名 .sha256) + --version 指定安装的版本 + --node-source bundled|system|tarball Node 运行时来源(默认 bundled) + --node-mirror / --node-tarball Node 下载镜像 / 离线包 + --with-nginx 顺带放一份 Nginx 反代配置 + --open-firewall 按检测到的防火墙放行端口 + --install-docker Docker 形态缺少 docker 时自动安装 + --yes 全部用默认值,不再交互(CI / 验收用) + --force 覆盖已有配置;无 systemd 时只铺文件不装服务 + --check 只体检(不需要 root,不改动系统) + --uninstall 卸载 +USAGE } # ---------------------------------------------------------------- 参数解析 @@ -181,6 +235,10 @@ parse_args() { OS_ID=""; OS_VER=""; OS_NAME=""; PKG="" detect_os() { + # /etc/os-release 里也有 VERSION(Ubuntu 是 "24.04.1 LTS (Noble Numbat)"),直接 source 会盖掉 + # 安装包版本号:.installed.json 会记成发行版名,而且不带 --package 时 resolve_package_url + # 会拿它当 Release tag 去拼下载地址(必然 404)。 + local keep_version="${VERSION:-}" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release @@ -190,6 +248,7 @@ detect_os() { else OS_NAME="$(uname -s) $(uname -r)" fi + VERSION="$keep_version" if command -v apt-get >/dev/null 2>&1; then PKG="apt" elif command -v dnf >/dev/null 2>&1; then PKG="dnf" elif command -v yum >/dev/null 2>&1; then PKG="yum" @@ -258,16 +317,51 @@ config_value() { # $1=KEY:从已有配置里读一项(覆盖安装时沿用 # ---------------------------------------------------------------- 交互问答 +# 交互输入的统一出口。 +# +# 为什么不能直接用 `read`:一行脚本安装的两种写法里,stdin 未必是终端 —— +# curl -fsSL .../install.sh | bash → **stdin 就是脚本内容本身**,read 会把脚本的下一行吃掉, +# 表现为"问题全被跳过 + 后续脚本乱套" +# bash -c "$(curl -fsSL .../install.sh)" → stdin 仍是终端,正常 +# 所以统一优先读 /dev/tty,退回 stdin;两者都不行(CI、定时任务)才按"非交互"处理。 +PROMPT_FD="" + +open_prompt_fd() { + if [ -n "$PROMPT_FD" ]; then return 0; fi + if [ "${CLASSHELPER_NO_TTY:-0}" = "1" ]; then return 1; fi + if exec 3/dev/null; then PROMPT_FD=3; return 0; fi + return 1 +} + +# $1=提示语 $2=目标变量名 $3=1 表示隐藏输入(密码);没有可用终端时返回 1(调用方一律用默认值) +read_prompt() { + local prompt="$1" var="$2" hidden="${3:-0}" value="" + printf -v "$var" '' + # 刻意**不**退回 stdin:`curl … | bash` 时 stdin 就是脚本内容本身, + # 从 stdin 读会把脚本的下一行当成用户输入吃掉,表现为"脚本后半段莫名其妙乱套"。 + open_prompt_fd || return 1 + printf '%s' "$prompt" >&3 + if [ "$hidden" = "1" ]; then IFS= read -r -s value <&3 || true; else IFS= read -r value <&3 || true; fi + printf '\n' >&3 + printf -v "$var" '%s' "$value" +} + +# 能不能问用户:--yes 不问;没有终端可读也不问(这时一律用默认值) +can_prompt() { + [ "$ASSUME_YES" = "1" ] && return 1 + open_prompt_fd +} + ask() { # $1=提示 $2=默认值 $3=目标变量 local prompt="$1" default="$2" var="$3" answer="" - if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then + if ! can_prompt; then printf -v "$var" '%s' "$default" return 0 fi if [ -n "$default" ]; then - read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true + read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer else - read -r -p "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer || true + read_prompt "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer fi printf -v "$var" '%s' "${answer:-$default}" } @@ -275,24 +369,23 @@ ask() { # $1=提示 $2=默认值 $3=目标变量 ask_secret() { # $1=提示 $2=目标变量(不回显) local prompt="$1" var="$2" value="" printf -v "$var" '' - if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi - read -r -s -p "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value || true - printf '\n' + if ! can_prompt; then return 0; fi + read_prompt "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value 1 printf -v "$var" '%s' "$value" } ask_yes_no() { # $1=提示 $2=默认 y/n;返回 0=是 local prompt="$1" default="$2" answer="" - if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then + if ! can_prompt; then [ "$default" = "y" ] return fi - read -r -p "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer || true + read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer case "${answer:-$default}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } choose_mode_interactive() { - if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then return 0; fi + if ! can_prompt; then return 0; fi printf '%s请选择:%s\n' "$C_BOLD" "$C_RESET" printf ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET" printf ' 2) 直接安装 + MySQL\n' @@ -300,7 +393,7 @@ choose_mode_interactive() { printf ' 4) 只体检(--check,不改动系统)\n' printf ' 5) 卸载\n' local choice="" - read -r -p "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice || true + read_prompt "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice case "${choice:-1}" in 1) MODE="native"; DB_KIND="sqlite" ;; 2) MODE="native"; DB_KIND="mysql" ;; @@ -414,13 +507,30 @@ fetch_package() { esac [ -s "$pkg" ] || die "安装包为空:${url}" + # 没给 --sha256 时,把发布页上同名的 .sha256 取回来自己校验。 + # 这一步是一行脚本安装下的**唯一**校验机会:用户不会自己去核哈希, + # 而少了它,"校验"就只是把算出来的哈希记进日志而已。 + if [ -z "$PACKAGE_SHA256" ]; then + local sum_file="${tmp}/expected.sha256" + case "$url" in + file://*|/*|./*|../*) [ -f "${url#file://}.sha256" ] && cp -f "${url#file://}.sha256" "$sum_file" ;; + *) curl -fsSL --max-time 60 -H 'User-Agent: ClassHelper-Installer' -o "$sum_file" "${url}.sha256" 2>/dev/null ;; + esac + if [ -s "$sum_file" ]; then + PACKAGE_SHA256="$(awk '{print $1; exit}' "$sum_file")" + [ -n "$PACKAGE_SHA256" ] && info "已从发布页取到 sha256,安装包会做校验" + fi + fi + local got; got="$(sha256sum "$pkg" | awk '{print $1}')" if [ -n "$PACKAGE_SHA256" ]; then - [ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}" + [ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got} +安装包可能在传输中损坏或被篡改,已中止(什么也没改)。可用 --sha256 <正确的哈希> 重试。" log "sha256 校验通过" else PACKAGE_SHA256="$got" - info "sha256(未提供校验值,仅记录):${got}" + warn "取不到发布页的 .sha256,本次只记录实际哈希、不做校验:${got}" + warn "(内网分发时请自带 --sha256;或手工核对上面这串哈希)" fi STAGE_DIR="${tmp}/pkg"