From 10adcc522e6b9113ee807bcfd7f6089d30581142 Mon Sep 17 00:00:00 2001 From: Arvin <62139570+wnlen@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:50:04 +0800 Subject: [PATCH] Fix reported install, select, and zsh completion bugs --- scripts/core/clashctl.sh | 48 +++++++++- scripts/core/common.sh | 72 ++++++++++---- scripts/core/completion.sh | 6 +- scripts/dev/check-home-unset-install-paths.sh | 26 +++++ scripts/dev/check-select-direct.sh | 96 +++++++++++++++++++ scripts/dev/check-zsh-completion-safe.sh | 24 +++++ scripts/init/systemd-user.sh | 16 ++-- 7 files changed, 259 insertions(+), 29 deletions(-) create mode 100644 scripts/dev/check-home-unset-install-paths.sh create mode 100644 scripts/dev/check-select-direct.sh create mode 100644 scripts/dev/check-zsh-completion-safe.sh diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index f38f1a6..d7258b5 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -1515,6 +1515,22 @@ print_select_feedback() { ui_blank } +select_usage() { + cat <<'EOF' +Usage: + clashctl select + clashctl select <策略组> <节点> + +Examples: + clashctl select + clashctl select "🚀 节点选择" "日本 01" + +Notes: + 不带参数时进入交互选择。 + 带参数时直接切换指定策略组到指定节点。 +EOF +} + print_sub_enable_feedback() { local name="$1" local health fail_count @@ -5743,7 +5759,7 @@ tun_unit_capability_text() { if command -v systemctl >/dev/null 2>&1; then content="$(systemctl --user cat "$unit" 2>/dev/null || true)" fi - unit_file="$HOME/.config/systemd/user/$unit" + unit_file="$(user_home_dir)/.config/systemd/user/$unit" ;; *) echo "非 systemd 后端,不适用 unit capability 声明" @@ -6746,9 +6762,15 @@ cmd_health() { } cmd_select() { - prepare + case "${1:-}" in + -h|--help|help) + select_usage + return 0 + ;; + esac if [ -z "${1:-}" ]; then + prepare print_select_context || return $? proxy_select_interactive_guarded return 0 @@ -7727,6 +7749,28 @@ proxy_select_interactive_guarded() { done } +proxy_select_direct() { + local group="${1:-}" + local node="${2:-}" + + if [ "$#" -ne 2 ]; then + die_usage "select 参数不合法" "clashctl select <策略组> <节点>" + fi + + prepare + + if ! status_is_running; then + die_state "代理内核未运行" "clashon" + fi + + if ! proxy_controller_reachable 2>/dev/null; then + die_state "控制器不可访问" "clashctl doctor" + fi + + proxy_group_select "$group" "$node" + print_select_feedback "$group" +} + cmd="${1:-}" shift || true diff --git a/scripts/core/common.sh b/scripts/core/common.sh index 2838e8c..4419f68 100644 --- a/scripts/core/common.sh +++ b/scripts/core/common.sh @@ -22,6 +22,31 @@ warn() { printf '⚠ %s\n' "$*" >&2; } error() { printf '✘ %s\n' "$*" >&2; } die() { error "$*"; exit 1; } +user_home_dir() { + local home_value uid + + if [ -n "${HOME:-}" ]; then + printf '%s\n' "$HOME" + return 0 + fi + + uid="$(id -u 2>/dev/null || true)" + if [ -n "${uid:-}" ] && command -v getent >/dev/null 2>&1; then + home_value="$(getent passwd "$uid" 2>/dev/null | awk -F: 'NR == 1 {print $6}')" + fi + + if [ -z "${home_value:-}" ] && [ -n "${uid:-}" ] && [ -r /etc/passwd ]; then + home_value="$(awk -F: -v uid="$uid" '$3 == uid {print $6; exit}' /etc/passwd 2>/dev/null || true)" + fi + + if [ -z "${home_value:-}" ]; then + die_state "无法确定当前用户 HOME" "请先设置 HOME 后重试:export HOME=/path/to/home" + fi + + export HOME="$home_value" + printf '%s\n' "$home_value" +} + ui_color() { local color="$1" local msg="$2" @@ -1282,7 +1307,7 @@ detect_install_scope() { if [ "$INSTALL_SCOPE" = "system" ]; then INSTALL_HOME="${CLASH_INSTALL_HOME:-/opt/clash-for-linux}" else - INSTALL_HOME="${CLASH_INSTALL_HOME:-$HOME/.local/share/clash-for-linux}" + INSTALL_HOME="${CLASH_INSTALL_HOME:-$(user_home_dir)/.local/share/clash-for-linux}" fi RUNTIME_DIR="$PROJECT_DIR/runtime" @@ -2571,12 +2596,15 @@ runtime_backend() { } shell_profile_file() { + local home_dir + if [ "$INSTALL_SCOPE" = "system" ]; then echo "/etc/profile.d/clash-for-linux.sh" return 0 fi - echo "$HOME/.bashrc" + home_dir="$(user_home_dir)" + echo "$home_dir/.bashrc" } alias_source_file() { @@ -2584,10 +2612,13 @@ alias_source_file() { } completion_dir() { + local home_dir + if [ "$INSTALL_SCOPE" = "system" ]; then echo "/etc/profile.d" else - echo "$HOME/.config/clash-for-linux" + home_dir="$(user_home_dir)" + echo "$home_dir/.config/clash-for-linux" fi } @@ -2616,7 +2647,7 @@ clashctl_bin_entry_target() { } ensure_command_install_dir_in_shell_path() { - local install_dir shell_rc + local install_dir shell_rc home_dir install_dir="$(command_install_dir)" @@ -2626,7 +2657,8 @@ ensure_command_install_dir_in_shell_path() { ;; esac - for shell_rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + home_dir="$(user_home_dir)" + for shell_rc in "$home_dir/.bashrc" "$home_dir/.zshrc" "$home_dir/.profile"; do [ -n "${shell_rc:-}" ] || continue touch "$shell_rc" if ! grep -Fq "$install_dir" "$shell_rc" 2>/dev/null; then @@ -2699,9 +2731,10 @@ EOF } cleanup_legacy_shell_entries() { - local shell_rc + local shell_rc home_dir - for shell_rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + home_dir="$(user_home_dir)" + for shell_rc in "$home_dir/.bashrc" "$home_dir/.zshrc" "$home_dir/.profile"; do [ -f "$shell_rc" ] || continue sed -i '\|/root/clashctl/scripts/cmd/clashctl.sh|d' "$shell_rc" 2>/dev/null || true @@ -2711,7 +2744,7 @@ cleanup_legacy_shell_entries() { } install_shell_alias_entry() { - local profile_file alias_file shell_rc bash_completion_file zsh_completion_file + local profile_file alias_file shell_rc bash_completion_file zsh_completion_file home_dir cleanup_legacy_shell_entries @@ -2747,7 +2780,8 @@ esac EOF chmod +x "$profile_file" - for shell_rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + home_dir="$(user_home_dir)" + for shell_rc in "$home_dir/.bashrc" "$home_dir/.zshrc" "$home_dir/.profile"; do install_rc_source_block "$shell_rc" "$profile_file" done @@ -2780,8 +2814,9 @@ remove_shell_alias_entry() { rm -f "$profile_file" 2>/dev/null || true if [ "$INSTALL_SCOPE" = "user" ]; then - local shell_rc - for shell_rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + local shell_rc home_dir + home_dir="$(user_home_dir)" + for shell_rc in "$home_dir/.bashrc" "$home_dir/.zshrc" "$home_dir/.profile"; do [ -f "$shell_rc" ] || continue awk -v profile="$profile_file" ' index($0, profile) == 0 { print } @@ -2803,18 +2838,21 @@ install_runtime_ready() { } shell_rc_files() { + local home_dir + if [ "$INSTALL_SCOPE" = "system" ]; then echo "/etc/profile.d/clash-for-linux.sh" return 0 fi - echo "$HOME/.bashrc" - echo "$HOME/.zshrc" - echo "$HOME/.profile" + home_dir="$(user_home_dir)" + echo "$home_dir/.bashrc" + echo "$home_dir/.zshrc" + echo "$home_dir/.profile" } user_local_bin_dir() { - echo "$HOME/.local/bin" + echo "$(user_home_dir)/.local/bin" } command_install_dir() { @@ -2825,7 +2863,7 @@ command_install_dir() { fi echo "/usr/local/bin" else - echo "$HOME/.local/bin" + echo "$(user_home_dir)/.local/bin" fi } @@ -2833,7 +2871,7 @@ profile_entry_file() { if [ "$INSTALL_SCOPE" = "system" ]; then echo "/etc/profile.d/clash-for-linux.sh" else - echo "$HOME/.config/clash-for-linux/profile.sh" + echo "$(user_home_dir)/.config/clash-for-linux/profile.sh" fi } diff --git a/scripts/core/completion.sh b/scripts/core/completion.sh index 19e7d6b..3f9fdc2 100644 --- a/scripts/core/completion.sh +++ b/scripts/core/completion.sh @@ -410,10 +410,10 @@ completion_emit_bash_script() { completion_emit_zsh_script() { cat <<'EOF' -# Ensure compinit is loaded before bashcompinit (provides compdef) -if ! command -v compinit >/dev/null 2>&1; then - autoload -Uz compinit && compinit -u 2>/dev/null +if [ -z "${ZSH_VERSION:-}" ]; then + return 0 fi +command -v compdef >/dev/null 2>&1 || return 0 autoload -Uz bashcompinit 2>/dev/null || return 0 bashcompinit >/dev/null 2>&1 || return 0 EOF diff --git a/scripts/dev/check-home-unset-install-paths.sh b/scripts/dev/check-home-unset-install-paths.sh new file mode 100644 index 0000000..472fded --- /dev/null +++ b/scripts/dev/check-home-unset-install-paths.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +output="$( + env -u HOME bash -c ' + set -euo pipefail + PROJECT_DIR="'"$PROJECT_DIR"'" + source "$PROJECT_DIR/scripts/core/common.sh" + init_project_context "$PROJECT_DIR" + detect_install_scope user + completion_dir + profile_entry_file + command_install_dir + ' +)" + +if printf '%s\n' "$output" | grep -Eq '/\.config/clash-for-linux|/\.local/bin'; then + echo "ok - user install paths resolve when HOME is unset" +else + echo "not ok - missing user install path output" >&2 + printf '%s\n' "$output" >&2 + exit 1 +fi diff --git a/scripts/dev/check-select-direct.sh b/scripts/dev/check-select-direct.sh new file mode 100644 index 0000000..e81b925 --- /dev/null +++ b/scripts/dev/check-select-direct.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +source_clashctl_for_tests() { + set -- "" + source "$PROJECT_DIR/scripts/core/clashctl.sh" >/dev/null +} + +source_clashctl_for_tests + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +prepare() { :; } +status_is_running() { return 0; } +proxy_controller_reachable() { return 0; } +print_select_feedback() { printf 'feedback:%s\n' "$1"; } +die_usage() { + printf 'usage:%s\nnext:%s\n' "$1" "${2:-}" + exit 2 +} +die_state() { + printf 'state:%s\nnext:%s\n' "$1" "${2:-}" + exit 3 +} + +run_help_case() { + local output rc + + set +e + output="$(cmd_select --help 2>&1)" + rc=$? + set -e + + if [ "$rc" -ne 0 ]; then + echo "not ok - select help should succeed" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi + + if ! printf '%s\n' "$output" | grep -Fq "clashctl select <策略组> <节点>"; then + echo "not ok - select help missing direct usage" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi + + echo "ok - select help prints direct usage" +} + +run_direct_case() { + local output + + proxy_group_select() { + printf '%s -> %s\n' "$1" "$2" > "$tmp_dir/selected" + } + + output="$(cmd_select "🚀 节点选择" "日本 01")" + + if [ "$(cat "$tmp_dir/selected")" != "🚀 节点选择 -> 日本 01" ]; then + echo "not ok - direct select did not pass group and node through" >&2 + cat "$tmp_dir/selected" >&2 + return 1 + fi + + if ! printf '%s\n' "$output" | grep -Fq "feedback:🚀 节点选择"; then + echo "not ok - direct select did not print feedback" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi + + echo "ok - direct select delegates to proxy_group_select" +} + +run_bad_args_case() { + local output rc + + set +e + output="$(cmd_select only-group 2>&1)" + rc=$? + set -e + + if [ "$rc" -ne 2 ]; then + echo "not ok - bad direct select args should fail usage" >&2 + printf 'rc=%s\n%s\n' "$rc" "$output" >&2 + return 1 + fi + + echo "ok - direct select validates arguments" +} + +run_help_case +run_direct_case +run_bad_args_case diff --git a/scripts/dev/check-zsh-completion-safe.sh b/scripts/dev/check-zsh-completion-safe.sh new file mode 100644 index 0000000..56dec2d --- /dev/null +++ b/scripts/dev/check-zsh-completion-safe.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +completion="$(bash "$PROJECT_DIR/scripts/core/clashctl.sh" completion zsh)" + +if printf '%s\n' "$completion" | grep -Eq '(^|[[:space:]])compinit([[:space:]]|$)'; then + echo "not ok - zsh completion should not run compinit from project startup" >&2 + exit 1 +fi + +if ! printf '%s\n' "$completion" | grep -Fq 'bashcompinit'; then + echo "not ok - zsh completion should still bridge bash completions when available" >&2 + exit 1 +fi + +if ! printf '%s\n' "$completion" | grep -Fq 'command -v compdef'; then + echo "not ok - zsh completion should guard compdef availability" >&2 + exit 1 +fi + +echo "ok - zsh completion is non-invasive" diff --git a/scripts/init/systemd-user.sh b/scripts/init/systemd-user.sh index 624bcd9..b8a2a09 100644 --- a/scripts/init/systemd-user.sh +++ b/scripts/init/systemd-user.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash install_systemd_user_entry() { - local user_dir unit_file - user_dir="$HOME/.config/systemd/user" + local user_dir unit_file home_dir + home_dir="$(user_home_dir)" + user_dir="$home_dir/.config/systemd/user" unit_file="$user_dir/$(service_unit_name)" mkdir -p "$user_dir" @@ -14,9 +15,9 @@ After=default.target [Service] Type=forking -ExecStart=$HOME/.local/bin/clashctl start-direct -ExecStop=$HOME/.local/bin/clashctl stop-direct -ExecReload=$HOME/.local/bin/clashctl restart-direct +ExecStart=$home_dir/.local/bin/clashctl start-direct +ExecStop=$home_dir/.local/bin/clashctl stop-direct +ExecReload=$home_dir/.local/bin/clashctl restart-direct PIDFile=$RUNTIME_DIR/mihomo.pid WorkingDirectory=$PROJECT_DIR Restart=on-failure @@ -38,8 +39,9 @@ EOF } remove_systemd_user_entry() { - local unit_file - unit_file="$HOME/.config/systemd/user/$(service_unit_name)" + local unit_file home_dir + home_dir="$(user_home_dir)" + unit_file="$home_dir/.config/systemd/user/$(service_unit_name)" if [ -f "$unit_file" ]; then systemctl --user disable "$(service_unit_name)" >/dev/null 2>&1 || true