diff --git a/scripts/core/common.sh b/scripts/core/common.sh index 4207cca..2838e8c 100644 --- a/scripts/core/common.sh +++ b/scripts/core/common.sh @@ -1145,8 +1145,104 @@ guard_unsafe_sudo_auto_install() { fi } +install_sudo_available() { + command -v sudo >/dev/null 2>&1 || return 1 + sudo -n true >/dev/null 2>&1 && return 0 + id -nG 2>/dev/null | tr ' ' '\n' | grep -Eq '^(sudo|wheel|admin)$' +} + +install_scope_choice_tty() { + [ -t 0 ] && [ -t 2 ] +} + +install_prompt_scope_choice() { + local selected=0 key + local root_label="root 安装(推荐)" + local user_label="user 安装" + local root_desc="安装到系统命令路径,支持 systemd 后端,适合长期运行、开机自启、全局可用。" + local user_desc="不写系统目录,安装到当前用户环境,适合无 root 权限或只给当前用户使用。" + + if ! install_scope_choice_tty; then + warn "检测到当前用户可使用 sudo;非交互终端默认使用 user 安装" + printf 'ℹ %s\n' "如需 root 安装,请显式执行:sudo bash install.sh system" >&2 + echo "user" + return 0 + fi + + while true; do + printf '\033[2J\033[H' >&2 + echo "检测到当前用户可使用 sudo,请选择安装方式:" >&2 + echo >&2 + + if [ "$selected" -eq 0 ]; then + echo "> $root_label" >&2 + else + echo " $root_label" >&2 + fi + echo " $root_desc" >&2 + echo >&2 + + if [ "$selected" -eq 1 ]; then + echo "> $user_label" >&2 + else + echo " $user_label" >&2 + fi + echo " $user_desc" >&2 + echo >&2 + echo "使用 ↑/↓ 或 j/k 移动,Enter 确认。" >&2 + + IFS= read -rsn1 key || { + echo "user" + return 0 + } + + case "$key" in + "") + if [ "$selected" -eq 0 ]; then + echo "system" + else + echo "user" + fi + return 0 + ;; + j|J) + selected=1 + ;; + k|K) + selected=0 + ;; + $'\033') + IFS= read -rsn2 -t 0.1 key || key="" + case "$key" in + "[A") selected=0 ;; + "[B") selected=1 ;; + esac + ;; + 1) + echo "system" + return 0 + ;; + 2) + echo "user" + return 0 + ;; + esac + done +} + +rerun_install_as_root_system() { + local install_script + + install_script="$PROJECT_DIR/install.sh" + [ -f "$install_script" ] || die "未找到安装脚本:$install_script" + + echo "👉 将使用 root/system 方式重新执行安装" + exec sudo bash "$install_script" system +} + detect_install_scope() { local requested="${1:-auto}" + local selected_scope case "$requested" in system) @@ -1159,7 +1255,23 @@ detect_install_scope() { if [ "$(id -u)" -eq 0 ]; then INSTALL_SCOPE="system" else - INSTALL_SCOPE="user" + if install_sudo_available; then + selected_scope="$(install_prompt_scope_choice)" + case "$selected_scope" in + system) + rerun_install_as_root_system + return $? + ;; + user) + INSTALL_SCOPE="user" + ;; + *) + die "未知安装选择:$selected_scope" + ;; + esac + else + INSTALL_SCOPE="user" + fi fi ;; *) diff --git a/scripts/dev/check-install-scope-selector.sh b/scripts/dev/check-install-scope-selector.sh new file mode 100644 index 0000000..8c728a9 --- /dev/null +++ b/scripts/dev/check-install-scope-selector.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=scripts/core/common.sh +source "$PROJECT_DIR/scripts/core/common.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +PROJECT_DIR="$tmp_dir/project" +mkdir -p "$PROJECT_DIR" +touch "$PROJECT_DIR/install.sh" + +reset_scope_state() { + INSTALL_SCOPE="" + INSTALL_HOME="" + RUNTIME_DIR="" + BIN_DIR="" + LOG_DIR="" + SELECTOR_PROMPTED="false" + RERUN_AS_ROOT="false" + MOCK_IS_ROOT="false" + MOCK_SUDO_AVAILABLE="false" + MOCK_TTY="false" + MOCK_SELECTED_SCOPE="user" + : > "$tmp_dir/prompted" + : > "$tmp_dir/rerun" +} + +id() { + if [ "${1:-}" = "-u" ]; then + if [ "$MOCK_IS_ROOT" = "true" ]; then + printf '0\n' + else + printf '1000\n' + fi + return 0 + fi + + command id "$@" +} + +install_sudo_available() { + [ "$MOCK_SUDO_AVAILABLE" = "true" ] +} + +install_scope_choice_tty() { + [ "$MOCK_TTY" = "true" ] +} + +install_prompt_scope_choice() { + printf 'prompted\n' >> "$tmp_dir/prompted" + if ! install_scope_choice_tty; then + printf 'user\n' + return 0 + fi + + printf '%s\n' "$MOCK_SELECTED_SCOPE" +} + +rerun_install_as_root_system() { + printf 'rerun\n' >> "$tmp_dir/rerun" + return 77 +} + +assert_equal() { + local name="$1" + local actual="$2" + local expected="$3" + + if [ "$actual" != "$expected" ]; then + echo "not ok - $name: got '$actual', expected '$expected'" >&2 + return 1 + fi + + echo "ok - $name" +} + +reset_scope_state +detect_install_scope user +assert_equal "explicit user selects user scope" "$INSTALL_SCOPE" "user" +assert_equal "explicit user does not prompt" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "0" + +reset_scope_state +detect_install_scope system +assert_equal "explicit system selects system scope" "$INSTALL_SCOPE" "system" +assert_equal "explicit system does not prompt" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "0" + +reset_scope_state +MOCK_IS_ROOT="true" +detect_install_scope auto +assert_equal "root auto selects system scope" "$INSTALL_SCOPE" "system" +assert_equal "root auto does not prompt" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "0" + +reset_scope_state +detect_install_scope auto +assert_equal "non-root without sudo selects user scope" "$INSTALL_SCOPE" "user" +assert_equal "non-root without sudo does not prompt" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "0" + +reset_scope_state +MOCK_SUDO_AVAILABLE="true" +detect_install_scope auto +assert_equal "non-root sudo non-tty prompts through default path" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "1" +assert_equal "non-root sudo non-tty selects user scope" "$INSTALL_SCOPE" "user" + +reset_scope_state +MOCK_SUDO_AVAILABLE="true" +MOCK_TTY="true" +MOCK_SELECTED_SCOPE="user" +detect_install_scope auto +assert_equal "non-root sudo tty user choice prompts" "$(wc -l < "$tmp_dir/prompted" | tr -d ' ')" "1" +assert_equal "non-root sudo tty user choice selects user scope" "$INSTALL_SCOPE" "user" + +reset_scope_state +MOCK_SUDO_AVAILABLE="true" +MOCK_TTY="true" +MOCK_SELECTED_SCOPE="system" +set +e +detect_install_scope auto +rc=$? +set -e +assert_equal "non-root sudo tty system choice calls root rerun" "$(wc -l < "$tmp_dir/rerun" | tr -d ' ')" "1" +assert_equal "non-root sudo tty system choice stops current flow" "$rc" "77"