feat(deploy): install.sh 支持一行远程安装(从 GitHub 取脚本与安装包,并自动校验哈希)
用户侧只要一行: 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 的开头同样补上(该文件另有一处 改动不属于本次提交,故未纳入)。
This commit is contained in:
1 parent
f1edb42665
commit
d820926491
2 files changed
+136
-15
No files matched your search
@@ -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
|
||||
|
||||
+125
-15
@@ -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]}")"
|
||||
|
||||
# 脚本自身路径。两种"一行脚本安装"方式下这里会不一样,必须都能活:
|
||||
# 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() {
|
||||
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 <url|file://路径> 服务端安装包(默认从 GitHub Release 取最新版)
|
||||
--sha256 <哈希> 安装包校验值(不给时自动取发布页上的同名 .sha256)
|
||||
--version <X.Y.Z> 指定安装的版本
|
||||
--node-source bundled|system|tarball Node 运行时来源(默认 bundled)
|
||||
--node-mirror <url> / --node-tarball <url|路径> 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/tty 2>/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"
|
||||
|
||||
Reference in new issue
Block a user