From 0b2d2b6c6dd5926e2be48dac81cc3cb0e0288019 Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 16 Sep 2026 15:51:48 +0800 Subject: [PATCH] feat(ff-ssh): add host from ssh args with ssh-copy-id - ff ssh parses target like ssh, runs ssh-copy-id, then writes ~/.ssh/config.d/.conf (HostName/User/Port/IdentityFile/ ProxyJump/ProxyCommand); supports --no-copy, add and run subcommands - record IdentityFile whenever -i / -o IdentityFile= is given and pass the same key to ssh-copy-id; omit it otherwise so ssh falls back to default identities - ff ssh re-sources ff-ssh.fish each call, ff reload clears cached dotfile functions so edits take effect - fix broken copy subcommand and ssh-arg parsing caused by --argument-names argv shadowing fish $argv - fix ssh subcommand completion guards; wrap ssh completions for add/run --- fish/completions/ff.fish | 21 ++- fish/functions/ff-ssh.fish | 316 +++++++++++++++++++++++++++++++++++-- fish/functions/ff.fish | 12 +- 3 files changed, 327 insertions(+), 22 deletions(-) diff --git a/fish/completions/ff.fish b/fish/completions/ff.fish index d41cdfe..295add1 100644 --- a/fish/completions/ff.fish +++ b/fish/completions/ff.fish @@ -14,7 +14,7 @@ complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "s" -d "R complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "help" -d "Show this help message" complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "region" -d "Toggle or set CN_REGION (mirror/proxy region)" -complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "ssh" -d "SSH init (keys + config.d) or copy config to remote" +complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "ssh" -d "SSH init / copy key + add host / copy config to remote" complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "dirsize" -d "Show directory sizes" complete -c ff -n "__fish_seen_subcommand_from dirsize" -s a -l all -d "Recursively show all subdirectories" @@ -24,9 +24,14 @@ complete -c ff -n "__fish_seen_subcommand_from dirsize" -s r -l reverse -d "Sort complete -c ff -n "__fish_seen_subcommand_from dirsize" -s h -l help -d "Show help" complete -c ff -n "__fish_seen_subcommand_from dirsize" -a "(__fish_complete_directories)" -d "Directory" -complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a init -d "Ensure SSH key and config.d Include" -complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a copy -d "Copy specified SSH config file(s) to remote" -complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a help -d "Show ssh subcommand help" +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a init -d "Ensure SSH key and config.d Include" +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a add -d "Copy key + add host from ssh-style args" +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a run -d "Copy key + add host, then connect" +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a copy -d "Copy specified SSH config file(s) to remote" +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a help -d "Show ssh subcommand help" + +# 裸参数与 ssh 原生参数一致:复用 ssh 自带的补全(选项 + 主机名) +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help" -w ssh function __ff_ssh_config_d_files for d in $HOME/.ssh/config.d $HOME/.dotfile/ssh/config.d @@ -40,8 +45,12 @@ function __ff_ssh_config_d_files end end -complete -c ff -n "__fish_seen_subcommand_from ssh copy; and test (count (commandline -opc)) -eq 2" -a "(__fish_ssh_hosts 2>/dev/null; or __fish_print_hostnames)" -d "SSH host" -complete -c ff -n "__fish_seen_subcommand_from ssh copy; and test (count (commandline -opc)) -ge 3" -a "(__ff_ssh_config_d_files)" -d "Config file" +complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from copy; and test (count (commandline -opc)) -eq 3" -a "(__fish_print_hostnames)" -d "SSH host" +complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from copy; and test (count (commandline -opc)) -ge 4" -a "(__ff_ssh_config_d_files)" -d "Config file" + +# add/run 使用与 ssh 相同的参数:直接复用 ssh 的选项与主机补全 +complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from add run" -w ssh +complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -l no-copy -d "Skip ssh-copy-id" # # make 子命令的补全 - 直接使用脚本文件名 # complete -c ff -n "__fish_seen_subcommand_from make" -a "help" -d "Show all make targets" diff --git a/fish/functions/ff-ssh.fish b/fish/functions/ff-ssh.fish index 3c237fa..3cff726 100644 --- a/fish/functions/ff-ssh.fish +++ b/fish/functions/ff-ssh.fish @@ -1,4 +1,4 @@ -# ff ssh: local init (keys + config.d) and copy config to remote +# ff ssh: local init (keys + config.d), copy key + add host, copy config to remote function __ff_ssh_has_key for f in $HOME/.ssh/id_* @@ -95,7 +95,7 @@ function __ff_ssh_resolve_copy_src --argument file return 1 end -function __ff_ssh_copy_remote --argument-names argv +function __ff_ssh_copy_remote set -l host $argv[1] set -l files $argv[2..-1] set -l ssh_dir "$HOME/.ssh" @@ -158,24 +158,316 @@ function __ff_ssh_copy_remote --argument-names argv echo "Done." end -function ff_ssh --argument-names argv +# ---- ssh-style argument parsing ------------------------------------------- + +# ssh short options that consume a following value +function __ff_ssh_opt_takes_value --argument-names flag + contains -- $flag -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w +end + +# First non-option argument of `ssh`-style args (the [user@]host) +function __ff_ssh_find_target + set -l n (count $argv) + set -l i 1 + while test $i -le $n + set -l a $argv[$i] + if test "$a" = '--' + set i (math $i + 1) + if test $i -le $n + echo $argv[$i] + end + return 0 + end + if string match -q -- '-*' $a + set -l flag (string sub -s 1 -l 2 -- $a) + if test (string length -- $a) -eq 2; and __ff_ssh_opt_takes_value $flag + set i (math $i + 2) + continue + end + set i (math $i + 1) + continue + end + echo $a + return 0 + end + return 1 +end + +# Value of a short option, supporting both `-i file` and `-ifile` +function __ff_ssh_argv_value --argument-names flag + set -l args $argv[2..-1] + set -l n (count $args) + set -l flen (string length -- $flag) + set -l i 1 + while test $i -le $n + set -l a $args[$i] + if test "$a" = "$flag" + set -l j (math $i + 1) + if test $j -le $n + echo $args[$j] + end + return 0 + end + if test (string length -- $a) -gt $flen; and string match -q -- "$flag*" $a + string sub -s (math $flen + 1) -- $a + return 0 + end + set i (math $i + 1) + end + return 1 +end + +# One value from `ssh -G` output (stdin) +function __ff_ssh_gval --argument-names key + while read -l line + set -l trimmed (string trim -- $line) + set -l k (string split -m1 ' ' -- $trimmed)[1] + if test (string lower -- $k) = (string lower -- $key) + string replace -r '^\S+\s+' '' -- $trimmed + return 0 + end + end +end + +# File that already declares `Host ` (main config or config.d) +function __ff_ssh_find_alias_file --argument-names alias_name + set -l files $HOME/.ssh/config + if test -d $HOME/.ssh/config.d + set files $files $HOME/.ssh/config.d/* + end + for f in $files + test -f $f; or continue + if awk -v want=(string lower -- $alias_name) ' + tolower($1) == "host" { + for (i = 2; i <= NF; i++) + if (tolower($i) == want) hit = 1 + } + END { exit(hit ? 0 : 1) } + ' $f + echo $f + return 0 + end + end + return 1 +end + +# 复制公钥到远端(ssh-copy-id),失败不阻断配置写入 +function __ff_ssh_copy_id + set -l target $argv[1] + set -l port $argv[2] + set -l identity $argv[3] + set -l proxyjump $argv[4] + set -l proxycommand $argv[5] + + if not type -q ssh-copy-id + echo "ff ssh: 未找到 ssh-copy-id,跳过公钥复制(可手动执行 ssh-copy-id $target)" >&2 + return 1 + end + + set -l cmd ssh-copy-id + if test -n "$port"; and test "$port" != 22 + set cmd $cmd -p $port + end + if test -n "$identity" + set cmd $cmd -i $identity + end + if test -n "$proxyjump" + set cmd $cmd -o "ProxyJump=$proxyjump" + end + if test -n "$proxycommand" + set cmd $cmd -o "ProxyCommand=$proxycommand" + end + set cmd $cmd $target + + echo "复制公钥: $cmd" + $cmd +end + +# `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host` +# 确保本地 key -> ssh-copy-id -> 写 ~/.ssh/config.d/.conf +function __ff_ssh_add_host + set -l args + set -l do_copy 1 + for a in $argv + if test "$a" = --no-copy + set do_copy 0 + else + set args $args $a + end + end + + set -l target (__ff_ssh_find_target $args) + if test -z "$target" + echo "ff ssh: 未找到 [user@]hostname 参数" >&2 + return 1 + end + + # ssh://user@host:port/path -> user / host / port + set -l plain (string replace -r '^ssh://' '' -- $target) + set plain (string replace -r '/.*$' '' -- $plain) + set -l user "" + if string match -q '*@*' -- $plain + set user (string replace -r '@[^@]*$' '' -- $plain) + end + set -l hostport (string replace -r '^.*@' '' -- $plain) + set -l port "" + if string match -q '*:*' -- $hostport + set port (string replace -r '^.*:' '' -- $hostport) + set hostport (string replace -r ':[^:]*$' '' -- $hostport) + end + set -l host $hostport + if test -z "$host" + echo "ff ssh: 无法解析主机名: $target" >&2 + return 1 + end + test -n "$port"; or set port (__ff_ssh_argv_value -p $args) + + set -l alias_name $host + if test -t 0 + read -P "SSH 别名 [$host]: " answer + if test $status -eq 0; and test -n "$answer" + set alias_name $answer + end + end + if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._-]*$' -- $alias_name + echo "ff ssh: 非法别名 '$alias_name'(只允许字母、数字、点、下划线、连字符)" >&2 + return 1 + end + + set -l existing (__ff_ssh_find_alias_file $alias_name) + if test -n "$existing" + echo "SSH 别名 '$alias_name' 已存在于 $existing,跳过写入" + return 0 + end + + # 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy* + set -l resolved (ssh -G $args 2>/dev/null) + set -l rhost (printf '%s\n' $resolved | __ff_ssh_gval hostname) + set -l ruser (printf '%s\n' $resolved | __ff_ssh_gval user) + set -l rport (printf '%s\n' $resolved | __ff_ssh_gval port) + set -l proxyjump (printf '%s\n' $resolved | __ff_ssh_gval proxyjump) + set -l proxycommand (printf '%s\n' $resolved | __ff_ssh_gval proxycommand) + test -n "$rhost"; or set rhost $host + test -n "$user"; or set user $ruser + test -n "$port"; or set port $rport + test -n "$port"; or set port 22 + test "$proxyjump" = none; and set proxyjump "" + test "$proxycommand" = none; and set proxycommand "" + + # IdentityFile: ssh -G 会列出所有默认密钥,仅在显式指定时记录 + set -l identity "" + set -l explicit_identity (__ff_ssh_argv_value -i $args) + if test -n "$explicit_identity" + set identity $explicit_identity + else if string match -q '*IdentityFile*' -- $args + set identity (printf '%s\n' $resolved | __ff_ssh_gval identityfile) + end + if string match -q -- "$HOME/*" "$identity" + set identity "~/"(string replace -r "^$HOME/" '' -- $identity) + end + + # ssh-copy-id 也要用同一把密钥(-i 或 -o IdentityFile=),否则会复制默认 key + set -l copy_identity $explicit_identity + if test -z "$copy_identity"; and test -n "$identity" + set copy_identity (string replace -r '^~/' "$HOME/" -- $identity) + end + + # 先复制公钥,这样写完配置就能直接 ssh + if test "$do_copy" = 1 + __ff_ssh_ensure_keys + __ff_ssh_copy_id "$target" "$port" "$copy_identity" "$proxyjump" "$proxycommand" + or echo "ff ssh: 公钥复制失败,已继续写入配置(可重试: ff ssh add $target)" >&2 + end + + set -l dir "$HOME/.ssh/config.d" + mkdir -p $dir + chmod 700 "$HOME/.ssh" $dir + set -l conf_file "$dir/$alias_name.conf" + if test -f $conf_file + cp -p $conf_file "$conf_file.backup."(date +%Y%m%d%H%M%S) + end + + set -l tmpdir /tmp + test -n "$TMPDIR"; and set tmpdir $TMPDIR + set -l tmp (mktemp "$tmpdir/ff-ssh.XXXXXX") + begin + printf 'Host %s\n' $alias_name + printf ' HostName %s\n' $rhost + if test -n "$user" + printf ' User %s\n' $user + end + printf ' Port %s\n' $port + if test -n "$identity" + printf ' IdentityFile %s\n' $identity + end + if test -n "$proxyjump" + printf ' ProxyJump %s\n' $proxyjump + end + if test -n "$proxycommand" + printf ' ProxyCommand %s\n' $proxycommand + end + end > $tmp + chmod 600 $tmp + mv $tmp $conf_file + + echo "已写入 SSH 配置: $conf_file" + echo "以后可直接运行: ssh $alias_name" +end + +function __ff_ssh_usage + echo "用法: ff ssh [init | | add | run | copy ... | help]" + echo "" + echo " (无参数) / init 确保本地 SSH key 存在,并启用 ~/.ssh/config.d Include" + echo " add 确保本地 key,ssh-copy-id 后写入 ~/.ssh/config.d/<别名>.conf" + echo " run 同 add,写完后立即连接(此时应已免密)" + echo " 裸参数等同 add(例: ff ssh -p 2222 root@1.2.3.4)" + echo " copy 检查远端 Include,然后 scp 指定配置文件到远端" + echo " help 显示此帮助" + echo "" + echo "选项:" + echo " --no-copy 只写配置,不执行 ssh-copy-id" + echo "" + echo "保留子命令: init / copy / add / run / help(同名主机请用 ssh 直接连接)" + echo "" + echo "示例:" + echo " ff ssh # 初始化本地 key 与 config.d" + echo " ff ssh a@b.com # 复制公钥 + 添加 Host 配置(交互式确认别名)" + echo " ff ssh -p 2222 -i ~/.ssh/id_x root@1.2.3.4" + echo " ff ssh --no-copy a@b.com # 只写配置,不复制公钥" + echo " ff ssh run a@b.com # 复制公钥 + 写配置并立即连接" + echo " ff ssh copy mynas 30-hanxi.conf # 推送配置片段到远端" +end + +function ff_ssh set -l sub (test (count $argv) -ge 1; and echo $argv[1]; or echo init) switch $sub case init '' __ff_ssh_ensure_keys __ff_ssh_ensure_config_d + case add + __ff_ssh_ensure_config_d + __ff_ssh_add_host $argv[2..-1] + case run + set -l run_args + for a in $argv[2..-1] + test "$a" = --no-copy; and continue + set run_args $run_args $a + end + __ff_ssh_ensure_config_d + __ff_ssh_add_host $argv[2..-1]; or return 1 + command ssh $run_args case copy __ff_ssh_copy_remote $argv[2..-1] - case help -h - echo "Usage: ff ssh [init|copy ...|help]" - echo "" - echo " init Ensure SSH key and config.d Include (default)" - echo " copy Check remote Include, then scp specified config file(s)" - echo " help Show this help" + case help -h --help + __ff_ssh_usage case '*' - echo "Unknown subcommand: $sub" - echo "Run 'ff ssh help' for usage" - return 1 + # 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh + if __ff_ssh_find_target $argv >/dev/null + __ff_ssh_ensure_config_d + __ff_ssh_add_host $argv + else + command ssh $argv + end end end diff --git a/fish/functions/ff.fish b/fish/functions/ff.fish index 78f1f2a..e4c3ec6 100644 --- a/fish/functions/ff.fish +++ b/fish/functions/ff.fish @@ -14,7 +14,7 @@ function ff "s|Alias for script" \ "update|Update dotfile" \ "region|Toggle or set CN_REGION (mirror/proxy region)" \ - "ssh|SSH init (keys + config.d) or copy config to remote" \ + "ssh|SSH init, copy key + add host config, or copy config to remote" \ "dirsize|Show directory sizes (du wrapper)" \ "help|Show this help message" @@ -28,6 +28,11 @@ function ff switch $argv[1] case reload + # autoload 会缓存函数定义,先清掉 dotfile 里的函数再重载 + for __f in ~/.dotfile/fish/functions/*.fish + functions -e (basename $__f .fish) 2>/dev/null + end + set -e __f source ~/.config/fish/config.fish echo "Fish configuration reloaded!" case edit @@ -95,9 +100,8 @@ function ff return 1 end case ssh - if not functions -q ff_ssh - source ~/.dotfile/fish/functions/ff-ssh.fish - end + # 每次都重新 source,避免 autoload 缓存旧定义 + source ~/.dotfile/fish/functions/ff-ssh.fish ff_ssh $argv[2..-1] case dirsize set -l script_path ~/.dotfile/script/dirsize.sh