diff --git a/README.md b/README.md index d3c0552..01c4e14 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ pnpm build:next ## 无 Docker 安装(systemd) -安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。 +安装器正式支持 **Linux x86_64(x64,glibc)**,应用包也可以在匹配的原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。安装器托管的 Node.js 24.20.0 仅覆盖 Node.js 官方提供的 x64/arm64 glibc 归档;musl 或 ARMv7 主机必须预先提供可用的系统 Node.js 24+,否则安装器会明确拒绝。Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持。 -发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: +发布包只包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、CI 在目标 Linux 架构上预编译的生产依赖、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh` 和 `SHA256SUMS`,不再携带 Node.js 二进制、源码或开发依赖。安装器检查系统 Node.js 是否为 24+;符合要求时直接复用,不符合时从 `nodejs.org` 下载并校验一次,后续应用更新不会重复下载运行时。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: ```bash curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash @@ -115,7 +115,7 @@ tallynote installer: 查看服务状态:systemctl status tallynote.service 已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。 -安装布局为 `/opt/tallynote/releases/` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。 +安装布局为 `/opt/tallynote/releases/` 加 `/opt/tallynote/current` 符号链接;没有系统 Node.js 24+ 时,安装器会额外创建带管理标记的 `/opt/tallynote/nodejs/`。切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。 升级有两种方式: diff --git a/bin/tallynote b/bin/tallynote index b5ade82..bfda484 100755 --- a/bin/tallynote +++ b/bin/tallynote @@ -1,8 +1,17 @@ #!/usr/bin/env bash set -Eeuo pipefail +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin +export PATH + ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P) -NODE="$ROOT/runtime/bin/node" -[[ -x "$NODE" ]] || NODE=$(command -v node || true) -[[ -n "$NODE" ]] || { printf 'TallyNote: Node.js runtime not found\n' >&2; exit 127; } +NODE=${TALLYNOTE_NODE:-} +node_is_usable() { + local candidate=$1 major + [[ -n "$candidate" && -x "$candidate" ]] || return 1 + major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]] +} +node_is_usable "$NODE" || NODE=$(command -v node || true) +node_is_usable "$NODE" || { printf 'TallyNote: Node.js 24+ not found; run the installer again\n' >&2; exit 127; } exec "$NODE" "$ROOT/dist/server/index.js" "$@" diff --git a/bin/tallynote-admin-init b/bin/tallynote-admin-init index 27d24f7..bdf55ba 100755 --- a/bin/tallynote-admin-init +++ b/bin/tallynote-admin-init @@ -4,7 +4,7 @@ set -Eeuo pipefail # Production entry point for first-admin setup. The installer keeps the # EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments # without sourcing arbitrary shell code. -PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 @@ -15,6 +15,13 @@ SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service} die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; } +node_is_usable() { + local candidate=$1 major + [[ -n "$candidate" && -x "$candidate" ]] || return 1 + major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]] +} + load_environment_file() { [[ -e "$CONFIG_FILE" ]] || return 0 [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件' @@ -105,9 +112,9 @@ main() { [[ "$argument" == "--check" ]] && check_only=1 done root=$(resolve_release_root) - node="$root/runtime/bin/node" - [[ -x "$node" ]] || node=$(command -v node || true) - [[ -n "$node" && -x "$node" ]] || die '找不到 Node.js runtime' + node=${TALLYNOTE_NODE:-} + node_is_usable "$node" || node=$(command -v node || true) + node_is_usable "$node" || die '找不到 Node.js 24+' cli="$root/dist/server/cli/admin-init.js" [[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在' diff --git a/docs/release.md b/docs/release.md index 1889214..a1102e7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,8 +1,10 @@ # Release、安装与更新 -TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`、`argon2`、`sharp` 和 Node runtime 都包含原生代码,不能在 macOS 上交叉打包后冒充 Linux。 +TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`、`argon2` 和 `sharp` 都包含原生代码,不能在 macOS 上交叉打包后冒充 Linux。Node.js 不再进入每个发布包;安装器负责复用系统 Node.js 24+,或从 Node.js 官方 HTTPS 归档下载并校验一次。 -正式支持:Linux x86_64/amd64;脚本和安装器也支持在原生 runner 上提供 Linux aarch64/arm64(glibc 或 musl)。当前仓库 workflow 只生成 x64,arm64 必须使用对应 runner 单独构建发布。ARMv7/ARM32 只在你拥有对应 runner 和完整依赖构建结果时实验使用。Linux x86 32 位(i386、i686、ia32)明确不支持,Node.js 24 及原生依赖没有可维护的正式构建,因此安装器会拒绝它。 +正式支持:Linux x86_64/amd64(glibc);发布脚本也可在原生 Linux aarch64/arm64 runner 上构建对应应用包。当前仓库 workflow 只生成 x64,arm64 需要在匹配的 runner 上单独构建发布。安装器托管的 Node.js 24.20.0 仅覆盖 Node.js 官方提供的 x64/arm64 glibc 归档;musl 或 ARMv7 主机必须预先提供可用的系统 Node.js 24+,否则安装器会明确拒绝,而不会请求不存在的官方归档。Linux x86 32 位(i386、i686、ia32)明确不支持。 + +首次安装按 `TALLYNOTE_NODE`、系统 `node`、安装器托管运行时的顺序选择 Node.js。没有满足 24+ 的系统 Node.js 时,安装器从 `https://nodejs.org/dist/v24.20.0/` 下载匹配架构的归档和 `SHASUMS256.txt`,通过 SHA-256 校验后放入 `/opt/tallynote/nodejs/`,并把路径写入 `/etc/tallynote/tallynote.env`。发布包和应用更新都不会再次携带或下载 Node.js;卸载器只删除带 TallyNote 管理标记的运行时目录。 ## 自动发布 @@ -30,7 +32,7 @@ GITEA_TOKEN=... \ ./scripts/publish-gitea-release.sh v1.1.2 ./release ``` -发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。构建脚本会同时生成完整安装包和轻量更新包:`tallynote-1.1.2-linux-x64-glibc.tar.gz` 用于首次安装,`tallynote-1.1.2-linux-x64-glibc.update-<锁文件 SHA256>.tar.gz` 仅用于复用现有运行时的后台更新。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 +发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。当前发布流程只生成这一份完整生产包:包内包含构建后的 `dist/`、目标 Linux 架构上预编译的生产 `node_modules/`、迁移文件、systemd 单元和安装/更新/卸载辅助脚本,但不包含 Node.js 二进制、源码或开发依赖。首次安装和后台应用更新都使用同一份完整包;主机上的 Node.js 24+ 由安装器一次性准备并在后续更新中复用。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 ## curl 安装 @@ -83,6 +85,7 @@ tallynote installer: 访问地址:http://127.0.0.1:<端口> ```text /opt/tallynote/releases// # 只读发布代码 /opt/tallynote/current -> releases/ +/opt/tallynote/nodejs/ # 主机没有 Node.js 24+ 时由安装器管理 /opt/tallynote/.update-work/ # 0700 root:root,root 更新器临时工作区 /opt/tallynote/.update-state # root 更新状态标记,异常中断后用于恢复 /var/lib/tallynote/ # SQLite、附件、暂存和导出 @@ -106,7 +109,7 @@ sudo /usr/local/sbin/tallynote-uninstall 将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos///releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。 -后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;当前安装如果存在匹配的锁文件指纹,更新器会自动选择轻量 `update-<锁文件 SHA256>` 资产,仅下载 `dist`、迁移和版本元数据,并复用当前版本的 Node 与生产依赖;如果运行时指纹不匹配或轻量包不可用,则自动选择完整安装包。root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。 +后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;更新器下载同一份完整生产包,流式校验 SHA-256、解包并暂存,成功后只显示“已下载,等待应用”,不会自动重启。管理员点击“立即更新”后才写入 root 更新请求,应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。 Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚: diff --git a/install.sh b/install.sh index b5d9ea5..b4baac7 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -Eeuo pipefail # TallyNote native installer. Installs the latest release by default; use # --dry-run to preview without changing the host. -PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 @@ -34,6 +34,11 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300} RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-} OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl} UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname} +NODE_MIN_MAJOR=24 +NODE_VERSION=${TALLYNOTE_NODE_VERSION:-24.20.0} +NODE_PATH=${TALLYNOTE_NODE:-} +NODE_INSTALL_ROOT=$PREFIX/nodejs +NODE_VERSION_DETECTED='' # Service network settings are written to the systemd EnvironmentFile on a # fresh install. Existing values are preserved unless the corresponding # TALLYNOTE_* variable is explicitly supplied to the installer. @@ -57,6 +62,7 @@ INSTALL_PREVIOUS_TARGET='' INSTALL_NEW_RELEASE='' INSTALL_WORK_DIR='' INSTALL_BACKUP_DIR='' +INSTALL_UNIT_TMP='' INSTALL_BACKUP_COMPLETE=0 INSTALL_WAS_ACTIVE=0 INSTALL_PATH_WAS_ACTIVE=0 @@ -65,6 +71,12 @@ INSTALL_WAS_ENABLED=0 INSTALL_PATH_WAS_ENABLED=0 INSTALL_UPDATE_WAS_ENABLED=0 INSTALL_SYSTEMD_TOUCHED=0 +INSTALL_NODE_CREATED=0 +INSTALL_NODE_TARGET='' +INSTALL_NODE_MARKER_CREATED=0 +INSTALL_NODE_MARKER='' +INSTALL_NODE_ROOT_CREATED=0 +NODE_INSTALL_TMP='' ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init INSTALL_FIRST_INSTALL=0 DATA_DIR_TEMP_ROOT=0 @@ -402,6 +414,7 @@ configure_network_interactively() { [[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false' [[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false' [[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg' +[[ "$NODE_VERSION" =~ ^24\.[0-9]+\.[0-9]+$ ]] || die 'TALLYNOTE_NODE_VERSION 必须是 24.x.y 版本号' [[ "$MAX_RELEASE_MB" =~ ^[1-9][0-9]*$ && "$MAX_EXTRACT_MB" =~ ^[1-9][0-9]*$ && "$MAX_ARCHIVE_ENTRIES" =~ ^[1-9][0-9]*$ ]] || die '安装资源限制必须是正整数' [[ "$CONNECT_TIMEOUT" =~ ^[1-9][0-9]*$ && "$MAX_TIME" =~ ^[1-9][0-9]*$ ]] || die '安装超时配置必须是正整数' @@ -461,6 +474,146 @@ detect_platform() { export TALLYNOTE_ARCH TALLYNOTE_LIBC } +node_major_version() { + local candidate=$1 value + [[ -x "$candidate" ]] || return 1 + value=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + printf '%s' "$value" +} + +node_is_legacy_embedded() { + local candidate=$1 resolved + [[ -n "$candidate" ]] || return 1 + resolved=$(readlink -f -- "$candidate" 2>/dev/null || realpath "$candidate" 2>/dev/null || printf '%s' "$candidate") + [[ "$resolved" == "$PREFIX/current/runtime/"* || "$resolved" == "$PREFIX/releases/"*/runtime/* ]] +} + +node_is_usable() { + local candidate=$1 major resolved uid mode_bits + [[ -n "$candidate" && -x "$candidate" ]] || return 1 + resolved=$(readlink -f -- "$candidate" 2>/dev/null || realpath "$candidate" 2>/dev/null || printf '%s' "$candidate") + [[ -x "$resolved" ]] || return 1 + if (( EUID == 0 )); then + uid=$(stat_uid "$resolved") + mode_bits=$(stat_mode_bits "$resolved") + [[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || return 1 + fi + major=$(node_major_version "$candidate") || return 1 + (( major >= NODE_MIN_MAJOR )) || return 1 + NODE_VERSION_DETECTED=$("$candidate" -p 'process.versions.node' 2>/dev/null || true) + [[ -n "$NODE_VERSION_DETECTED" ]] +} + +node_archive_name() { + local platform + case "${TALLYNOTE_LIBC}:${TALLYNOTE_ARCH}" in + glibc:x64) platform=linux-x64 ;; + glibc:arm64) platform=linux-arm64 ;; + *) die "Node.js 官方未提供当前平台的 ${NODE_MIN_MAJOR}+ 归档(${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC});请先安装可用的系统 Node.js 24+ 后重试" ;; + esac + printf 'node-v%s-%s.tar.xz' "$NODE_VERSION" "$platform" +} + +install_managed_node() { + local archive checksum archive_name expected actual tmp extracted target marker + archive_name=$(node_archive_name) + tmp=$(mktemp -d) + NODE_INSTALL_TMP=$tmp + archive="$tmp/$archive_name" + checksum="$tmp/SHASUMS256.txt" + stage "系统未找到 Node.js ${NODE_MIN_MAJOR}+,下载官方运行时 ${NODE_VERSION}" + append_allowed_host nodejs.org + download "https://nodejs.org/dist/v${NODE_VERSION}/${archive_name}" "$archive" $((256 * 1024 * 1024)) + download "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" "$checksum" $((4 * 1024 * 1024)) + expected=$(awk -v name="$archive_name" '$2 == name { print $1; exit }' "$checksum") + [[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'Node.js 官方校验清单中没有匹配归档' + actual=$(sha256sum "$archive" | awk '{print $1}') + [[ "${actual,,}" == "${expected,,}" ]] || die 'Node.js 官方归档 SHA-256 校验失败' + if [[ ! -e "$NODE_INSTALL_ROOT" && ! -L "$NODE_INSTALL_ROOT" ]]; then + INSTALL_NODE_ROOT_CREATED=1 + fi + ensure_root_directory "$NODE_INSTALL_ROOT" 755 + tar -xJf "$archive" -C "$tmp" + extracted="$tmp/${archive_name%.tar.xz}" + [[ -d "$extracted" && -x "$extracted/bin/node" ]] || die 'Node.js 官方归档结构无效' + target="$NODE_INSTALL_ROOT/${archive_name%.tar.xz}" + [[ ! -e "$target" && ! -L "$target" ]] || die "Node.js 目标目录已存在:$target" + mv -- "$extracted" "$target" + INSTALL_NODE_CREATED=1 + INSTALL_NODE_TARGET=$target + marker="$NODE_INSTALL_ROOT/.tallynote-managed" + if [[ -e "$marker" || -L "$marker" ]]; then + [[ -f "$marker" && ! -L "$marker" && "$(sed -n '1p' "$marker" 2>/dev/null)" == tallynote-managed-node-v1 ]] || die 'Node.js 管理目录标记无效' + else + printf 'tallynote-managed-node-v1\n' > "$marker" + chmod 600 "$marker" + INSTALL_NODE_MARKER_CREATED=1 + INSTALL_NODE_MARKER=$marker + fi + chown -R root:root "$target" + chown root:root "$marker" + NODE_PATH="$target/bin/node" + rm -rf -- "$tmp" + NODE_INSTALL_TMP='' + node_is_usable "$NODE_PATH" || die '已安装的 Node.js 运行时无法通过版本检查' + stage_done "Node.js ${NODE_VERSION_DETECTED} 已安装并记录为共享运行时" +} + +cleanup_node_install_if_needed() { + local result=$? marker + if [[ -n "$NODE_INSTALL_TMP" && -d "$NODE_INSTALL_TMP" ]]; then + rm -rf -- "$NODE_INSTALL_TMP" 2>/dev/null || true + NODE_INSTALL_TMP='' + fi + if (( INSTALL_COMMITTED == 0 && INSTALL_NODE_CREATED == 1 )); then + if [[ -n "$INSTALL_NODE_TARGET" && -d "$INSTALL_NODE_TARGET" && ! -L "$INSTALL_NODE_TARGET" ]]; then + rm -rf -- "$INSTALL_NODE_TARGET" 2>/dev/null || true + fi + marker=$INSTALL_NODE_MARKER + if (( INSTALL_NODE_MARKER_CREATED == 1 )) && [[ -n "$marker" && -f "$marker" && ! -L "$marker" ]]; then + rm -f -- "$marker" 2>/dev/null || true + fi + if (( INSTALL_NODE_ROOT_CREATED == 1 )) && [[ -d "$NODE_INSTALL_ROOT" && ! -L "$NODE_INSTALL_ROOT" ]]; then + rmdir -- "$NODE_INSTALL_ROOT" 2>/dev/null || true + fi + INSTALL_NODE_CREATED=0 + fi + return "$result" +} + +ensure_node_runtime() { + local candidate='' managed_node marker + [[ "$NODE_INSTALL_ROOT" == "$PREFIX"/* ]] || die 'Node.js 管理目录必须位于 TallyNote 安装目录内' + if [[ -n "$NODE_PATH" ]] && ! node_is_legacy_embedded "$NODE_PATH" && node_is_usable "$NODE_PATH"; then + stage_done "复用已配置的 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH" + return 0 + fi + candidate=$(command -v node || true) + if [[ -n "$candidate" ]] && node_is_usable "$candidate"; then + NODE_PATH=$candidate + stage_done "复用系统 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH" + return 0 + fi + marker="$NODE_INSTALL_ROOT/.tallynote-managed" + if [[ -f "$marker" && ! -L "$marker" && "$(sed -n '1p' "$marker" 2>/dev/null)" == tallynote-managed-node-v1 ]]; then + while IFS= read -r managed_node; do + [[ -n "$managed_node" ]] || continue + if node_is_usable "$managed_node"; then + NODE_PATH=$managed_node + stage_done "复用已安装的 Node.js ${NODE_VERSION_DETECTED}:$NODE_PATH" + return 0 + fi + done < <(find "$NODE_INSTALL_ROOT" -mindepth 3 -maxdepth 3 -type f -path '*/bin/node' -print 2>/dev/null | sort -V -r) + fi + if (( ! APPLY )); then + log "dry-run: 当前主机需要 Node.js ${NODE_MIN_MAJOR}+;正式安装时将从 nodejs.org 下载并校验" + return 0 + fi + [[ $EUID -eq 0 ]] || die '安装 Node.js 运行时必须以 root 运行' + install_managed_node +} + require_https() { local value=$1 case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac @@ -711,7 +864,7 @@ normalize_release_tree() { fi find "$root" -type d -exec chmod 755 {} + find "$root" -type f -exec chmod 644 {} + - for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do + for item in "$root/bin"/* "$root/scripts"/*.sh "$root/uninstall.sh"; do [[ -f "$item" && ! -L "$item" ]] || continue chmod 755 "$item" done @@ -868,6 +1021,10 @@ stop_existing_services() { rollback_install_if_needed() { local result=$? rollback_tmp + # This helper intentionally returns the original exit status when used as + # the early EXIT trap. Once called from this rollback trap, swallow that + # status so errexit cannot skip restoration of the previous installation. + cleanup_node_install_if_needed || true if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then # The failed install may have started units that were inactive before the # attempt. Stop them before restoring files so systemd never keeps running @@ -928,6 +1085,10 @@ rollback_install_if_needed() { if [[ -n "$INSTALL_WORK_DIR" && -d "$INSTALL_WORK_DIR" ]]; then rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true fi + if [[ -n "$INSTALL_UNIT_TMP" && -d "$INSTALL_UNIT_TMP" && ! -L "$INSTALL_UNIT_TMP" ]]; then + rm -rf -- "$INSTALL_UNIT_TMP" 2>/dev/null || true + fi + INSTALL_UNIT_TMP='' return "$result" } @@ -1077,7 +1238,7 @@ validate_existing_env() { mode_bits=$(stat_mode_bits "$file") (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' local key key_count - for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do + for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_CONFIG_DIR TALLYNOTE_NODE TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do key_count=$(env_key_count "$file" "$key") [[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key" done @@ -1085,6 +1246,12 @@ validate_existing_env() { [[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致' value=$(read_env_value "$file" TALLYNOTE_DATA_DIR) [[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致' + value=$(read_env_value "$file" TALLYNOTE_CONFIG_DIR) + [[ -z "$value" || "${value%/}" == "${CONFIG_DIR%/}" ]] || die '环境文件中的配置目录与本次安装不一致' + value=$(read_env_value "$file" TALLYNOTE_NODE) + if [[ -n "$value" ]]; then + validate_env_value "$value" '环境文件中的 Node.js 路径' + fi value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE) [[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false' if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then @@ -1161,6 +1328,7 @@ install_release() { safe_extract "$archive" "$tmp/unpacked" normalize_release_tree "$tmp/unpacked" [[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root' + [[ ! -e "$tmp/unpacked/runtime" ]] || die 'release archive must not contain an embedded Node.js runtime' [[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote' [[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete' [[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units' @@ -1224,6 +1392,9 @@ main() { fi detect_platform configure_network_interactively + if [[ -z "$NODE_PATH" && -f "$CONFIG_DIR/tallynote.env" ]]; then + NODE_PATH=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_NODE 2>/dev/null || true) + fi validate_listen_host "$INSTALL_HOST" validate_listen_port "$INSTALL_PORT" if (( APPLY && NETWORK_INTERACTIVE )); then @@ -1310,6 +1481,11 @@ main() { for command_name in curl sha256sum tar install sed awk find systemctl; do command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" done + # Install the cleanup trap before downloading a managed Node.js runtime. A + # failed runtime download or extraction must not leave a partial toolchain + # behind even when release acquisition has not started yet. + trap cleanup_node_install_if_needed EXIT + ensure_node_runtime if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled' fi @@ -1394,24 +1570,26 @@ main() { [[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" && -x "$release_dir/bin/tallynote-admin-init" && -f "$release_dir/dist/server/cli/admin-init.js" ]] || die 'release package is missing update/uninstall/admin-init support files' stage '安装 systemd 单元、更新辅助程序和卸载器' install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system - local unit_tmp - unit_tmp=$(mktemp -d) - sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service" - sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service" - sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path" - sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init" - install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service - install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service - install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path - install -o root -g root -m 755 "$unit_tmp/tallynote-admin-init" "$ADMIN_INIT_PATH" - rm -rf "$unit_tmp" - install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update - install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner + INSTALL_UNIT_TMP=$(mktemp -d) + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote.service" > "$INSTALL_UNIT_TMP/tallynote.service" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote-update.service" > "$INSTALL_UNIT_TMP/tallynote-update.service" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$INSTALL_UNIT_TMP/tallynote-update.path" + sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$INSTALL_UNIT_TMP/tallynote-admin-init" + install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote.service" /etc/systemd/system/tallynote.service + install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote-update.service" /etc/systemd/system/tallynote-update.service + install -o root -g root -m 644 "$INSTALL_UNIT_TMP/tallynote-update.path" /etc/systemd/system/tallynote-update.path + install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-admin-init" "$ADMIN_INIT_PATH" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/scripts/tallynote-update.sh" > "$INSTALL_UNIT_TMP/tallynote-update.sh" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/scripts/tallynote-update-runner.sh" > "$INSTALL_UNIT_TMP/tallynote-update-runner.sh" + install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-update.sh" /usr/local/sbin/tallynote-update + install -o root -g root -m 755 "$INSTALL_UNIT_TMP/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner + rm -rf -- "$INSTALL_UNIT_TMP" + INSTALL_UNIT_TMP='' install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700 local env_created=0 if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then - sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env" + sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env" chown root:root "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env" env_created=1 @@ -1463,6 +1641,11 @@ main() { if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX" ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" + ensure_env_key TALLYNOTE_CONFIG_DIR "$CONFIG_DIR" + configured_node=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_NODE 2>/dev/null || true) + if [[ -z "$configured_node" ]] || node_is_legacy_embedded "$configured_node" || ! node_is_usable "$configured_node"; then + set_env_key TALLYNOTE_NODE "$NODE_PATH" + fi ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL" ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS" diff --git a/package.json b/package.json index 85d853f..ad71385 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.3.3", + "version": "1.3.4", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", @@ -29,30 +29,21 @@ "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^9.2.1", "@fastify/static": "^10.1.3", - "@fontsource-variable/plus-jakarta-sans": "5.3.0", - "@reduxjs/toolkit": "2.12.0", "archiver": "^8.0.0", "argon2": "^0.44.0", "better-sqlite3": "^12.2.0", "drizzle-orm": "^0.45.2", - "echarts": "6.1.0", - "echarts-for-react": "3.0.6", "exceljs": "^4.4.0", "fast-xml-parser": "^5.2.5", "fastify": "^5.4.0", - "less": "4.4.1", - "lucide-react": "^0.542.0", "pdf-lib": "^1.17.1", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-redux": "9.2.0", - "react-router-dom": "7.18.3", "sharp": "^0.35.4", - "tdesign-react": "1.18.2", "yauzl": "^3.2.0", "zod": "^4.1.5" }, "devDependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@reduxjs/toolkit": "2.12.0", "@playwright/test": "^1.55.0", "@types/archiver": "^8.0.0", "@types/better-sqlite3": "^7.6.13", @@ -63,6 +54,15 @@ "@vitejs/plugin-react": "^5.0.2", "concurrently": "^9.2.1", "drizzle-kit": "^0.31.4", + "echarts": "6.1.0", + "echarts-for-react": "3.0.6", + "less": "4.4.1", + "lucide-react": "^0.542.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-redux": "9.2.0", + "react-router-dom": "7.18.3", + "tdesign-react": "1.18.2", "tsx": "^4.20.5", "typescript": "^5.9.2", "vite": "^7.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8705934..1c66235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,6 @@ importers: '@fastify/static': specifier: ^10.1.3 version: 10.1.3 - '@fontsource-variable/plus-jakarta-sans': - specifier: 5.3.0 - version: 5.3.0 - '@reduxjs/toolkit': - specifier: 2.12.0 - version: 2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) archiver: specifier: ^8.0.0 version: 8.0.0 @@ -41,12 +35,6 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) - echarts: - specifier: 6.1.0 - version: 6.1.0 - echarts-for-react: - specifier: 3.0.6 - version: 3.0.6(echarts@6.1.0)(react@19.2.8) exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -56,33 +44,12 @@ importers: fastify: specifier: ^5.4.0 version: 5.12.1 - less: - specifier: 4.4.1 - version: 4.4.1 - lucide-react: - specifier: ^0.542.0 - version: 0.542.0(react@19.2.8) pdf-lib: specifier: ^1.17.1 version: 1.17.1 - react: - specifier: ^19.1.1 - version: 19.2.8 - react-dom: - specifier: ^19.1.1 - version: 19.2.8(react@19.2.8) - react-redux: - specifier: 9.2.0 - version: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) - react-router-dom: - specifier: 7.18.3 - version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@24.13.3) - tdesign-react: - specifier: 1.18.2 - version: 1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yauzl: specifier: ^3.2.0 version: 3.4.0 @@ -90,9 +57,15 @@ importers: specifier: ^4.1.5 version: 4.4.3 devDependencies: + '@fontsource-variable/plus-jakarta-sans': + specifier: 5.3.0 + version: 5.3.0 '@playwright/test': specifier: ^1.55.0 version: 1.62.1 + '@reduxjs/toolkit': + specifier: 2.12.0 + version: 2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) '@types/archiver': specifier: ^8.0.0 version: 8.0.0 @@ -120,6 +93,33 @@ importers: drizzle-kit: specifier: ^0.31.4 version: 0.31.10 + echarts: + specifier: 6.1.0 + version: 6.1.0 + echarts-for-react: + specifier: 3.0.6 + version: 3.0.6(echarts@6.1.0)(react@19.2.8) + less: + specifier: 4.4.1 + version: 4.4.1 + lucide-react: + specifier: ^0.542.0 + version: 0.542.0(react@19.2.8) + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + react-redux: + specifier: 9.2.0 + version: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react-router-dom: + specifier: 7.18.3 + version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tdesign-react: + specifier: 1.18.2 + version: 1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tsx: specifier: ^4.20.5 version: 4.23.12 diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 37bab63..088577f 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -Eeuo pipefail -# Build a self-contained release on the target Linux architecture. Native -# addons (SQLite, Argon2 and image processing) must be installed on the same -# architecture/libc as the artifact. +# Build a production release on the target Linux architecture. Native addons +# (SQLite, Argon2 and image processing) must be installed on the same +# architecture/libc as the artifact. Node.js itself is deliberately managed +# by the installer outside each release so application updates stay small. ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P) VERSION=${1:-} OUT_DIR=${2:-$ROOT/release} @@ -28,7 +29,7 @@ cd "$ROOT" pnpm build stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT -mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin" +mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" # Copy only the production build outputs. In particular, do not carry a # stale dist/web-next directory from a previous local preview build. cp -a dist/server "$stage/dist/" @@ -40,9 +41,7 @@ cp -a bin/. "$stage/bin/" cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/" cp uninstall.sh "$stage/uninstall.sh" cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/" -node_path=$(command -v node) -cp -L "$node_path" "$stage/runtime/bin/node" -chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh" +chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/uninstall.sh" # pnpm's default linker creates symlinks. A release archive is deliberately # symlink-free so the installer can reject traversal links deterministically. diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 5d4938e..bb7e302 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -5,7 +5,7 @@ set -Eeuo pipefail # always generated; an Ed25519 detached signature is added when a signing key # is supplied. The script remains separate from the workflow so operators can # dry-run the exact same asset selection locally without exposing a key. -PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 @@ -205,7 +205,6 @@ fi command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required' full_assets=() -update_assets=() for file in "$ASSET_DIR"/*.tar.gz; do [[ -f "$file" && ! -L "$file" ]] || continue name=$(basename -- "$file") @@ -214,13 +213,11 @@ for file in "$ASSET_DIR"/*.tar.gz; do asset_version=${asset_version%%-linux-*} [[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name" if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then - update_assets+=("$file") - else - full_assets+=("$file") + die "不再发布轻量更新资产:$name;请只保留完整生产包" fi + full_assets+=("$file") done assets=("${full_assets[@]}") -if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi (( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found' (( ${#full_assets[@]} > 0 )) || die 'no full release asset found' diff --git a/scripts/tallynote-update-runner.sh b/scripts/tallynote-update-runner.sh index afb6885..ca1bd13 100755 --- a/scripts/tallynote-update-runner.sh +++ b/scripts/tallynote-update-runner.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash set -Eeuo pipefail -PATH=/usr/sbin:/usr/bin:/sbin:/bin +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote} DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote} +CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} +CONFIG_FILE="$CONFIG_DIR/tallynote.env" REQUEST_FILE="$DATA_DIR/update-request.json" CURRENT_LINK="$PREFIX/current" STATE_FILE="$PREFIX/.update-state" @@ -22,6 +24,27 @@ if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEA die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; } +node_is_usable() { + local candidate=$1 major + [[ -n "$candidate" && -x "$candidate" ]] || return 1 + major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]] +} + +resolve_node() { + local candidate=${TALLYNOTE_NODE:-} + if [[ -z "$candidate" && -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then + candidate=$(sed -n 's/^TALLYNOTE_NODE=//p' "$CONFIG_FILE" | head -n 1) + fi + if node_is_usable "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + candidate=$(command -v node || true) + node_is_usable "$candidate" || return 1 + printf '%s' "$candidate" +} + # The runner may exit during any of the checks below. Install its EXIT cleanup # before doing privileged preflight so a partial invocation never leaves a # heartbeat or lock behind. @@ -202,9 +225,7 @@ if [[ "$request_operation" == download ]]; then trap 'exit 143' TERM trap 'exit 130' INT start_heartbeat - node_bin="$CURRENT_LINK/runtime/bin/node" - [[ -x "$node_bin" ]] || node_bin=$(command -v node || true) - [[ -n "$node_bin" ]] || die 'node runtime not found' + node_bin=$(resolve_node) || die 'Node.js 24+ not found' cli="$CURRENT_LINK/dist/server/cli/update.js" [[ -f "$cli" ]] || die 'update CLI not found in current release' set +e @@ -243,8 +264,7 @@ restore_initial_service() { return "$result" } trap restore_initial_service EXIT -old_node="$CURRENT_LINK/runtime/bin/node" -[[ -x "$old_node" ]] || old_node=$(command -v node || true) +old_node=$(resolve_node) || die 'Node.js 24+ not found' handled=0 write_update_state() { write_recovery_state "$1"; } @@ -287,8 +307,7 @@ recover_stale_state() { return 0 fi if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then - recovery_node="$CURRENT_LINK/runtime/bin/node" - [[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true) + recovery_node=$(resolve_node) || die 'Node.js 24+ not found' for _ in 1 2 3; do if finalize_state_job "$recovery_node" completed "$state_job"; then rm -f -- "$REQUEST_FILE" 2>/dev/null || true @@ -304,8 +323,7 @@ recover_stale_state() { # that case the old link is already safe to serve, but the database row # can still be `applying`; finish it as failed before clearing recovery # markers so the UI does not poll forever. - recovery_node="$CURRENT_LINK/runtime/bin/node" - [[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true) + recovery_node=$(resolve_node) || die 'Node.js 24+ not found' if finalize_state_job "$recovery_node" failed "$state_job"; then rm -f -- "$REQUEST_FILE" 2>/dev/null || true clear_update_state || true @@ -328,8 +346,7 @@ recover_stale_state() { rm -f -- "$rollback_link" 2>/dev/null || true return 1 fi - recovery_node="$CURRENT_LINK/runtime/bin/node" - [[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true) + recovery_node=$(resolve_node) || die 'Node.js 24+ not found' if ! finalize_state_job "$recovery_node" failed "$state_job"; then # If the original queue is still present, retry it from the restored old # release; a crash before the CLI wrote its job row is recoverable this @@ -439,9 +456,7 @@ cleanup_after_update() { } trap cleanup_after_update EXIT -node_bin="$CURRENT_LINK/runtime/bin/node" -[[ -x "$node_bin" ]] || node_bin=$(command -v node || true) -[[ -n "$node_bin" ]] || die 'node runtime not found' +node_bin=$(resolve_node) || die 'Node.js 24+ not found' cli="$CURRENT_LINK/dist/server/cli/update.js" [[ -f "$cli" ]] || die 'update CLI not found in current release' @@ -483,8 +498,7 @@ if (( was_active == 0 )); then fi write_update_state finalizing || exit 1 -final_node="$CURRENT_LINK/runtime/bin/node" -[[ -x "$final_node" ]] || final_node=$(command -v node || true) +final_node=$(resolve_node) || die 'Node.js 24+ not found' if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then finalized=0 for _ in 1 2 3; do diff --git a/scripts/tallynote-update.sh b/scripts/tallynote-update.sh index 81c5b77..bf801f6 100755 --- a/scripts/tallynote-update.sh +++ b/scripts/tallynote-update.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -Eeuo pipefail -PATH=/usr/sbin:/usr/bin:/sbin:/bin +PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 @@ -10,9 +10,22 @@ umask 077 # extraction and atomic release switching. PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}} DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote} +CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} +CONFIG_FILE="$CONFIG_DIR/tallynote.env" REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json} NODE=${TALLYNOTE_NODE:-} +node_is_usable() { + local candidate=$1 major + [[ -n "$candidate" && -x "$candidate" ]] || return 1 + major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) + [[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]] +} + +if [[ -z "$NODE" && -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then + NODE=$(sed -n 's/^TALLYNOTE_NODE=//p' "$CONFIG_FILE" | head -n 1) +fi + die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; } version_sort_desc() { if sort -V /dev/null 2>&1; then @@ -71,10 +84,9 @@ if [[ -x /usr/local/libexec/tallynote-update-runner ]]; then exec /usr/local/libexec/tallynote-update-runner fi if [[ -z "$NODE" ]]; then - NODE="$PREFIX/current/runtime/bin/node" - [[ -x "$NODE" ]] || NODE=$(command -v node || true) + NODE=$(command -v node || true) fi -[[ -n "$NODE" ]] || die 'node runtime not found' +node_is_usable "$NODE" || die 'Node.js 24+ not found' CLI="$PREFIX/current/dist/server/cli/update.js" [[ -f "$CLI" ]] || die 'update CLI not found' diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 2cbcf8f..2fcbd88 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -173,23 +173,23 @@ runner_root="$tmp/runner" runner_prefix="$runner_root/prefix" runner_data="$runner_root/data" runner_tools="$runner_root/tools" -mkdir -p "$runner_prefix/releases/1.0.0/runtime/bin" "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools" +mkdir -p "$runner_prefix/releases/1.0.0/dist/server/cli" "$runner_data" "$runner_tools" ln -s "$runner_prefix/releases/1.0.0" "$runner_prefix/current" printf '%s\n' '{"jobId":"00000000-0000-4000-8000-000000000001","operation":"apply"}' > "$runner_data/update-request.json" -printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "$*" >> "$TALLYNOTE_NODE_TRACE"' 'exit 0' > "$runner_prefix/releases/1.0.0/runtime/bin/node" +printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_NODE_TRACE"; fi' 'exit 0' > "$runner_tools/node" printf '%s\n' cli > "$runner_prefix/releases/1.0.0/dist/server/cli/update.js" printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$runner_tools/systemctl" printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$runner_tools/readlink" printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-Tf" ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi' > "$runner_tools/mv" printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$runner_tools/curl" -chmod 755 "$runner_prefix/releases/1.0.0/runtime/bin/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl" +chmod 755 "$runner_tools/node" "$runner_tools/systemctl" "$runner_tools/readlink" "$runner_tools/mv" "$runner_tools/curl" runner_script="$runner_root/runner.sh" runner_path="$runner_tools:/usr/sbin:/usr/bin:/sbin:/bin" -sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script" +sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$runner_path#" "$root/scripts/tallynote-update-runner.sh" > "$runner_script" chmod 755 "$runner_script" runner_prefix_physical=$(cd "$runner_prefix" && pwd -P) runner_data_physical=$(cd "$runner_data" && pwd -P) -env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script" +env EUID=0 TALLYNOTE_INSTALL_PREFIX="$runner_prefix_physical" TALLYNOTE_DATA_DIR="$runner_data_physical" TALLYNOTE_NODE="$runner_tools/node" TALLYNOTE_NODE_TRACE="$runner_root/node.log" bash "$runner_script" grep -q -- '--request-file' "$runner_root/node.log" grep -q -- '--finalize-job' "$runner_root/node.log" [[ ! -e "$runner_data/update-request.json" ]] @@ -202,14 +202,14 @@ download_runner_root="$tmp/download-runner" download_runner_prefix="$download_runner_root/prefix" download_runner_data="$download_runner_root/data" download_runner_tools="$download_runner_root/tools" -mkdir -p "$download_runner_prefix/releases/1.0.0/runtime/bin" "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools" +mkdir -p "$download_runner_prefix/releases/1.0.0/dist/server/cli" "$download_runner_data" "$download_runner_tools" ln -s "$download_runner_prefix/releases/1.0.0" "$download_runner_prefix/current" # The request has already been consumed; only the stale download marker is # left, which is the narrow recovery window covered by this fixture. download_runner_prefix_physical=$(cd "$download_runner_prefix" && pwd -P) download_runner_data_physical=$(cd "$download_runner_data" && pwd -P) printf '%s\n' 'job_id=00000000-0000-4000-8000-000000000002' "old_target=$download_runner_prefix_physical/releases/1.0.0" 'phase=download' > "$download_runner_prefix/.update-state" -printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"' 'exit 0' > "$download_runner_prefix/releases/1.0.0/runtime/bin/node" +printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else printf "%s\n" "$*" >> "$TALLYNOTE_DOWNLOAD_NODE_TRACE"; fi' 'exit 0' > "$download_runner_tools/node" printf '%s\n' cli > "$download_runner_prefix/releases/1.0.0/dist/server/cli/update.js" printf '%s\n' '#!/usr/bin/env bash' 'case "${1:-}" in is-active) exit 0;; *) exit 0;; esac' > "$download_runner_tools/systemctl" printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-f" ]]; then shift; [[ "${1:-}" == "--" ]] && shift; /bin/realpath "$1"; else /usr/bin/readlink "$@"; fi' > "$download_runner_tools/readlink" @@ -221,11 +221,11 @@ case "$*" in *) /usr/bin/stat "$@" ;; esac EOF -chmod 755 "$download_runner_prefix/releases/1.0.0/runtime/bin/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat" +chmod 755 "$download_runner_tools/node" "$download_runner_tools/systemctl" "$download_runner_tools/readlink" "$download_runner_tools/stat" download_runner_script="$download_runner_root/runner.sh" -sed "s#PATH=/usr/sbin:/usr/bin:/sbin:/bin#PATH=$download_runner_tools:/usr/sbin:/usr/bin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script" +sed "s#PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#PATH=$download_runner_tools:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin#" "$root/scripts/tallynote-update-runner.sh" > "$download_runner_script" chmod 755 "$download_runner_script" -env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script" +env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE_DATA_DIR="$download_runner_data_physical" TALLYNOTE_NODE="$download_runner_tools/node" TALLYNOTE_DOWNLOAD_NODE_TRACE="$download_runner_root/node.log" bash "$download_runner_script" [[ ! -e "$download_runner_root/node.log" ]] [[ ! -e "$download_runner_prefix/.update-state" ]] @@ -233,7 +233,7 @@ env EUID=0 TALLYNOTE_INSTALL_PREFIX="$download_runner_prefix_physical" TALLYNOTE # temporary variables still exist; otherwise set -u fails at the end of main. release_fixture="$tmp/release-fixture" mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \ - "$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd" + "$release_fixture/scripts" "$release_fixture/systemd" printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json" printf '%s\n' server > "$release_fixture/dist/server/index.js" printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js" @@ -282,16 +282,16 @@ grep -Fxq "PathChanged=$tmp/custom-prefix" "$rendered_path" # The production admin wrapper must load a release-relative runtime, change to # the release root, and forward CLI arguments without requiring pnpm. wrapper_prefix="$tmp/wrapper-prefix" -mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli" +mkdir -p "$wrapper_prefix/releases/1.0.0/dist/server/cli" "$tmp/wrapper-tools" ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current" -printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node" -chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node" +printf '%s\n' '#!/usr/bin/env bash' 'if [[ "${1:-}" == "-p" ]]; then printf "%s\n" "24"; else pwd -P > "$TALLYNOTE_WRAPPER_LOG"; printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"; fi' > "$tmp/wrapper-tools/node" +chmod 755 "$tmp/wrapper-tools/node" printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js" # This fixture verifies release-relative execution and argument forwarding. # Force the wrapper's non-root branch so the root CI runner does not need a # real `tallynote` service account or a privileged runuser hand-off; that # privilege boundary is validated by the production checks themselves. -env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \ +env EUID=1000 TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_NODE="$tmp/wrapper-tools/node" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \ bash "$root/bin/tallynote-admin-init" --generate wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P) grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log" @@ -590,13 +590,12 @@ env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \ # A release archive is extracted under umask 077, then explicitly normalized # so the tallynote system user can traverse and execute the shipped tree. source_tmp="$tmp/source" -mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin" +mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" printf '%s\n' 'server' > "$source_tmp/dist/server/index.js" printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh" printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote" printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh" -printf '%s\n' 'node' > "$source_tmp/runtime/bin/node" -chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node" +chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" chmod 755 "$source_tmp/uninstall.sh" archive_tmp="$tmp/release.tar.gz" tar -C "$source_tmp" -czf "$archive_tmp" . @@ -612,6 +611,7 @@ bash -c ' [[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]] [[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]] [[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]] + [[ ! -e "$destination/runtime" ]] ' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked" # A normal public-release install only needs the detached SHA-256 manifest; diff --git a/scripts/test-uninstaller.sh b/scripts/test-uninstaller.sh index 7f3ba0b..b655611 100755 --- a/scripts/test-uninstaller.sh +++ b/scripts/test-uninstaller.sh @@ -34,7 +34,7 @@ make_fixture() { done printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update" printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner" - printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init" + printf '%s\n' '#!/usr/bin/env bash' 'exec /usr/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init" cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall" chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall" printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db" diff --git a/server/cli/update.ts b/server/cli/update.ts index ab65384..16fa5d6 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type Database from "better-sqlite3"; @@ -10,7 +10,6 @@ import { writeAudit } from "../audit.js"; import { atomicSwitchDirectory, atomicSwitchRelease, - applicationUpdateRuntimeHash, compareSemver, createSafeArchive, detectPlatform, @@ -20,7 +19,6 @@ import { isNewerVersion, normalizeReleasePermissions, parseSemver, - runtimeHashFromLockfile, selectReleaseAsset, sanitizeAssetName, validateHttpsUrl, @@ -223,16 +221,9 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType { throw new Error("当前安装目录无效"); }); - const currentInfo = await lstat(currentRelease).catch(() => null); - if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效"); - for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) { - const source = path.join(currentRelease, entry); - const sourceInfo = await lstat(source).catch(() => null); - if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新"); - await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }); - } - } + const embeddedRuntime = await lstat(path.join(stagedDir, "runtime")).catch(() => null); + if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime"); await normalizeReleasePermissions(stagedDir); const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录"); diff --git a/server/update-service.ts b/server/update-service.ts index b4afd06..6794850 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -1,5 +1,5 @@ import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto"; import type Database from "better-sqlite3"; @@ -16,7 +16,6 @@ import { isNewerVersion, normalizeReleasePermissions, parseSemver, - runtimeHashFromLockfile, sanitizeAssetName, selectReleaseAsset, validateHttpsUrl, @@ -211,14 +210,9 @@ export async function checkForUpdate(database: Database.Database, config: AppCon } catch { throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试"); } - let runtimeHash: string | undefined; - try { - runtimeHash = runtimeHashFromLockfile(readFileSync(path.join(config.projectRoot, "pnpm-lock.yaml"))); - } catch { - // Legacy or source installations may not contain the lockfile. They stay - // on the full release asset instead of risking an incompatible runtime. - } - // Force choosing the full standalone archive so users always get a real, visible streaming download + // Always select the complete production archive. The host Node.js runtime + // is reused, while the application package remains self-contained and + // identical for first installs and in-place updates. let asset = selectReleaseAsset(metadata, platform, undefined); let signatureVerified = false; if (asset) { @@ -688,8 +682,9 @@ export function cancelUpdateJob( * The root runner only needs to apply (stop/backup/switch/restart) afterwards. * * This function runs asynchronously outside the request lifecycle. It updates - * the job row in the database so the frontend can poll progress. On success it - * writes an apply request file so the systemd path unit triggers the runner. + * the job row in the database so the frontend can poll progress. A successful + * download only becomes staged; applying it is a separate, explicit action + * that the administrator submits after reviewing the verification result. */ export async function downloadAndStageUpdate( database: Database.Database, @@ -756,8 +751,10 @@ export async function downloadAndStageUpdate( await extractSafeArchive(archivePath, payloadDir); await normalizeReleasePermissions(payloadDir); + const embeddedRuntime = await lstat(path.join(payloadDir, "runtime")).catch(() => null); + if (embeddedRuntime) throw new Error("发布包不应包含 Node.js runtime"); + // Verify payload contains dist directory - const { lstat } = await import("node:fs/promises"); const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) { throw new Error("发布包缺少 dist 目录"); @@ -765,26 +762,10 @@ export async function downloadAndStageUpdate( // Transition to staged const staged = database.prepare( - "UPDATE update_jobs SET status='staged', operation='apply', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')", + "UPDATE update_jobs SET status='staged', operation='download', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')", ).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId); if (staged.changes !== 1) return; // cancelled - // Write apply request file for the root runner - await writeUpdateRequest(config, { - jobId, - operation: "apply", - version, - metadataUrl, - assetUrl, - assetName, - expectedSha256, - requestedAt: Date.now(), - currentLink: config.currentLink, - releasesDir: config.releasesDir, - dataDir: config.dataDir, - stagedPath: workspace, - }); - writeAudit(database, { requestId: `download:${jobId}`, actorAdminId: adminId, diff --git a/server/update.ts b/server/update.ts index a6e07d6..fb27ded 100644 --- a/server/update.ts +++ b/server/update.ts @@ -1000,7 +1000,7 @@ export async function normalizeReleasePermissions(rootPath: string): Promise { await mkdir(path.join(source, "dist", "server"), { recursive: true }); await mkdir(path.join(source, "bin"), { recursive: true }); await mkdir(path.join(source, "scripts"), { recursive: true }); - await mkdir(path.join(source, "runtime", "bin"), { recursive: true }); await writeFile(path.join(source, "dist", "server", "large.js"), Buffer.alloc(2 * 1024 * 1024, 0x41)); await writeFile(path.join(source, "bin", "tallynote"), "#!/bin/sh\n"); await writeFile(path.join(source, "scripts", "runner.sh"), "#!/bin/sh\n"); - await writeFile(path.join(source, "runtime", "bin", "node"), "node"); const archive = path.join(root, "release.tar.gz"); await createSafeArchive(source, archive); expect((await stat(archive)).size).toBeLessThan(64 * 1024); @@ -472,7 +470,7 @@ describe("更新安全工具", () => { expect((await stat(path.join(destination, "dist", "server", "large.js"))).mode & 0o777).toBe(0o644); expect((await stat(path.join(destination, "bin", "tallynote"))).mode & 0o777).toBe(0o755); expect((await stat(path.join(destination, "scripts", "runner.sh"))).mode & 0o777).toBe(0o755); - expect((await stat(path.join(destination, "runtime", "bin", "node"))).mode & 0o777).toBe(0o755); + expect(await stat(path.join(destination, "runtime")).catch(() => null)).toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/uninstall.sh b/uninstall.sh index 276c934..9424201 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -417,6 +417,7 @@ remove_prefix() { log "warning: 保留非符号链接 current:$current" fi remove_tree "$releases" 'releases' + remove_managed_node remove_tree "$PREFIX/.update-work" 'update work' remove_file_if_owned "$PREFIX/.update-state" 'update state' if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then @@ -429,6 +430,33 @@ remove_prefix() { fi } +remove_managed_node() { + local node_root="$PREFIX/nodejs" marker + [[ -e "$node_root" || -L "$node_root" ]] || return 0 + [[ -d "$node_root" && ! -L "$node_root" ]] || die "Node.js 管理目录不是安全目录:$node_root" + marker="$node_root/.tallynote-managed" + if [[ ! -f "$marker" || "$(sed -n '1p' "$marker" 2>/dev/null)" != tallynote-managed-node-v1 ]]; then + log "保留非 TallyNote 管理的 Node.js 目录:$node_root" + return 0 + fi + allowed_owner "$node_root" || die "Node.js 管理目录的所有者不受信任:$node_root" + # Node distributions contain npm/corepack symlinks. They are safe to remove + # because rm never follows symlinks; validate ownership and permissions for + # every node while deliberately permitting those internal links. + local node mode_bits + while IFS= read -r node; do + allowed_owner "$node" || die "Node.js 管理目录节点的所有者不受信任:$node" + [[ -L "$node" ]] && continue + mode_bits=$(stat_mode_bits "$node") + (( (mode_bits & 18) == 0 )) || die "Node.js 管理目录权限过宽:$node" + done < <(find "$node_root" -print) + if (( DRY_RUN )); then + log "dry-run: remove managed Node.js $node_root" + else + rm -rf -- "$node_root" + fi +} + remove_config() { remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key' remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'