Compare commits
15 Commits
c95a1d70b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9e8a50bd9 | |||
| 39cf5c6d19 | |||
| 59e2356f5c | |||
| 2ede67d1c9 | |||
| 4fc664d243 | |||
| 89ad569581 | |||
| cffe3f01f4 | |||
| 8c80e5b630 | |||
| 2060bbd23a | |||
| 0b2d2b6c6d | |||
| 0b04a4de50 | |||
| 097fce7827 | |||
| a834c46b89 | |||
| 2dedb9573c | |||
| dc302bff1c |
@@ -10,6 +10,8 @@ ff script <name> # 运行脚本
|
|||||||
make -f Makefile.scripts <name> # 同上(make 入口)
|
make -f Makefile.scripts <name> # 同上(make 入口)
|
||||||
ff region # 切换 CN_REGION(国内/国外镜像)
|
ff region # 切换 CN_REGION(国内/国外镜像)
|
||||||
ff reload # 重载 fish 配置
|
ff reload # 重载 fish 配置
|
||||||
|
ff update # 立即 git pull origin main 并 reload
|
||||||
|
fish fish/tests/ff-auto-update.fish # ff 自动更新回归
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录职责
|
## 目录职责
|
||||||
@@ -21,7 +23,8 @@ ff reload # 重载 fish 配置
|
|||||||
| `fish/default.fish` | 共享 fish 配置(进 git) |
|
| `fish/default.fish` | 共享 fish 配置(进 git) |
|
||||||
| `~/.config/fish/config.fish` | 本机 wrapper,仅 `source` default.fish + 可选 `custom.fish` |
|
| `~/.config/fish/config.fish` | 本机 wrapper,仅 `source` default.fish + 可选 `custom.fish` |
|
||||||
| `fish/functions/`、`fish/completions/` | fish 函数与补全;`fish-init` 会软链到 `~/.config/fish/` |
|
| `fish/functions/`、`fish/completions/` | fish 函数与补全;`fish-init` 会软链到 `~/.config/fish/` |
|
||||||
| `git/`、`nvim/`、`tmux/`、`zsh/`、`cursor/`、`ghostty/`、`mise/` 等 | 各工具**真实配置**,由对应 `*-init` 软链到系统路径 |
|
| `git/`、`nvim/`、`tmux/`、`zsh/`、`cursor/`、`ghostty/` 等 | 各工具**真实配置**,由对应 `*-init` 软链到系统路径 |
|
||||||
|
| `mise/config.toml` | 跨机器共享;`mise-install` 链到 `~/.config/mise/conf.d/dotfile.toml`。本机工具在 `~/.config/mise/config.toml` |
|
||||||
| `Makefile.scripts` | `make <script-name>` 运行 `script/<name>.sh` |
|
| `Makefile.scripts` | `make <script-name>` 运行 `script/<name>.sh` |
|
||||||
|
|
||||||
## 脚本命名
|
## 脚本命名
|
||||||
@@ -51,16 +54,22 @@ ff reload # 重载 fish 配置
|
|||||||
- 优先用系统包管理器(brew/apt),其次官方安装脚本或 Release 二进制到 `~/.local/bin`
|
- 优先用系统包管理器(brew/apt),其次官方安装脚本或 Release 二进制到 `~/.local/bin`
|
||||||
- Debian/Ubuntu 安装类脚本应支持 `CN_REGION` 切换镜像(参考 `docker-install.sh`)
|
- Debian/Ubuntu 安装类脚本应支持 `CN_REGION` 切换镜像(参考 `docker-install.sh`)
|
||||||
- 需要 root 的脚本在 usage 里写 `sudo`,不要静默假设
|
- 需要 root 的脚本在 usage 里写 `sudo`,不要静默假设
|
||||||
|
- **mise**:共享 `mise/config.toml` 链到 `~/.config/mise/conf.d/dotfile.toml`;`mise use -g` 写本机 `~/.config/mise/config.toml`,不进 git。旧的指向仓库的全局软链由 `mise-install` 解开
|
||||||
|
|
||||||
## Fish / `ff` 入口
|
## Fish / `ff` 入口
|
||||||
|
|
||||||
- 跑脚本:`ff script <name>` 或 `ff s <name>`(走 `execute-script.fish`)
|
- 跑脚本:`ff script <name>` 或 `ff s <name>`(走 `execute-script.fish`)
|
||||||
- 列脚本:`ff script -h`
|
- 列脚本:`ff script -h`
|
||||||
- 新增 `ff` 子命令时:改 `fish/functions/ff.fish` + `fish/completions/ff.fish`
|
- 新增 `ff` 子命令时:改 `fish/functions/ff.fish` + `fish/completions/ff.fish`
|
||||||
|
- **自动更新**:日常 `ff` 命令超过间隔(默认开、24h)会顺带 `git pull --ff-only origin main` 更新 `~/.dotfile`,成功且有新提交后再 reload。配置在本机 `~/.config/ff/config`(`enabled`、`interval`),时间戳 `~/.local/share/ff/state`,不要进 git。跳过:`FF_NO_AUTO_UPDATE=1` 或 `ff --no-auto-update …`。显式 `ff update` 立即拉取并刷新时间戳,这次不再走自动更新。脏工作区、超时、网络失败时 stderr 一行并继续当前命令。回归:`fish fish/tests/ff-auto-update.fish`
|
||||||
|
|
||||||
## Git 配置
|
## Git 配置
|
||||||
|
|
||||||
- 公共配置在 `git/.gitconfig`
|
- 公共配置在 `git/.gitconfig`
|
||||||
|
- **身份**:不设全局 `user.name` / `user.email`,`user.useConfigOnly = true`。未匹配目录时拒绝 commit,避免错用身份
|
||||||
|
- `~/.dotfile/`、`~/code/`、`~/.pouch/` → `git/.gitconfig.personal`(个人,进仓库,已写 name/email)
|
||||||
|
- `~/workrepo/` → `~/.gitconfig.work`(公司,仅本机;`git-init` 会生成空模板)
|
||||||
|
- include 目标文件必须存在,否则该目录下 git 命令会 fatal
|
||||||
- **凭据**(所有 Git 版本):`git/git-credential.sh` 写在主配置里,使用 `!$HOME/.dotfile/...`(Git 不展开 `~`)
|
- **凭据**(所有 Git 版本):`git/git-credential.sh` 写在主配置里,使用 `!$HOME/.dotfile/...`(Git 不展开 `~`)
|
||||||
- macOS:`osxkeychain` → 回退 `store`(`~/.git-credentials`)
|
- macOS:`osxkeychain` → 回退 `store`(`~/.git-credentials`)
|
||||||
- Linux:`libsecret` → 回退 `store`
|
- Linux:`libsecret` → 回退 `store`
|
||||||
@@ -71,6 +80,8 @@ ff reload # 重载 fish 配置
|
|||||||
|
|
||||||
- 不要把密钥、webhook、本机专用路径写进 git(`script/clash.sh` 等已在 `.gitignore`)
|
- 不要把密钥、webhook、本机专用路径写进 git(`script/clash.sh` 等已在 `.gitignore`)
|
||||||
- 不要改 `~/.config/fish/config.fish` 进仓库;本机差异放 `custom.fish`
|
- 不要改 `~/.config/fish/config.fish` 进仓库;本机差异放 `custom.fish`
|
||||||
|
- 不要把本机 `mise use -g` 的工具写进 `mise/config.toml`;写 `~/.config/mise/config.toml`
|
||||||
|
- 不要把 `~/.config/ff/config` 或 `~/.local/share/ff/state` 写进 git(本机自动更新配置)
|
||||||
- 不要为 init 复制整份配置到 `$HOME`;**优先软链接**到 dotfile
|
- 不要为 init 复制整份配置到 `$HOME`;**优先软链接**到 dotfile
|
||||||
- 脚本改动保持最小 diff,风格与相邻脚本一致(bash、`set -euo pipefail` 视情况)
|
- 脚本改动保持最小 diff,风格与相邻脚本一致(bash、`set -euo pipefail` 视情况)
|
||||||
- 未经明确要求不要 `git commit` / `git push`
|
- 未经明确要求不要 `git commit` / `git push`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Completions for ff command
|
# Completions for ff command
|
||||||
complete -c ff -f
|
complete -c ff -f
|
||||||
set -l ff_subcommands reload edit fish dotfile pon poff pst make script s update help region ssh init copy dirsize help region
|
set -l ff_subcommands reload edit fish dotfile pon poff pst make script s update help region ssh init copy dirsize pouch help region
|
||||||
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "reload" -d "Reload fish configuration"
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "reload" -d "Reload fish configuration"
|
||||||
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "edit" -d "Edit custom fish configuration"
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "edit" -d "Edit custom fish configuration"
|
||||||
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "fish" -d "Change to fish config directory"
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "fish" -d "Change to fish config directory"
|
||||||
@@ -11,11 +11,15 @@ complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "pst" -d
|
|||||||
# complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "make" -d "Run dotfile Makefile commands"
|
# complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "make" -d "Run dotfile Makefile commands"
|
||||||
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "script" -d "Run script from script directory"
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "script" -d "Run script from script directory"
|
||||||
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "s" -d "Run script (alias for script)"
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "s" -d "Run script (alias for script)"
|
||||||
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "update" -d "Update dotfile now (also auto every 24h)"
|
||||||
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 "help" -d "Show this help message"
|
||||||
|
complete -c ff -l no-auto-update -d "Skip auto-update this run"
|
||||||
|
|
||||||
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 "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 "not __fish_seen_subcommand_from $ff_subcommands" -a "dirsize" -d "Show directory sizes"
|
||||||
|
complete -c ff -n "not __fish_seen_subcommand_from $ff_subcommands" -a "pouch" -d "Clone and install pouch to ~/.pouch"
|
||||||
|
complete -c ff -n "__fish_seen_subcommand_from pouch" -s h -l help -d "Show help"
|
||||||
|
|
||||||
complete -c ff -n "__fish_seen_subcommand_from dirsize" -s a -l all -d "Recursively show all subdirectories"
|
complete -c ff -n "__fish_seen_subcommand_from dirsize" -s a -l all -d "Recursively show all subdirectories"
|
||||||
complete -c ff -n "__fish_seen_subcommand_from dirsize" -s d -l depth -d "Recursion depth (default 1)" -r
|
complete -c ff -n "__fish_seen_subcommand_from dirsize" -s d -l depth -d "Recursion depth (default 1)" -r
|
||||||
@@ -24,9 +28,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" -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 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 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 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 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 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 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
|
function __ff_ssh_config_d_files
|
||||||
for d in $HOME/.ssh/config.d $HOME/.dotfile/ssh/config.d
|
for d in $HOME/.ssh/config.d $HOME/.dotfile/ssh/config.d
|
||||||
@@ -40,8 +49,12 @@ function __ff_ssh_config_d_files
|
|||||||
end
|
end
|
||||||
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; 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 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)) -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 子命令的补全 - 直接使用脚本文件名
|
# # make 子命令的补全 - 直接使用脚本文件名
|
||||||
# complete -c ff -n "__fish_seen_subcommand_from make" -a "help" -d "Show all make targets"
|
# complete -c ff -n "__fish_seen_subcommand_from make" -a "help" -d "Show all make targets"
|
||||||
|
|||||||
+7
-1
@@ -89,7 +89,11 @@ end
|
|||||||
set -gx GOPATH $HOME/go:$HOME/workrepo/go
|
set -gx GOPATH $HOME/go:$HOME/workrepo/go
|
||||||
set -gx GOPROXY "goproxy.cn,direct"
|
set -gx GOPROXY "goproxy.cn,direct"
|
||||||
set -gx GOSUMDB "sum.golang.google.cn"
|
set -gx GOSUMDB "sum.golang.google.cn"
|
||||||
|
# editor
|
||||||
|
set -gx SYSTEMD_EDITOR vim
|
||||||
set -gx EDITOR vim
|
set -gx EDITOR vim
|
||||||
|
set -gx VISUAL vim
|
||||||
|
set -gx SUDO_EDITOR vim
|
||||||
add_to_path $HOME/go/bin
|
add_to_path $HOME/go/bin
|
||||||
add_to_path $HOME/.yarn/bin $HOME/.config/yarn/global/node_modules/.bin
|
add_to_path $HOME/.yarn/bin $HOME/.config/yarn/global/node_modules/.bin
|
||||||
function gtr
|
function gtr
|
||||||
@@ -184,8 +188,10 @@ function sudo --wraps sudo
|
|||||||
if test (count $argv) -gt 0
|
if test (count $argv) -gt 0
|
||||||
set cmd $argv[1]
|
set cmd $argv[1]
|
||||||
switch $cmd
|
switch $cmd
|
||||||
|
case systemctl
|
||||||
|
command sudo env SYSTEMD_EDITOR=$SYSTEMD_EDITOR systemctl $argv[2..-1]
|
||||||
case sc
|
case sc
|
||||||
command sudo systemctl $argv[2..-1]
|
command sudo env SYSTEMD_EDITOR=$SYSTEMD_EDITOR systemctl $argv[2..-1]
|
||||||
case jc
|
case jc
|
||||||
command sudo journalctl $argv[2..-1]
|
command sudo journalctl $argv[2..-1]
|
||||||
case dc
|
case dc
|
||||||
|
|||||||
+557
-31
@@ -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
|
function __ff_ssh_has_key
|
||||||
for f in $HOME/.ssh/id_*
|
for f in $HOME/.ssh/id_*
|
||||||
@@ -22,13 +22,50 @@ function __ff_ssh_ensure_keys
|
|||||||
|
|
||||||
echo "No SSH key found, generating ed25519: $key_path"
|
echo "No SSH key found, generating ed25519: $key_path"
|
||||||
if test -t 0
|
if test -t 0
|
||||||
ssh-keygen -t ed25519 -f $key_path -C $comment
|
ssh-keygen -t ed25519 -f $key_path -C $comment; or return 1
|
||||||
else
|
else
|
||||||
ssh-keygen -t ed25519 -f $key_path -C $comment -N ""
|
ssh-keygen -t ed25519 -f $key_path -C $comment -N ""; or return 1
|
||||||
echo "Non-interactive mode: created with empty passphrase"
|
echo "Non-interactive mode: created with empty passphrase"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function __ff_ssh_has_toplevel_include --argument ssh_config
|
||||||
|
awk '
|
||||||
|
tolower($1) == "host" || tolower($1) == "match" { saw_block = 1 }
|
||||||
|
tolower($1) == "include" {
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
|
if (!saw_block) found_ok = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit(found_ok ? 0 : 1) }
|
||||||
|
' "$ssh_config"
|
||||||
|
end
|
||||||
|
|
||||||
|
function __ff_ssh_prepend_include --argument ssh_config include_line
|
||||||
|
set -l tmp (mktemp)
|
||||||
|
awk -v include_line="$include_line" '
|
||||||
|
function is_config_d_include() {
|
||||||
|
if (tolower($1) != "include") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
BEGIN { print include_line }
|
||||||
|
is_config_d_include() { next }
|
||||||
|
{ print }
|
||||||
|
' "$ssh_config" > $tmp
|
||||||
|
cp $tmp "$ssh_config"
|
||||||
|
rm -f $tmp
|
||||||
|
chmod 600 "$ssh_config"
|
||||||
|
end
|
||||||
|
|
||||||
function __ff_ssh_ensure_config_d
|
function __ff_ssh_ensure_config_d
|
||||||
set -l ssh_config "$HOME/.ssh/config"
|
set -l ssh_config "$HOME/.ssh/config"
|
||||||
set -l include_line "Include ~/.ssh/config.d/*"
|
set -l include_line "Include ~/.ssh/config.d/*"
|
||||||
@@ -36,15 +73,22 @@ function __ff_ssh_ensure_config_d
|
|||||||
chmod 700 "$HOME/.ssh"
|
chmod 700 "$HOME/.ssh"
|
||||||
|
|
||||||
if not test -f $ssh_config
|
if not test -f $ssh_config
|
||||||
printf '%s\n' $include_line > $ssh_config
|
printf '%s\n' "$include_line" > $ssh_config
|
||||||
chmod 600 $ssh_config
|
chmod 600 $ssh_config
|
||||||
echo "Created $ssh_config with $include_line"
|
echo "Created $ssh_config with $include_line"
|
||||||
else if grep -qE 'Include[[:space:]]+.*config\.d' $ssh_config
|
return
|
||||||
echo "config.d Include already in $ssh_config"
|
|
||||||
else
|
|
||||||
printf '\n# config.d\n%s\n' $include_line >> $ssh_config
|
|
||||||
echo "Appended to $ssh_config: $include_line"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
chmod 600 $ssh_config
|
||||||
|
if __ff_ssh_has_toplevel_include $ssh_config
|
||||||
|
echo "config.d Include already in $ssh_config"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l backup "$ssh_config.backup."(date +%Y%m%d%H%M%S)
|
||||||
|
cp -p $ssh_config $backup
|
||||||
|
__ff_ssh_prepend_include $ssh_config "$include_line"
|
||||||
|
echo "Prepended to $ssh_config: $include_line (backup $backup)"
|
||||||
end
|
end
|
||||||
|
|
||||||
function __ff_ssh_resolve_path --argument path
|
function __ff_ssh_resolve_path --argument path
|
||||||
@@ -58,22 +102,62 @@ function __ff_ssh_resolve_path --argument path
|
|||||||
end
|
end
|
||||||
|
|
||||||
function __ff_ssh_remote_has_include --argument host
|
function __ff_ssh_remote_has_include --argument host
|
||||||
ssh -q $host 'test -f ~/.ssh/config && grep -qE "Include[[:space:]]+.*config\.d" ~/.ssh/config'
|
set -l script 'test -f ~/.ssh/config && awk "
|
||||||
|
tolower(\$1) == \"host\" || tolower(\$1) == \"match\" { saw_block = 1 }
|
||||||
|
tolower(\$1) == \"include\" {
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
|
if (!saw_block) found_ok = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit(found_ok ? 0 : 1) }
|
||||||
|
" ~/.ssh/config'
|
||||||
|
printf '%s\n' "$script" | ssh -qT $host sh -s
|
||||||
end
|
end
|
||||||
|
|
||||||
function __ff_ssh_remote_ensure_include --argument host
|
function __ff_ssh_remote_ensure_include --argument host
|
||||||
ssh $host 'mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh
|
set -l script 'mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh
|
||||||
if [ -f ~/.ssh/config ] && grep -qE "Include[[:space:]]+.*config\.d" ~/.ssh/config; then
|
include_line="Include ~/.ssh/config.d/*"
|
||||||
echo "Remote config.d Include already exists"
|
has_toplevel() {
|
||||||
elif [ -f ~/.ssh/config ]; then
|
awk "
|
||||||
printf "\n# config.d\nInclude ~/.ssh/config.d/*\n" >> ~/.ssh/config
|
tolower(\$1) == \"host\" || tolower(\$1) == \"match\" { saw_block = 1 }
|
||||||
chmod 600 ~/.ssh/config
|
tolower(\$1) == \"include\" {
|
||||||
echo "Appended config.d Include on remote"
|
for (i = 2; i <= NF; i++) {
|
||||||
else
|
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
printf "%s\n" "Include ~/.ssh/config.d/*" > ~/.ssh/config
|
if (!saw_block) found_ok = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit(found_ok ? 0 : 1) }
|
||||||
|
" ~/.ssh/config
|
||||||
|
}
|
||||||
|
if [ ! -f ~/.ssh/config ]; then
|
||||||
|
printf "%s\n" "$include_line" > ~/.ssh/config
|
||||||
chmod 600 ~/.ssh/config
|
chmod 600 ~/.ssh/config
|
||||||
echo "Created remote config with config.d Include"
|
echo "Created remote config with config.d Include"
|
||||||
fi'
|
elif has_toplevel; then
|
||||||
|
echo "Remote config.d Include already exists"
|
||||||
|
else
|
||||||
|
tmp=$(mktemp)
|
||||||
|
awk -v include_line="$include_line" "
|
||||||
|
function is_config_d_include() {
|
||||||
|
if (tolower(\$1) != \"include\") return 0
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower(\$i) ~ /(^|\/)config\.d\/\*/) return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
BEGIN { print include_line }
|
||||||
|
is_config_d_include() { next }
|
||||||
|
{ print }
|
||||||
|
" ~/.ssh/config > "$tmp"
|
||||||
|
cp "$tmp" ~/.ssh/config
|
||||||
|
rm -f "$tmp"
|
||||||
|
chmod 600 ~/.ssh/config
|
||||||
|
echo "Prepended config.d Include on remote"
|
||||||
|
fi'
|
||||||
|
printf '%s\n' "$script" | ssh -T $host sh -s
|
||||||
end
|
end
|
||||||
|
|
||||||
function __ff_ssh_resolve_copy_src --argument file
|
function __ff_ssh_resolve_copy_src --argument file
|
||||||
@@ -95,7 +179,7 @@ function __ff_ssh_resolve_copy_src --argument file
|
|||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
function __ff_ssh_copy_remote --argument-names argv
|
function __ff_ssh_copy_remote
|
||||||
set -l host $argv[1]
|
set -l host $argv[1]
|
||||||
set -l files $argv[2..-1]
|
set -l files $argv[2..-1]
|
||||||
set -l ssh_dir "$HOME/.ssh"
|
set -l ssh_dir "$HOME/.ssh"
|
||||||
@@ -158,24 +242,466 @@ function __ff_ssh_copy_remote --argument-names argv
|
|||||||
echo "Done."
|
echo "Done."
|
||||||
end
|
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
|
||||||
|
|
||||||
|
# Files declaring an exact alias; repeated declarations need manual disambiguation.
|
||||||
|
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
|
||||||
|
awk -v want=(string lower -- $alias_name) '
|
||||||
|
tolower($1) == "host" {
|
||||||
|
for (i = 2; i <= NF && $i !~ /^#/; i++) {
|
||||||
|
if (tolower($i) == want) { print FILENAME; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' $f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Replace managed values in place; split shared Host declarations for other aliases.
|
||||||
|
function __ff_ssh_replace_alias --argument-names alias_name original replacement
|
||||||
|
awk -v want=(string lower -- $alias_name) -v replacement="$replacement" '
|
||||||
|
BEGIN {
|
||||||
|
while ((getline line < replacement) > 0) block = block line "\n"
|
||||||
|
close(replacement)
|
||||||
|
}
|
||||||
|
tolower($1) == "host" || tolower($1) == "match" {
|
||||||
|
updating = 0
|
||||||
|
if (tolower($1) == "host") {
|
||||||
|
hit = 0; others = ""; comment = ""
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if ($i ~ /^#/) {
|
||||||
|
for (; i <= NF; i++) comment = comment " " $i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (tolower($i) == want) hit = 1
|
||||||
|
else others = others " " $i
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
printf "%s", block
|
||||||
|
if (others != "") print "Host" others " !" want comment
|
||||||
|
else {
|
||||||
|
updating = 1
|
||||||
|
if (comment != "") print " " comment
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updating && tolower($1) ~ /^(hostname|user|port|identityfile|proxyjump|proxycommand)$/ { next }
|
||||||
|
{ print }
|
||||||
|
' "$original"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Normalize ssh -G paths (including explicit .pub) to a private-key path.
|
||||||
|
function __ff_ssh_private_path --argument-names identity
|
||||||
|
set identity (string replace -r '^~/' "$HOME/" -- $identity)
|
||||||
|
set identity (string replace -a '%d' "$HOME" -- $identity)
|
||||||
|
set identity (string replace -r '\.pub$' '' -- $identity)
|
||||||
|
if not string match -q '/*' -- $identity
|
||||||
|
set identity "$PWD/$identity"
|
||||||
|
end
|
||||||
|
printf '%s\n' "$identity"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Copy only the selected key; upstream can return 0 after an interrupted filter.
|
||||||
|
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;未写入 Host(可用 --no-copy 只写配置)" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l options -o ConnectTimeout=10 -o ConnectionAttempts=1
|
||||||
|
if test -n "$proxyjump"
|
||||||
|
set options $options -o "ProxyJump=$proxyjump"
|
||||||
|
end
|
||||||
|
if test -n "$proxycommand"
|
||||||
|
set options $options -o "ProxyCommand=$proxycommand"
|
||||||
|
end
|
||||||
|
set -l cmd ssh-copy-id -i "$identity.pub" -p "$port" $options "$target"
|
||||||
|
echo "复制公钥: $cmd"
|
||||||
|
# The wrapper records SIGINT even if ssh-copy-id's cleanup trap exits 0.
|
||||||
|
command sh -c 'trap "exit 130" INT; trap "exit 143" TERM; "$@"' ff-ssh $cmd
|
||||||
|
if test $status -ne 0
|
||||||
|
echo "ff ssh: 公钥复制失败或已取消,未写入 Host" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# A skipped-key warning alone proves nothing. Exclude inherited identities,
|
||||||
|
# certificates and multiplexed sessions; the agent may unlock this key only.
|
||||||
|
command ssh -F /dev/null -i "$identity" -p "$port" $options \
|
||||||
|
-o BatchMode=yes -o PreferredAuthentications=publickey \
|
||||||
|
-o IdentitiesOnly=yes -o CertificateFile=none -o ControlPath=none \
|
||||||
|
"$target" exit
|
||||||
|
if test $status -ne 0
|
||||||
|
echo "ff ssh: 公钥可能已复制,但所选密钥登录验证失败,未写入 Host。加密私钥请先用 ssh-add 解锁,或用 --no-copy 只写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# `ff ssh a@b.com` / `ff ssh -p 2222 -i key root@host`
|
||||||
|
# 确保本地 key -> ssh-copy-id -> 写 ~/.ssh/config.d/<alias>.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 -ne 0
|
||||||
|
echo "ff ssh: 已取消,未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
test -n "$answer"; and set alias_name $answer
|
||||||
|
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"
|
||||||
|
if test (count $existing) -ne 1
|
||||||
|
echo "ff ssh: 别名 '$alias_name' 有多个 Host 声明($existing),请先手动合并;未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
echo "SSH 别名 '$alias_name' 已存在于 $existing"
|
||||||
|
if not test -t 0
|
||||||
|
echo "ff ssh: 非交互模式无法确认覆盖,未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
read -l -P "覆盖更新 '$alias_name'?[y/N]: " overwrite
|
||||||
|
if test $status -ne 0
|
||||||
|
echo "ff ssh: 已取消覆盖,未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
if not contains -- (string lower -- (string trim -- "$overwrite")) y yes
|
||||||
|
echo "ff ssh: 未确认覆盖,未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
else if test -e "$HOME/.ssh/config.d/$alias_name.conf"
|
||||||
|
echo "ff ssh: 目标文件已存在但未声明 '$alias_name',请先手动整理;未复制公钥或改写配置" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# 用 ssh -G 解析最终生效的 HostName/User/Port/Proxy*
|
||||||
|
set -l resolved (ssh -G $args 2>/dev/null)
|
||||||
|
if test $status -ne 0
|
||||||
|
echo "ff ssh: 无法解析 SSH 配置,未写入 Host" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# Explicit -i / -o IdentityFile must never silently fall back to another key.
|
||||||
|
set -l explicit_identity ""
|
||||||
|
for i in (seq (count $args))
|
||||||
|
if test "$args[$i]" = -i; and test $i -lt (count $args)
|
||||||
|
set explicit_identity $args[(math $i + 1)]
|
||||||
|
break
|
||||||
|
else if string match -q -- '-i*' "$args[$i]"
|
||||||
|
set explicit_identity (string sub -s 3 -- "$args[$i]")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
set -l option ""
|
||||||
|
if test "$args[$i]" = -o; and test $i -lt (count $args)
|
||||||
|
set option $args[(math $i + 1)]
|
||||||
|
else if string match -q -- '-o*' "$args[$i]"
|
||||||
|
set option (string sub -s 3 -- "$args[$i]")
|
||||||
|
end
|
||||||
|
if string match -qri '^identityfile[=[:space:]]' -- "$option"
|
||||||
|
set explicit_identity (string replace -ri '^identityfile[=[:space:]]+' '' -- "$option")
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l identities (printf '%s\n' $resolved | string match -ri '^identityfile[[:space:]]+.*$' | string replace -r '^\S+\s+' '')
|
||||||
|
set -l copy_identity ""
|
||||||
|
set -l configured_identity 0
|
||||||
|
if test -n "$explicit_identity"
|
||||||
|
# ssh -G can omit an unreadable -i and print defaults instead.
|
||||||
|
set copy_identity (__ff_ssh_private_path "$explicit_identity")
|
||||||
|
set identities
|
||||||
|
end
|
||||||
|
for candidate in $identities
|
||||||
|
set -l private (__ff_ssh_private_path "$candidate")
|
||||||
|
set -l name (string replace -- "$HOME/.ssh/" '' "$private")
|
||||||
|
if not contains -- "$name" id_rsa id_dsa id_ecdsa id_ecdsa_sk id_ed25519 id_ed25519_sk id_xmss
|
||||||
|
set configured_identity 1
|
||||||
|
end
|
||||||
|
if test -r "$private"; and test -r "$private.pub"
|
||||||
|
set copy_identity "$private"
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ssh-copy-id also supports nonstandard id_*.pub names. Among these, keep
|
||||||
|
# its newest-public-key preference, but only select a complete readable pair.
|
||||||
|
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
|
||||||
|
for pub in (command ls -t "$HOME"/.ssh/id_*.pub 2>/dev/null)
|
||||||
|
string match -q '*-cert.pub' -- "$pub"; and continue
|
||||||
|
set -l private (__ff_ssh_private_path "$pub")
|
||||||
|
if test -r "$private"; and test -r "$pub"
|
||||||
|
set copy_identity "$private"
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if test "$do_copy" = 1
|
||||||
|
if test -z "$copy_identity"; and test -z "$explicit_identity"; and test "$configured_identity" = 0
|
||||||
|
__ff_ssh_ensure_keys; or return 1
|
||||||
|
set -l generated "$HOME/.ssh/id_ed25519"
|
||||||
|
if test -r "$generated"; and test -r "$generated.pub"
|
||||||
|
set copy_identity "$generated"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if test -z "$copy_identity"; or not test -r "$copy_identity"; or not test -r "$copy_identity.pub"
|
||||||
|
echo "ff ssh: 未找到可读的公私钥对,请用 -i 指定密钥;未写入 Host" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
echo "所选 SSH 密钥: $copy_identity"
|
||||||
|
set -l copy_target "$rhost"
|
||||||
|
test -n "$user"; and set copy_target "$user@$rhost"
|
||||||
|
__ff_ssh_copy_id "$copy_target" "$port" "$copy_identity" "$proxyjump" "$proxycommand"; or return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l identity "$copy_identity"
|
||||||
|
if string match -q -- "$HOME/*" "$identity"
|
||||||
|
set identity "~/"(string sub -s (math (string length -- "$HOME") + 2) -- "$identity")
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l dir "$HOME/.ssh/config.d"
|
||||||
|
set -l conf_file "$dir/$alias_name.conf"
|
||||||
|
test -n "$existing"; and set conf_file "$existing"
|
||||||
|
|
||||||
|
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
|
||||||
|
if test -n "$existing"
|
||||||
|
set -l updated (mktemp "$tmpdir/ff-ssh.XXXXXX"); or begin
|
||||||
|
rm -f $tmp
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
__ff_ssh_replace_alias "$alias_name" "$conf_file" "$tmp" > $updated; or begin
|
||||||
|
rm -f $tmp $updated
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
rm -f $tmp
|
||||||
|
set tmp $updated
|
||||||
|
# config.d/* would load backups too, so keep them outside that directory.
|
||||||
|
set -l backup_dir "$HOME/.ssh/config.backups"
|
||||||
|
mkdir -p "$backup_dir"; or return 1
|
||||||
|
chmod 700 "$backup_dir"
|
||||||
|
set -l backup (mktemp "$backup_dir/"(basename "$conf_file")".backup.XXXXXX"); or return 1
|
||||||
|
cp -p "$conf_file" "$backup"; or begin
|
||||||
|
rm -f $tmp $backup
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
echo "原配置备份: $backup"
|
||||||
|
end
|
||||||
|
mkdir -p $dir; or return 1
|
||||||
|
chmod 700 "$HOME/.ssh" $dir
|
||||||
|
chmod 600 $tmp
|
||||||
|
mv $tmp $conf_file; or return 1
|
||||||
|
__ff_ssh_ensure_config_d; or return 1
|
||||||
|
|
||||||
|
echo "已写入 SSH 配置: $conf_file"
|
||||||
|
echo "以后可直接运行: ssh $alias_name"
|
||||||
|
end
|
||||||
|
|
||||||
|
function __ff_ssh_usage
|
||||||
|
echo "用法: ff ssh [init | <ssh 参数> | add <ssh 参数> | run <ssh 参数> | copy <host> <file>... | help]"
|
||||||
|
echo ""
|
||||||
|
echo " (无参数) / init 确保本地 SSH key 存在,并启用 ~/.ssh/config.d Include"
|
||||||
|
echo " add <ssh 参数> 确保本地 key,ssh-copy-id 后写入 ~/.ssh/config.d/<别名>.conf"
|
||||||
|
echo " run <ssh 参数> 同 add,写完后立即连接(此时应已免密)"
|
||||||
|
echo " <ssh 参数> 裸参数等同 add(例: ff ssh -p 2222 root@1.2.3.4)"
|
||||||
|
echo " copy <host> <file> 检查远端 Include,然后 scp 指定配置文件到远端"
|
||||||
|
echo " help 显示此帮助"
|
||||||
|
echo ""
|
||||||
|
echo "选项:"
|
||||||
|
echo " --no-copy 只写配置,不执行 ssh-copy-id 或登录验证"
|
||||||
|
echo " 重复别名需交互确认覆盖(默认否),成功后备份并在原位置更新"
|
||||||
|
echo " 公钥复制后仅用所选密钥验证登录;连接超时 10 秒,失败或取消不写 Host"
|
||||||
|
echo " 验证使用 BatchMode;加密私钥请先用 ssh-add 解锁"
|
||||||
|
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)
|
set -l sub (test (count $argv) -ge 1; and echo $argv[1]; or echo init)
|
||||||
|
|
||||||
switch $sub
|
switch $sub
|
||||||
case init ''
|
case init ''
|
||||||
__ff_ssh_ensure_keys
|
__ff_ssh_ensure_keys
|
||||||
__ff_ssh_ensure_config_d
|
__ff_ssh_ensure_config_d
|
||||||
|
case add
|
||||||
|
__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_add_host $argv[2..-1]; or return 1
|
||||||
|
command ssh $run_args
|
||||||
case copy
|
case copy
|
||||||
__ff_ssh_copy_remote $argv[2..-1]
|
__ff_ssh_copy_remote $argv[2..-1]
|
||||||
case help -h
|
case help -h --help
|
||||||
echo "Usage: ff ssh [init|copy <host> <file>...|help]"
|
__ff_ssh_usage
|
||||||
echo ""
|
|
||||||
echo " init Ensure SSH key and config.d Include (default)"
|
|
||||||
echo " copy <host> <file> Check remote Include, then scp specified config file(s)"
|
|
||||||
echo " help Show this help"
|
|
||||||
case '*'
|
case '*'
|
||||||
echo "Unknown subcommand: $sub"
|
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
|
||||||
echo "Run 'ff ssh help' for usage"
|
if __ff_ssh_find_target $argv >/dev/null
|
||||||
return 1
|
__ff_ssh_add_host $argv
|
||||||
|
else
|
||||||
|
command ssh $argv
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+244
-5
@@ -1,4 +1,214 @@
|
|||||||
# Fast commands management
|
# Fast commands management
|
||||||
|
|
||||||
|
function _ff_config_file
|
||||||
|
echo ~/.config/ff/config
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_state_file
|
||||||
|
echo ~/.local/share/ff/state
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_config_value --argument-names key default
|
||||||
|
set -l file (_ff_config_file)
|
||||||
|
if not test -f $file
|
||||||
|
echo $default
|
||||||
|
return
|
||||||
|
end
|
||||||
|
set -l found
|
||||||
|
for line in (cat $file)
|
||||||
|
set line (string trim -- $line)
|
||||||
|
if test -z "$line"; or string match -q '#*' -- $line
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
if string match -q -- "$key=*" $line
|
||||||
|
set found (string split -m 1 -- '=' $line)[2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if test -n "$found"
|
||||||
|
echo (string trim -- $found)
|
||||||
|
else
|
||||||
|
echo $default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_parse_interval --argument-names raw
|
||||||
|
set -l text (string trim -- (string lower -- $raw))
|
||||||
|
if test -z "$text"
|
||||||
|
echo 86400
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if string match -qr '^[0-9]+$' -- $text
|
||||||
|
echo $text
|
||||||
|
return
|
||||||
|
end
|
||||||
|
set -l unit (string sub -s -1 $text)
|
||||||
|
set -l amount (string sub -e -1 $text)
|
||||||
|
if not string match -qr '^[0-9]+$' -- $amount
|
||||||
|
echo 86400
|
||||||
|
return
|
||||||
|
end
|
||||||
|
switch $unit
|
||||||
|
case s
|
||||||
|
echo $amount
|
||||||
|
case m
|
||||||
|
math $amount '*' 60
|
||||||
|
case h
|
||||||
|
math $amount '*' 3600
|
||||||
|
case d
|
||||||
|
math $amount '*' 86400
|
||||||
|
case '*'
|
||||||
|
echo 86400
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_env_no_auto_update
|
||||||
|
if not set -q FF_NO_AUTO_UPDATE
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
switch (string lower -- (string trim -- $FF_NO_AUTO_UPDATE))
|
||||||
|
case 1 true yes on
|
||||||
|
return 0
|
||||||
|
case '*'
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_auto_update_enabled
|
||||||
|
switch (string lower -- (_ff_config_value enabled true))
|
||||||
|
case 0 false no off
|
||||||
|
return 1
|
||||||
|
case '*'
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_record_auto_update
|
||||||
|
set -l file (_ff_state_file)
|
||||||
|
mkdir -p (dirname $file)
|
||||||
|
or return
|
||||||
|
echo "last_auto_update="(date +%s) >$file
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_last_auto_update
|
||||||
|
set -l file (_ff_state_file)
|
||||||
|
if not test -f $file
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
set -l val 0
|
||||||
|
for line in (cat $file)
|
||||||
|
if string match -q 'last_auto_update=*' -- $line
|
||||||
|
set val (string split -m 1 -- '=' $line)[2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if string match -qr '^[0-9]+$' -- (string trim -- $val)
|
||||||
|
echo (string trim -- $val)
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_interval_elapsed --argument-names interval
|
||||||
|
if test "$interval" -le 0
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
set -l last (_ff_last_auto_update)
|
||||||
|
if test "$last" -eq 0
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
set -l now (date +%s)
|
||||||
|
test (math $now - $last) -ge $interval
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_reload
|
||||||
|
for __f in ~/.dotfile/fish/functions/*.fish
|
||||||
|
if test -f $__f
|
||||||
|
functions -e (basename $__f .fish) 2>/dev/null
|
||||||
|
end
|
||||||
|
end
|
||||||
|
source ~/.config/fish/config.fish
|
||||||
|
# fish 对已加载过的 autoload 函数不会在 functions -e 后重新加载,
|
||||||
|
# 必须显式 source 一遍,否则函数(含 ff 自身)会丢失
|
||||||
|
for __f in ~/.dotfile/fish/functions/*.fish
|
||||||
|
if test -f $__f
|
||||||
|
source $__f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set -e __f
|
||||||
|
end
|
||||||
|
|
||||||
|
# 自动 git pull ~/.dotfile。失败/跳过不得打断当前 ff 子命令。
|
||||||
|
# 返回: 0 有更新, 1 失败或脏树, 2 静默跳过, 3 已是最新
|
||||||
|
function _ff_auto_update_try_pull
|
||||||
|
set -l dir ~/.dotfile
|
||||||
|
if not test -d $dir
|
||||||
|
return 2
|
||||||
|
end
|
||||||
|
if not git -C $dir rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||||
|
return 2
|
||||||
|
end
|
||||||
|
set -l porcelain (git -C $dir status --porcelain 2>/dev/null)
|
||||||
|
if test -n "$porcelain"
|
||||||
|
echo "[ff] 自动更新已跳过: 工作区不干净" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l before (git -C $dir rev-parse HEAD 2>/dev/null)
|
||||||
|
set -lx GIT_TERMINAL_PROMPT 0
|
||||||
|
set -l st 0
|
||||||
|
if command -q timeout
|
||||||
|
command timeout 20 git -C $dir pull --ff-only origin main >/dev/null 2>&1
|
||||||
|
set st $status
|
||||||
|
else
|
||||||
|
git -C $dir pull --ff-only origin main >/dev/null 2>&1
|
||||||
|
set st $status
|
||||||
|
end
|
||||||
|
if test $st -eq 124
|
||||||
|
echo "[ff] 自动更新失败,已跳过: git 超时" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
if test $st -ne 0
|
||||||
|
echo "[ff] 自动更新失败,已跳过" >&2
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
set -l after (git -C $dir rev-parse HEAD 2>/dev/null)
|
||||||
|
if test -n "$before" -a -n "$after" -a "$before" != "$after"
|
||||||
|
echo "[ff] 已自动更新 dotfile" >&2
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return 3
|
||||||
|
end
|
||||||
|
|
||||||
|
function _ff_maybe_auto_update --argument-names command no_auto_flag
|
||||||
|
if test "$no_auto_flag" = 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if _ff_env_no_auto_update
|
||||||
|
return
|
||||||
|
end
|
||||||
|
# 显式 ff update 自己拉,这里不要再拉一遍
|
||||||
|
if test "$command" = update
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not _ff_auto_update_enabled
|
||||||
|
return
|
||||||
|
end
|
||||||
|
set -l interval (_ff_parse_interval (_ff_config_value interval 24h))
|
||||||
|
if not _ff_interval_elapsed $interval
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
_ff_auto_update_try_pull
|
||||||
|
set -l st $status
|
||||||
|
if test $st -eq 2
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_ff_record_auto_update
|
||||||
|
if test $st -eq 0
|
||||||
|
_ff_reload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function ff
|
function ff
|
||||||
# 定义命令和描述(格式:命令|描述)
|
# 定义命令和描述(格式:命令|描述)
|
||||||
set -l commands \
|
set -l commands \
|
||||||
@@ -12,12 +222,28 @@ function ff
|
|||||||
"make|Run dotfile Makefile commands" \
|
"make|Run dotfile Makefile commands" \
|
||||||
"script|Run script from script directory" \
|
"script|Run script from script directory" \
|
||||||
"s|Alias for script" \
|
"s|Alias for script" \
|
||||||
"update|Update dotfile" \
|
"update|Update dotfile now (git pull + reload); also auto every 24h" \
|
||||||
"region|Toggle or set CN_REGION (mirror/proxy region)" \
|
"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)" \
|
"dirsize|Show directory sizes (du wrapper)" \
|
||||||
|
"pouch|Clone and install pouch to ~/.pouch" \
|
||||||
"help|Show this help message"
|
"help|Show this help message"
|
||||||
|
|
||||||
|
set -l no_auto 0
|
||||||
|
set -l args
|
||||||
|
set -l past_command 0
|
||||||
|
for a in $argv
|
||||||
|
if test $past_command -eq 0; and test "$a" = "--no-auto-update"
|
||||||
|
set no_auto 1
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
set past_command 1
|
||||||
|
set -a args $a
|
||||||
|
end
|
||||||
|
set argv $args
|
||||||
|
|
||||||
|
_ff_maybe_auto_update "$argv[1]" $no_auto
|
||||||
|
|
||||||
# 确保 execute-script 可用(在 fish 自动加载前手动引入一次)
|
# 确保 execute-script 可用(在 fish 自动加载前手动引入一次)
|
||||||
if not functions -q execute-script
|
if not functions -q execute-script
|
||||||
set -l execute_script_path ~/.dotfile/fish/functions/execute-script.fish
|
set -l execute_script_path ~/.dotfile/fish/functions/execute-script.fish
|
||||||
@@ -28,7 +254,8 @@ function ff
|
|||||||
|
|
||||||
switch $argv[1]
|
switch $argv[1]
|
||||||
case reload
|
case reload
|
||||||
source ~/.config/fish/config.fish
|
# autoload 会缓存函数定义,先清掉 dotfile 里的函数再重载
|
||||||
|
_ff_reload
|
||||||
echo "Fish configuration reloaded!"
|
echo "Fish configuration reloaded!"
|
||||||
case edit
|
case edit
|
||||||
vim ~/.config/fish/custom.fish
|
vim ~/.config/fish/custom.fish
|
||||||
@@ -77,6 +304,10 @@ function ff
|
|||||||
set -l parts (string split '|' $cmd)
|
set -l parts (string split '|' $cmd)
|
||||||
printf " %-10s %s\n" $parts[1] $parts[2]
|
printf " %-10s %s\n" $parts[1] $parts[2]
|
||||||
end
|
end
|
||||||
|
echo ""
|
||||||
|
echo "Auto-update: ordinary ff commands git-pull ~/.dotfile every 24h (default on)."
|
||||||
|
echo " Config: ~/.config/ff/config (enabled=true, interval=24h)"
|
||||||
|
echo " Skip: FF_NO_AUTO_UPDATE=1 or ff --no-auto-update <cmd>"
|
||||||
case update
|
case update
|
||||||
set -l dotfile_dir ~/.dotfile
|
set -l dotfile_dir ~/.dotfile
|
||||||
if not test -d $dotfile_dir
|
if not test -d $dotfile_dir
|
||||||
@@ -86,6 +317,7 @@ function ff
|
|||||||
set -l prev_dir (pwd)
|
set -l prev_dir (pwd)
|
||||||
cd $dotfile_dir
|
cd $dotfile_dir
|
||||||
if git pull origin main
|
if git pull origin main
|
||||||
|
_ff_record_auto_update
|
||||||
cd $prev_dir
|
cd $prev_dir
|
||||||
echo "Reloading fish configuration..."
|
echo "Reloading fish configuration..."
|
||||||
ff reload
|
ff reload
|
||||||
@@ -95,9 +327,8 @@ function ff
|
|||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
case ssh
|
case ssh
|
||||||
if not functions -q ff_ssh
|
# 每次都重新 source,避免 autoload 缓存旧定义
|
||||||
source ~/.dotfile/fish/functions/ff-ssh.fish
|
source ~/.dotfile/fish/functions/ff-ssh.fish
|
||||||
end
|
|
||||||
ff_ssh $argv[2..-1]
|
ff_ssh $argv[2..-1]
|
||||||
case dirsize
|
case dirsize
|
||||||
set -l script_path ~/.dotfile/script/dirsize.sh
|
set -l script_path ~/.dotfile/script/dirsize.sh
|
||||||
@@ -107,6 +338,14 @@ function ff
|
|||||||
echo "Error: dirsize script not found at $script_path"
|
echo "Error: dirsize script not found at $script_path"
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
case pouch
|
||||||
|
set -l script_path ~/.dotfile/script/pouch-install.sh
|
||||||
|
if test -f $script_path
|
||||||
|
bash $script_path $argv[2..-1]
|
||||||
|
else
|
||||||
|
echo "Error: pouch-install script not found at $script_path"
|
||||||
|
return 1
|
||||||
|
end
|
||||||
case region
|
case region
|
||||||
if not functions -q region_toggle
|
if not functions -q region_toggle
|
||||||
source ~/.dotfile/fish/functions/region.fish
|
source ~/.dotfile/fish/functions/region.fish
|
||||||
|
|||||||
Executable
+181
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env fish
|
||||||
|
# 回归:ff 日常命令按间隔自动更新 ~/.dotfile
|
||||||
|
|
||||||
|
set -g REPO_ROOT (cd (dirname (status filename))/../.. && pwd)
|
||||||
|
source $REPO_ROOT/fish/functions/ff.fish
|
||||||
|
|
||||||
|
set -e GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR
|
||||||
|
set -g _t_ok 0
|
||||||
|
set -g _t_fail 0
|
||||||
|
|
||||||
|
function t_ok --argument-names msg
|
||||||
|
set -g _t_ok (math $_t_ok + 1)
|
||||||
|
echo "ok - $msg"
|
||||||
|
end
|
||||||
|
|
||||||
|
function t_fail --argument-names msg
|
||||||
|
set -g _t_fail (math $_t_fail + 1)
|
||||||
|
echo "not ok - $msg"
|
||||||
|
end
|
||||||
|
|
||||||
|
function t_assert --argument-names cond msg
|
||||||
|
if test $cond -eq 0
|
||||||
|
t_ok $msg
|
||||||
|
else
|
||||||
|
t_fail $msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function setup_env
|
||||||
|
set -g TEST_HOME (mktemp -d)
|
||||||
|
set -gx HOME $TEST_HOME
|
||||||
|
set -e FF_NO_AUTO_UPDATE
|
||||||
|
mkdir -p $HOME/.config/fish $HOME/.config/ff $HOME/.local/share/ff
|
||||||
|
echo '# test fish config' >$HOME/.config/fish/config.fish
|
||||||
|
|
||||||
|
set -g UPSTREAM $TEST_HOME/upstream
|
||||||
|
mkdir -p $UPSTREAM/fish/functions
|
||||||
|
echo init >$UPSTREAM/README
|
||||||
|
echo '# stub' >$UPSTREAM/fish/functions/stub.fish
|
||||||
|
git init -q -b main $UPSTREAM
|
||||||
|
git -C $UPSTREAM config user.name Test
|
||||||
|
git -C $UPSTREAM config user.email test@example.com
|
||||||
|
git -C $UPSTREAM config commit.gpgsign false
|
||||||
|
git -C $UPSTREAM add .
|
||||||
|
git -C $UPSTREAM commit -q -m init
|
||||||
|
git clone -q $UPSTREAM $HOME/.dotfile
|
||||||
|
git -C $HOME/.dotfile config pull.ff only
|
||||||
|
git -C $HOME/.dotfile config user.name Test
|
||||||
|
git -C $HOME/.dotfile config user.email test@example.com
|
||||||
|
git -C $HOME/.dotfile config commit.gpgsign false
|
||||||
|
end
|
||||||
|
|
||||||
|
function cleanup_env
|
||||||
|
if set -q TEST_HOME
|
||||||
|
rm -rf $TEST_HOME
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function add_upstream_marker --argument-names name
|
||||||
|
echo $name >$UPSTREAM/$name
|
||||||
|
git -C $UPSTREAM add $name
|
||||||
|
git -C $UPSTREAM commit -q -m $name
|
||||||
|
end
|
||||||
|
|
||||||
|
function write_state --argument-names ts
|
||||||
|
echo "last_auto_update=$ts" >$HOME/.local/share/ff/state
|
||||||
|
end
|
||||||
|
|
||||||
|
function capture_ff
|
||||||
|
set -g FF_OUT $TEST_HOME/out.txt
|
||||||
|
set -g FF_ERR $TEST_HOME/err.txt
|
||||||
|
ff $argv >$FF_OUT 2>$FF_ERR
|
||||||
|
set -g FF_STATUS $status
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- tests ---
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_FIRST
|
||||||
|
capture_ff help
|
||||||
|
string match -q '*Usage: ff <command>*' <$FF_OUT
|
||||||
|
t_assert $status "interval elapsed: ff help still prints usage"
|
||||||
|
test -f $HOME/.dotfile/MARKER_FIRST
|
||||||
|
t_assert $status "interval elapsed: first command pulls new commit"
|
||||||
|
test -f $HOME/.local/share/ff/state
|
||||||
|
t_assert $status "interval elapsed: records last_auto_update"
|
||||||
|
string match -q '*Auto-update:*' <$FF_OUT
|
||||||
|
t_assert $status "ff help documents auto-update"
|
||||||
|
string match -q '*已自动更新*' <$FF_ERR
|
||||||
|
t_assert $status "interval elapsed: stderr says updated"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_RECENT
|
||||||
|
write_state (date +%s)
|
||||||
|
capture_ff help
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_RECENT
|
||||||
|
t_assert $status "interval not elapsed: does not pull"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
echo "enabled=false" >$HOME/.config/ff/config
|
||||||
|
add_upstream_marker MARKER_DISABLED
|
||||||
|
capture_ff help
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_DISABLED
|
||||||
|
t_assert $status "enabled=false: does not pull"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_DIRTY
|
||||||
|
echo local >$HOME/.dotfile/dirty.txt
|
||||||
|
capture_ff help
|
||||||
|
string match -q '*Usage: ff <command>*' <$FF_OUT
|
||||||
|
t_assert $status "dirty tree: current command still runs"
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_DIRTY
|
||||||
|
t_assert $status "dirty tree: skips pull"
|
||||||
|
string match -q '*工作区不干净*' <$FF_ERR
|
||||||
|
t_assert $status "dirty tree: stderr skip message"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
write_state (date +%s)
|
||||||
|
add_upstream_marker MARKER_EXPLICIT
|
||||||
|
capture_ff update
|
||||||
|
test -f $HOME/.dotfile/MARKER_EXPLICIT
|
||||||
|
t_assert $status "explicit ff update still pulls when interval not elapsed"
|
||||||
|
set -l after_ts (string split -m 1 -- '=' (cat $HOME/.local/share/ff/state))[2]
|
||||||
|
test -n "$after_ts" -a "$after_ts" -gt 0
|
||||||
|
t_assert $status "explicit ff update refreshes timestamp"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_ENV
|
||||||
|
set -gx FF_NO_AUTO_UPDATE 1
|
||||||
|
capture_ff help
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_ENV
|
||||||
|
t_assert $status "FF_NO_AUTO_UPDATE=1: does not pull"
|
||||||
|
set -e FF_NO_AUTO_UPDATE
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_FLAG
|
||||||
|
capture_ff --no-auto-update help
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_FLAG
|
||||||
|
t_assert $status "--no-auto-update: does not pull"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_TRAILING
|
||||||
|
capture_ff help --no-auto-update
|
||||||
|
test -f $HOME/.dotfile/MARKER_TRAILING
|
||||||
|
t_assert $status "trailing --no-auto-update after command is not a global skip"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
add_upstream_marker MARKER_FAIL
|
||||||
|
git -C $HOME/.dotfile remote set-url origin /nonexistent/ff-auto-update.git
|
||||||
|
capture_ff help
|
||||||
|
string match -q '*Usage: ff <command>*' <$FF_OUT
|
||||||
|
t_assert $status "pull failure: current command still runs"
|
||||||
|
test ! -f $HOME/.dotfile/MARKER_FAIL
|
||||||
|
t_assert $status "pull failure: does not apply update"
|
||||||
|
string match -q '*自动更新失败*' <$FF_ERR
|
||||||
|
t_assert $status "pull failure: stderr skip message"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
setup_env
|
||||||
|
echo "interval=24h" >$HOME/.config/ff/config
|
||||||
|
write_state (math (date +%s) - 86401)
|
||||||
|
add_upstream_marker MARKER_INTERVAL
|
||||||
|
capture_ff help
|
||||||
|
test -f $HOME/.dotfile/MARKER_INTERVAL
|
||||||
|
t_assert $status "interval 24h elapsed: pulls"
|
||||||
|
cleanup_env
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "$_t_ok passed, $_t_fail failed"
|
||||||
|
if test $_t_fail -gt 0
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Isolated ff ssh regressions: dummy keys and mock commands, no network access."""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import selectors
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REAL_SSH = shutil.which("ssh")
|
||||||
|
SOURCE = Path(os.environ.get("FF_SSH_TEST_SOURCE", ROOT / "fish/functions/ff-ssh.fish"))
|
||||||
|
MOCK = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, pathlib, signal, sys, time
|
||||||
|
home = pathlib.Path(os.environ["HOME"])
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
with (home / "calls.jsonl").open("a") as f:
|
||||||
|
f.write(json.dumps([name, sys.argv[1:]]) + "\n")
|
||||||
|
if name == "ssh":
|
||||||
|
if "-G" in sys.argv:
|
||||||
|
print((home / "resolved").read_text(), end="")
|
||||||
|
sys.exit(int(os.environ.get("RESOLVE_STATUS", "0")))
|
||||||
|
sys.exit(int(os.environ.get("VERIFY_STATUS", "0")))
|
||||||
|
if name == "ssh-keygen":
|
||||||
|
if os.environ.get("KEYGEN_FAIL"):
|
||||||
|
sys.exit(1)
|
||||||
|
key = pathlib.Path(sys.argv[sys.argv.index("-f") + 1])
|
||||||
|
key.write_text("dummy private\n")
|
||||||
|
key.with_name(key.name + ".pub").write_text("dummy public\n")
|
||||||
|
sys.exit(0)
|
||||||
|
mode = os.environ.get("COPY_MODE", "success")
|
||||||
|
if mode == "cancel-zero":
|
||||||
|
def cancel(*_):
|
||||||
|
print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr)
|
||||||
|
print("WARNING: All keys were skipped because they already exist", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGINT, cancel)
|
||||||
|
(home / "ready").touch()
|
||||||
|
time.sleep(15)
|
||||||
|
if mode == "false-zero":
|
||||||
|
print("ERROR: Assertion failure: in filter_ids(): scratch_dir was not correctly set up", file=sys.stderr)
|
||||||
|
print("WARNING: All keys were skipped because they already exist", file=sys.stderr)
|
||||||
|
if mode in ("failure", "timeout"):
|
||||||
|
print("Connection timed out" if mode == "timeout" else "Permission denied", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class SshTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(prefix="ff-ssh-test-")
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.home = Path(self.temp.name)
|
||||||
|
self.ssh = self.home / ".ssh"
|
||||||
|
self.ssh.mkdir()
|
||||||
|
bindir = self.home / "bin"
|
||||||
|
bindir.mkdir()
|
||||||
|
for name in ("ssh", "ssh-copy-id", "ssh-keygen"):
|
||||||
|
path = bindir / name
|
||||||
|
path.write_text(MOCK)
|
||||||
|
path.chmod(0o755)
|
||||||
|
self.env = dict(os.environ, HOME=str(self.home),
|
||||||
|
XDG_CONFIG_HOME=str(self.home / ".config"),
|
||||||
|
PATH=f"{bindir}:{os.environ['PATH']}", USER="test")
|
||||||
|
self.resolve("~/.ssh/id_rsa", "~/.ssh/id_ed25519")
|
||||||
|
|
||||||
|
def resolve(self, *identities):
|
||||||
|
(self.home / "resolved").write_text(
|
||||||
|
"hostname machine.invalid\nuser testuser\nport 2222\nproxyjump none\nproxycommand none\n" +
|
||||||
|
"".join(f"identityfile {key}\n" for key in identities))
|
||||||
|
|
||||||
|
def key(self, name):
|
||||||
|
private = self.ssh / name
|
||||||
|
private.write_text("dummy private\n")
|
||||||
|
private.with_name(name + ".pub").write_text("dummy public\n")
|
||||||
|
return private
|
||||||
|
|
||||||
|
def launch(self, args):
|
||||||
|
return subprocess.Popen(
|
||||||
|
["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]',
|
||||||
|
str(SOURCE), *args], env=self.env, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||||
|
|
||||||
|
def run_ff(self, *args):
|
||||||
|
proc = self.launch(args)
|
||||||
|
try:
|
||||||
|
out, err = proc.communicate(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
raise
|
||||||
|
return proc.returncode, out + err
|
||||||
|
|
||||||
|
def calls(self, name):
|
||||||
|
path = self.home / "calls.jsonl"
|
||||||
|
return [args for cmd, args in map(json.loads, path.read_text().splitlines()) if cmd == name] if path.exists() else []
|
||||||
|
|
||||||
|
def assert_selected(self, private):
|
||||||
|
copy = self.calls("ssh-copy-id")
|
||||||
|
self.assertEqual(len(copy), 1)
|
||||||
|
chosen = copy[0][copy[0].index("-i") + 1]
|
||||||
|
self.assertEqual(str(private), chosen.removesuffix(".pub"))
|
||||||
|
config = (self.ssh / "config.d/machine.invalid.conf").read_text()
|
||||||
|
identities = [line.strip().split(maxsplit=1)[1].strip('"') for line in config.splitlines() if line.strip().startswith("IdentityFile ")]
|
||||||
|
self.assertEqual(identities, [str(private).replace(str(self.home), "~", 1)])
|
||||||
|
|
||||||
|
def assert_no_host(self):
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
|
||||||
|
def test_default_matches_resolved_order_with_multiple_keys(self):
|
||||||
|
selected = self.key("id_rsa")
|
||||||
|
self.key("id_ed25519")
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_nonstandard_default_key(self):
|
||||||
|
selected = self.key("id_hx_new")
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_explicit_private_and_public_paths(self):
|
||||||
|
for suffix in ("", ".pub"):
|
||||||
|
with self.subTest(suffix=suffix):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected) + suffix, "~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-i", str(selected) + suffix, "testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_identity_option_case_and_forms(self):
|
||||||
|
for option in ("IdentityFile=", "identityfile "):
|
||||||
|
with self.subTest(option=option):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected), "~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-o", option + str(selected), "testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_existing_config_identity(self):
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.resolve(str(selected))
|
||||||
|
status, output = self.run_ff("testuser@machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
|
||||||
|
def test_invalid_explicit_identity_never_falls_back(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
# OpenSSH omits an unreadable -i from ssh -G, leaving defaults.
|
||||||
|
self.resolve("~/.ssh/id_rsa")
|
||||||
|
status, output = self.run_ff("-i", "/missing/key", "testuser@machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_unavailable_config_identity_never_falls_back(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.resolve("~/.ssh/custom_missing")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_unpaired_default_key_is_not_overwritten(self):
|
||||||
|
path = self.ssh / "id_ed25519"
|
||||||
|
path.write_text("existing private")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertEqual(path.read_text(), "existing private")
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_generation_then_copy_same_key(self):
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_selected(self.ssh / "id_ed25519")
|
||||||
|
|
||||||
|
def test_generation_failure_aborts(self):
|
||||||
|
self.env["KEYGEN_FAIL"] = "1"
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
def test_copy_failure_and_timeout_abort(self):
|
||||||
|
for mode in ("failure", "timeout"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["COPY_MODE"] = mode
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_false_success_requires_selected_key_verification(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env.update(COPY_MODE="false-zero", VERIFY_STATUS="255")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
|
||||||
|
def test_ctrl_c_with_upstream_exit_zero_aborts(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["COPY_MODE"] = "cancel-zero"
|
||||||
|
proc = self.launch(["machine.invalid"])
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while not (self.home / "ready").exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(.02)
|
||||||
|
self.assertTrue((self.home / "ready").exists(), "mock copy must reach cancellable stage")
|
||||||
|
os.killpg(proc.pid, signal.SIGINT)
|
||||||
|
out, err = proc.communicate(timeout=5)
|
||||||
|
self.assertNotEqual(proc.returncode, 0, out + err)
|
||||||
|
self.assert_no_host()
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
|
def test_no_copy_never_generates_or_connects(self):
|
||||||
|
status, output = self.run_ff("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertTrue((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
self.assertTrue(all("-G" in call for call in self.calls("ssh")))
|
||||||
|
|
||||||
|
def test_no_copy_public_identity_records_private(self):
|
||||||
|
private = self.key("custom")
|
||||||
|
self.resolve(str(private) + ".pub")
|
||||||
|
status, output = self.run_ff("--no-copy", "-i", str(private) + ".pub", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
config = (self.ssh / "config.d/machine.invalid.conf").read_text()
|
||||||
|
self.assertIn("~/.ssh/custom", config)
|
||||||
|
self.assertNotIn("custom.pub", config)
|
||||||
|
|
||||||
|
def existing(self, location="config.d/machine.invalid.conf", shared=False):
|
||||||
|
path = self.ssh / location
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = ("# original settings\nHost " + ("machine.invalid neighbor" if shared else "machine.invalid") +
|
||||||
|
"\n HostName old.invalid\n User olduser\n Port 22\n"
|
||||||
|
" IdentityFile ~/.ssh/old\n ForwardAgent no\n"
|
||||||
|
"Host untouched\n HostName untouched.invalid\n")
|
||||||
|
path.write_text(content)
|
||||||
|
if location != "config":
|
||||||
|
(self.ssh / "config").write_text(f"Include {self.ssh}/config.d/*\n")
|
||||||
|
return path, content
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return {str(p.relative_to(self.ssh)): p.read_bytes()
|
||||||
|
for p in self.ssh.rglob("*") if p.is_file()}
|
||||||
|
|
||||||
|
def interactive(self, *args, answer="y", cancel_at=None):
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["fish", "--no-config", "-c", 'source "$argv[1]"; ff_ssh $argv[2..-1]',
|
||||||
|
str(SOURCE), *args], env=self.env, stdin=slave,
|
||||||
|
stdout=slave, stderr=slave, start_new_session=True,
|
||||||
|
preexec_fn=lambda: fcntl.ioctl(slave, termios.TIOCSCTTY, 0))
|
||||||
|
os.close(slave)
|
||||||
|
output = b""
|
||||||
|
selector = selectors.DefaultSelector()
|
||||||
|
selector.register(master, selectors.EVENT_READ)
|
||||||
|
|
||||||
|
def until(prompt):
|
||||||
|
nonlocal output
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while prompt.encode() not in output and time.monotonic() < deadline:
|
||||||
|
for key, _ in selector.select(.1):
|
||||||
|
chunk = os.read(key.fileobj, 65536)
|
||||||
|
if not chunk:
|
||||||
|
self.fail("process ended before prompt: " + output.decode(errors="replace"))
|
||||||
|
output += chunk
|
||||||
|
self.assertIn(prompt, output.decode(errors="replace"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
until("SSH 别名")
|
||||||
|
time.sleep(.1) # let fish finish entering terminal input mode
|
||||||
|
if cancel_at == "alias":
|
||||||
|
os.write(master, b"\x04")
|
||||||
|
else:
|
||||||
|
os.write(master, b"\r")
|
||||||
|
until("[y/N]")
|
||||||
|
time.sleep(.1)
|
||||||
|
if cancel_at == "interrupt":
|
||||||
|
os.killpg(proc.pid, signal.SIGINT)
|
||||||
|
elif cancel_at == "eof":
|
||||||
|
os.write(master, b"\x04")
|
||||||
|
else:
|
||||||
|
os.write(master, (answer + "\r").encode())
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
ready = selector.select(.1)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
chunk = os.read(master, 65536)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output += chunk
|
||||||
|
elif proc.poll() is not None:
|
||||||
|
break
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
return proc.returncode, output.decode(errors="replace")
|
||||||
|
finally:
|
||||||
|
selector.close()
|
||||||
|
os.close(master)
|
||||||
|
if proc.poll() is None:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
|
def assert_unchanged(self, before):
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
|
||||||
|
def assert_backup(self, content):
|
||||||
|
backups = list((self.ssh / "config.backups").glob("*.backup.*"))
|
||||||
|
self.assertEqual(len(backups), 1)
|
||||||
|
self.assertEqual(backups[0].read_text(), content)
|
||||||
|
self.assertEqual((self.ssh / "config.backups").stat().st_mode & 0o777, 0o700)
|
||||||
|
self.assertFalse(list((self.ssh / "config.d").glob("*.backup.*")))
|
||||||
|
|
||||||
|
def effective(self, host):
|
||||||
|
# -G is local only; absolute Include plus -F avoid the real SSH config.
|
||||||
|
result = subprocess.run([REAL_SSH, "-G", "-F", str(self.ssh / "config"), host],
|
||||||
|
env=self.env, capture_output=True, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
return dict(line.split(" ", 1) for line in result.stdout.splitlines())
|
||||||
|
|
||||||
|
def test_existing_alias_is_actionable_failure_and_preserved(self):
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("非交互", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
self.assertFalse(self.calls("ssh"))
|
||||||
|
|
||||||
|
def test_overwrite_accepts_only_explicit_yes(self):
|
||||||
|
for answer in ("n", "", "sure", "yes please"):
|
||||||
|
with self.subTest(answer=answer):
|
||||||
|
self.setUp()
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.interactive("machine.invalid", answer=answer)
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("未确认覆盖", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
self.assertFalse(self.calls("ssh"))
|
||||||
|
|
||||||
|
def test_overwrite_eof_and_ctrl_c_preserve_everything(self):
|
||||||
|
for cancel in ("eof", "interrupt", "alias"):
|
||||||
|
with self.subTest(cancel=cancel):
|
||||||
|
self.setUp()
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.interactive("machine.invalid", cancel_at=cancel)
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("取消", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_overwrite_yes_copies_then_backs_up_and_updates(self):
|
||||||
|
for answer in ("y", "YES"):
|
||||||
|
with self.subTest(answer=answer):
|
||||||
|
self.setUp()
|
||||||
|
selected = self.key("custom")
|
||||||
|
self.resolve(str(selected))
|
||||||
|
path, original = self.existing()
|
||||||
|
status, output = self.interactive("newuser@machine.invalid", "-p", "2200", answer=answer)
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assertIn(str(path), output)
|
||||||
|
self.assert_selected(selected)
|
||||||
|
self.assert_backup(original)
|
||||||
|
updated = path.read_text()
|
||||||
|
for value in ("HostName machine.invalid", "User newuser", "Port 2200", "ForwardAgent no",
|
||||||
|
"Host untouched\n HostName untouched.invalid"):
|
||||||
|
self.assertIn(value, updated)
|
||||||
|
self.assertNotIn("old.invalid", updated)
|
||||||
|
self.assertNotIn("~/.ssh/old", updated)
|
||||||
|
|
||||||
|
def test_overwrite_no_copy_in_shared_nonmatching_filename(self):
|
||||||
|
path, original = self.existing("config.d/20-shared.conf", shared=True)
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
self.assertFalse(self.calls("ssh-keygen"))
|
||||||
|
self.assertTrue(all("-G" in call for call in self.calls("ssh")))
|
||||||
|
self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid")
|
||||||
|
self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid")
|
||||||
|
self.assertEqual(self.effective("neighbor")["identityfile"], "~/.ssh/old")
|
||||||
|
self.assertEqual(self.effective("untouched")["hostname"], "untouched.invalid")
|
||||||
|
self.assertIn("Host neighbor !machine.invalid", path.read_text())
|
||||||
|
|
||||||
|
def test_overwrite_shared_wildcard_excludes_target(self):
|
||||||
|
path, original = self.existing("config.d/20-shared.conf", shared=True)
|
||||||
|
original = original.replace("machine.invalid neighbor", "machine.invalid neighbor *")
|
||||||
|
path.write_text(original)
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertEqual(self.effective("machine.invalid")["hostname"], "machine.invalid")
|
||||||
|
self.assertNotIn("~/.ssh/old", self.effective("machine.invalid")["identityfile"])
|
||||||
|
self.assertEqual(self.effective("neighbor")["hostname"], "old.invalid")
|
||||||
|
|
||||||
|
def test_overwrite_main_config_preserves_neighbors(self):
|
||||||
|
path, original = self.existing("config")
|
||||||
|
status, output = self.interactive("--no-copy", "machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
self.assert_backup(original)
|
||||||
|
self.assertIn("HostName machine.invalid", path.read_text())
|
||||||
|
self.assertIn("Host untouched\n HostName untouched.invalid", path.read_text())
|
||||||
|
self.assertFalse((self.ssh / "config.d/machine.invalid.conf").exists())
|
||||||
|
|
||||||
|
def test_overwrite_copy_or_verification_failure_preserves_original(self):
|
||||||
|
for mode in ("failure", "false-zero"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.setUp()
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.existing(location="config")
|
||||||
|
before = self.snapshot()
|
||||||
|
self.env.update(COPY_MODE=mode, VERIFY_STATUS="255")
|
||||||
|
status, output = self.interactive("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
self.assertEqual(len(self.calls("ssh-copy-id")), 1)
|
||||||
|
|
||||||
|
def test_ambiguous_declarations_are_not_partially_updated(self):
|
||||||
|
self.existing()
|
||||||
|
(self.ssh / "config.d/20-second.conf").write_text("Host machine.invalid\n HostName second.invalid\n")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("多个 Host", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_filename_collision_does_not_replace_other_alias(self):
|
||||||
|
path = self.ssh / "config.d/machine.invalid.conf"
|
||||||
|
path.parent.mkdir()
|
||||||
|
path.write_text("Host other\n HostName other.invalid\n")
|
||||||
|
before = self.snapshot()
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assertIn("目标文件已存在", output)
|
||||||
|
self.assert_unchanged(before)
|
||||||
|
|
||||||
|
def test_connect_timeout_and_verification_identity_isolation(self):
|
||||||
|
selected = self.key("id_rsa")
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertEqual(status, 0, output)
|
||||||
|
copy = self.calls("ssh-copy-id")[0]
|
||||||
|
self.assertIn("ConnectTimeout=10", copy)
|
||||||
|
self.assertIn("ConnectionAttempts=1", copy)
|
||||||
|
verify = [call for call in self.calls("ssh") if "-G" not in call]
|
||||||
|
self.assertEqual(len(verify), 1)
|
||||||
|
for option in ("ConnectTimeout=10", "ConnectionAttempts=1", "BatchMode=yes", "IdentitiesOnly=yes", "CertificateFile=none", "ControlPath=none", "PreferredAuthentications=publickey"):
|
||||||
|
self.assertIn(option, verify[0])
|
||||||
|
self.assertEqual(verify[0][verify[0].index("-F") + 1], "/dev/null")
|
||||||
|
self.assertEqual(verify[0][verify[0].index("-i") + 1], str(selected))
|
||||||
|
self.assertNotIn("StrictHostKeyChecking=no", verify[0])
|
||||||
|
|
||||||
|
def test_resolve_failure_aborts(self):
|
||||||
|
self.key("id_rsa")
|
||||||
|
self.env["RESOLVE_STATUS"] = "255"
|
||||||
|
status, output = self.run_ff("machine.invalid")
|
||||||
|
self.assertNotEqual(status, 0, output)
|
||||||
|
self.assert_no_host()
|
||||||
|
self.assertFalse(self.calls("ssh-copy-id"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
+16
-4
@@ -1,6 +1,7 @@
|
|||||||
|
# 不设全局 user.name / user.email,避免未匹配目录误用身份。
|
||||||
|
# useConfigOnly(Git 2.8+):未配置时拒绝 commit,而不是猜系统用户名。
|
||||||
[user]
|
[user]
|
||||||
name = laily
|
useConfigOnly = true
|
||||||
email = i@laily.net
|
|
||||||
[core]
|
[core]
|
||||||
autocrlf = input
|
autocrlf = input
|
||||||
quotepath = false
|
quotepath = false
|
||||||
@@ -42,8 +43,18 @@
|
|||||||
[mergetool "nvimdiff"]
|
[mergetool "nvimdiff"]
|
||||||
cmd = nvim -d \"$LOCAL\" \"$REMOTE\" -ancestor \"$BASE\" -merge \"$MERGED\"
|
cmd = nvim -d \"$LOCAL\" \"$REMOTE\" -ancestor \"$BASE\" -merge \"$MERGED\"
|
||||||
trustExitCode = true
|
trustExitCode = true
|
||||||
|
# 按仓库目录套身份。个人身份在 git/.gitconfig.personal;公司邮箱只放本机 ~/.gitconfig.work。
|
||||||
|
# include 目标文件必须存在,否则该目录下 git 命令会直接 fatal。
|
||||||
|
[includeIf "gitdir:~/.dotfile/"]
|
||||||
|
path = ~/.dotfile/git/.gitconfig.personal
|
||||||
|
[includeIf "gitdir:~/code/"]
|
||||||
|
path = ~/.dotfile/git/.gitconfig.personal
|
||||||
|
[includeIf "gitdir:~/.pouch/"]
|
||||||
|
path = ~/.dotfile/git/.gitconfig.personal
|
||||||
[includeIf "gitdir:~/workrepo/"]
|
[includeIf "gitdir:~/workrepo/"]
|
||||||
path = ~/.workgitconfig
|
path = ~/.gitconfig.work
|
||||||
|
[includeIf "gitdir:~/repo/"]
|
||||||
|
path = ~/.gitconfig.work
|
||||||
[rebase]
|
[rebase]
|
||||||
autoSquash = true
|
autoSquash = true
|
||||||
autoStash = true
|
autoStash = true
|
||||||
@@ -73,7 +84,8 @@
|
|||||||
pt = push origin --tags
|
pt = push origin --tags
|
||||||
ph = push origin HEAD
|
ph = push origin HEAD
|
||||||
pht = push origin --tags
|
pht = push origin --tags
|
||||||
merge2 = !sh -c '_CURRENT_BRANCH=$(git symbolic-ref --short HEAD) && git checkout $1 && git merge $_CURRENT_BRANCH && git checkout $_CURRENT_BRANCH' -
|
# 把当前分支合进目标分支后回到当前分支;目标已被其它 worktree 占用时在那边 merge
|
||||||
|
merge2 = !$HOME/.dotfile/git/git-merge2.sh
|
||||||
bg = "!f() { git branch | grep $1; }; f"
|
bg = "!f() { git branch | grep $1; }; f"
|
||||||
delbr = "!f(){ git branch -D $(git branch | grep \"\b$1\"); };f"
|
delbr = "!f(){ git branch -D $(git branch | grep \"\b$1\"); };f"
|
||||||
can = commit --amend --no-edit
|
can = commit --amend --no-edit
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 个人身份:~/.dotfile、~/code、~/.pouch(由 includeIf gitdir 加载)
|
||||||
|
[user]
|
||||||
|
name = laily
|
||||||
|
email = i@laily.net
|
||||||
+26
-9
@@ -21,23 +21,40 @@ warn_store_fallback() {
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# helper 在 `git --exec-path`,不一定在 PATH。必须 exec 绝对路径:
|
||||||
|
# Apple Git 的 osxkeychain 会对 argv[0] 做 O_EXLOCK,相对名会去锁 cwd 下的同名文件。
|
||||||
|
dotfile_git_credential_exec() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local helper=""
|
||||||
|
local exec_path
|
||||||
|
exec_path="$(git --exec-path 2>/dev/null || true)"
|
||||||
|
if [ -n "$exec_path" ] && [ -x "$exec_path/git-credential-$name" ]; then
|
||||||
|
helper="$exec_path/git-credential-$name"
|
||||||
|
elif command -v "git-credential-$name" >/dev/null 2>&1; then
|
||||||
|
helper="$(command -v "git-credential-$name")"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
exec "$helper" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
Darwin)
|
Darwin)
|
||||||
if command -v git-credential-osxkeychain >/dev/null 2>&1; then
|
dotfile_git_credential_exec osxkeychain "$@" || true
|
||||||
exec git-credential-osxkeychain "$@"
|
|
||||||
fi
|
|
||||||
warn_store_fallback "未找到 osxkeychain"
|
warn_store_fallback "未找到 osxkeychain"
|
||||||
exec git-credential-store "$@"
|
dotfile_git_credential_exec store "$@" || true
|
||||||
;;
|
;;
|
||||||
Linux)
|
Linux)
|
||||||
if command -v git-credential-libsecret >/dev/null 2>&1; then
|
dotfile_git_credential_exec libsecret "$@" || true
|
||||||
exec git-credential-libsecret "$@"
|
|
||||||
fi
|
|
||||||
warn_store_fallback "未找到 git-credential-libsecret"
|
warn_store_fallback "未找到 git-credential-libsecret"
|
||||||
exec git-credential-store "$@"
|
dotfile_git_credential_exec store "$@" || true
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
warn_store_fallback "未知系统 $(uname -s)"
|
warn_store_fallback "未知系统 $(uname -s)"
|
||||||
exec git-credential-store "$@"
|
dotfile_git_credential_exec store "$@" || true
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
echo "git credential helper 不可用" >&2
|
||||||
|
exit 1
|
||||||
|
|||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 把当前分支 merge 进目标分支;目标已在其它 worktree 时直接在那边 merge
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法: git merge2 <branch> [git merge 参数...]
|
||||||
|
|
||||||
|
把当前分支合进 <branch>,然后留在当前分支。
|
||||||
|
|
||||||
|
若 <branch> 已在其它 worktree 里 checkout,则在那个 worktree 里 merge,
|
||||||
|
不会在当前目录 checkout(避免 "already used by worktree")。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
current="$(git symbolic-ref --short HEAD)"
|
||||||
|
target="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ "$current" == "$target" ]]; then
|
||||||
|
echo "已经在 $target 上" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# porcelain 含主 worktree;%(worktreepath) 只填 linked worktree,不能用
|
||||||
|
worktree_for_branch() {
|
||||||
|
local branch="$1"
|
||||||
|
local wt=""
|
||||||
|
local line
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
case "$line" in
|
||||||
|
worktree\ *)
|
||||||
|
wt="${line#worktree }"
|
||||||
|
;;
|
||||||
|
"branch refs/heads/${branch}")
|
||||||
|
printf '%s\n' "$wt"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
wt=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(git worktree list --porcelain)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# alias / 子进程会继承当前 worktree 的 GIT_DIR,必须清掉才能 git -C 到另一个 worktree
|
||||||
|
merge_in_worktree() {
|
||||||
|
local wt="$1"
|
||||||
|
shift
|
||||||
|
env -u GIT_DIR -u GIT_WORK_TREE git -C "$wt" merge "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
wt="$(worktree_for_branch "$target" || true)"
|
||||||
|
if [[ -n "$wt" ]]; then
|
||||||
|
echo "在 worktree 中 merge $current -> $target: $wt"
|
||||||
|
merge_in_worktree "$wt" "$current" "$@"
|
||||||
|
else
|
||||||
|
git checkout "$target"
|
||||||
|
git merge "$current" "$@"
|
||||||
|
git checkout "$current"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# herdr
|
||||||
|
|
||||||
|
个人 herdr 配置与迁移笔记。配置由 `tmux/.tmux.conf` 映射而来。
|
||||||
|
|
||||||
|
- `config.toml` — 真实配置,经 `script/herdr-init.sh` 软链到 `~/.config/herdr/config.toml`
|
||||||
|
- 校验:`herdr config check`
|
||||||
|
- 热重载:`prefix+shift+r`(本配置改为 `prefix+r`)或 `herdr server reload-config`
|
||||||
|
- 默认值参考:`herdr --default-config`
|
||||||
|
- 官方速查:https://herdr.dev/docs/concepts/ ,给 agent 看的:`herdr --skill`
|
||||||
|
|
||||||
|
## 概念对照
|
||||||
|
|
||||||
|
| tmux | herdr | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| session | workspace | herdr 的 workspace 对应“一个项目/任务”,侧栏按 workspace 汇总 agent 状态 |
|
||||||
|
| (无对应) | session | herdr 的 named session 是**独立的 server 命名空间**(`herdr session attach work`),跟 tmux 的 session 不是一回事 |
|
||||||
|
| window | tab | |
|
||||||
|
| pane | pane | 只能向右(right)或向下(down)切 |
|
||||||
|
| prefix mode | prefix mode (`ctrl+b`) | 相同;另有 terminal mode / navigate mode |
|
||||||
|
| status bar + 脚本 | sidebar | 鼠标可点、可拖分隔条、右键菜单 |
|
||||||
|
| (无对应) | agent | herdr 识别 pane 里的 coding agent,状态 `working`/`blocked`/`done`/`idle`/`unknown` |
|
||||||
|
| tmux-resurrect | session.json + `[experimental] pane_history` | 布局自动持久化,agent 原生会话恢复需要装 integration |
|
||||||
|
| tpm 插件 | 无插件系统 | fzf 类能力内建(`prefix+w` workspace picker、`prefix+g` goto);自定义动作用 `[[keys.command]]` |
|
||||||
|
| tmux over ssh | `herdr --remote <ssh-target>` | 远端跑 server,本地 attach |
|
||||||
|
|
||||||
|
## 和 tmux 的主要差异
|
||||||
|
|
||||||
|
1. **鼠标优先**。所有交互都能用鼠标完成;键位是可选层。`prefix+?` 可实时列出当前所有有效绑定。
|
||||||
|
2. **agent 感知**。侧栏能看到每个 pane 里的 agent 状态,`blocked`(等你审批/回答)会被顶到前面。tmux 只能靠 pane 标题自己写。
|
||||||
|
3. **CLI + 本地 socket API**。`herdr pane split/run/read/wait-output`、`herdr agent prompt/wait` 这类命令可以从脚本或 agent 内部驱动布局和读输出,tmux 的 `send-keys` 做不到等待与状态判定。
|
||||||
|
4. **配置是单个 TOML**,没有插件系统,也没有 `run-shell` 状态栏脚本;但支持
|
||||||
|
`[[keys.command]]` 自定义命令(`type = "shell"` / `"pane"` / `"popup"`,可指定宽高百分比),基本能覆盖原来用插件/脚本做的事。
|
||||||
|
5. **直接快捷键**。除 `prefix+...` 外还支持 `ctrl+alt+...` 这类直通键位(适合想摆脱 prefix 的用法)。反过来,被 OS 或外层终端占用的组合 herdr 收不到,表现为“绑了没反应”。
|
||||||
|
6. **没有 `escape-time` / `terminal-overrides` / 真彩色开关**。那些是 tmux 作为终端适配层才需要的,herdr 自己处理能力协商。通知走 `[ui.toast] delivery`,剪贴板走 `[ui] copy_on_select`。
|
||||||
|
7. **不支持嵌套启动**(`experimental.allow_nested = false`):herdr pane 里再跑 `herdr` 会被拒绝。tmux 里 `tmux` 是能套的。
|
||||||
|
8. `[remote] manage_ssh_config` 默认开启:`herdr --remote` 会生成一份私有 ssh config,**先 include 你的 `~/.ssh/config`**,再补 `ServerAliveInterval`/`ServerAliveCountMax` 兜底,并用私有 control socket 复用连接。所以 `~/.ssh/config` 里已有的 keepalive 仍然优先,我们的 `Include ~/.ssh/config.d/*` 也照常生效。
|
||||||
|
|
||||||
|
## 切换时的注意事项
|
||||||
|
|
||||||
|
- **分屏按分隔线的形状记**:`|` 是竖线 → 切成左右;`-` 是横线 → 切成上下。herdr 就是按形状命名的(`split_vertical` = 竖线 = 向右切,`split_horizontal` = 横线 = 向下切),而 tmux 的 `splitw -v/-h` 按“排列方向”命名,两者相反:tmux `splitw -v`(上下堆叠)对应 herdr `split_horizontal`,tmux `splitw -h`(左右并排)对应 `split_vertical`。本配置按形状对齐(`-` 上下、`|` 左右)。
|
||||||
|
- **`herdr` 裸跑会 attach;`herdr server` 是前台 headless server**。别为了“看看帮助”敲 `herdr server`——它不返回,会把调用它的地方永久挂住(踩过一次:pi 的 bash 工具卡了 46 分钟)。要看帮助用 `herdr --help`、`herdr <group>`(如 `herdr pane`);停 server 用 `herdr server stop`。
|
||||||
|
- **prefix 都是 `ctrl+b`**,两套配置不冲突,但同时开难免按错。切过去之前建议先退掉 tmux,避免双层状态栏/双层 prefix。
|
||||||
|
- **没有 `-c '#{pane_current_path}'` 这个开关**:新建 pane/tab 的目录策略在 `[terminal] new_cwd`(`follow` / `home` / `current` / 固定路径),本配置是 `follow`。
|
||||||
|
- **resize 步长不可配**。tmux 那边是固定 10 格;herdr 直接绑定(本配置 `prefix+shift+hjkl`)用默认步长,或 `prefix+r` 进 resize mode 再动。
|
||||||
|
- **`mouse on/off` 没有运行时快捷键**:由 `[ui] mouse_capture` 决定,运行时去 `prefix+s` 设置面板改。用 `copy_on_select = false` 可以在拖选后保留选区,等 `Ctrl+C` 再复制。
|
||||||
|
- **复制模式**:`mode-keys vi` 在 herdr 没有等价配置。本配置给 `copy_mode` 绑了 `prefix+[`(herdr 0.8.2 支持该校验通过,但未写进 `--default-config`),复制模式的内部键位不可配;平时直接鼠标拖选即可。想把 scrollback 丢进 `$EDITOR` 用 `prefix+e`。
|
||||||
|
- **中文/IME**:如果你在 pane 里用 pi/claude/codex 这类自绘光标的 TUI 遇到输入法候选框错位,可试开
|
||||||
|
`[experimental] reveal_hidden_cursor_for_cjk_ime = true`(可用 `cjk_ime_agents` 限定只对某些 agent 生效)。默认关闭。
|
||||||
|
- **别再跑 tpm**:`script/tmux-init.sh` 会克隆 tpm 并装插件,那是 tmux 的;herdr 不需要。两套可以并存,但别指望插件搬到 herdr。
|
||||||
|
- **升级**:`herdr update`(stable 通道);0.8.2 → 0.9.0 已经可用。用包管理器装的则走包管理器。
|
||||||
|
|
||||||
|
## 迁移状态
|
||||||
|
|
||||||
|
- [x] `herdr/config.toml`(tmux 键位/UI 行为映射,`herdr config check` 通过)
|
||||||
|
- [x] `script/herdr-init.sh`(软链 + 备份 + 幂等 + 校验)
|
||||||
|
- [ ] 装 agent integration(`herdr integration install claude` 等),才有原生会话恢复与状态上报
|
||||||
|
- [ ] 需要时启用 `reveal_hidden_cursor_for_cjk_ime`
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# herdr 配置 —— 由 ~/.dotfile/tmux/.tmux.conf 映射而来
|
||||||
|
#
|
||||||
|
# 仓库内真实文件: ~/.dotfile/herdr/config.toml
|
||||||
|
# 由 script/herdr-init.sh 软链到: ~/.config/herdr/config.toml
|
||||||
|
#
|
||||||
|
# 校验: herdr config check
|
||||||
|
# 热重载: prefix+shift+r,或 herdr server reload-config
|
||||||
|
# 默认值: herdr --default-config
|
||||||
|
#
|
||||||
|
# 这里只写与 herdr 默认值不同(或需要显式强调)的键,未写到的沿用默认。
|
||||||
|
# 概念对照: tmux session → herdr workspace,tmux window → herdr tab,tmux pane → herdr pane
|
||||||
|
|
||||||
|
[keys]
|
||||||
|
# ---------------------------------------------------------------- 前缀
|
||||||
|
# tmux 里的 prefix 相关行都被注释掉了,所以沿用 ctrl+b(herdr 默认值,无需显式设置)
|
||||||
|
# prefix = "ctrl+b"
|
||||||
|
|
||||||
|
# tmux: bind r source-file ~/.tmux.conf \; display-message "Config reloaded"
|
||||||
|
reload_config = "prefix+r" # herdr 默认是 prefix+shift+r
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 分屏
|
||||||
|
# 按“分隔线的形状”对齐:
|
||||||
|
# | 是竖线 → 把 pane 切成左右
|
||||||
|
# - 是横线 → 把 pane 切成上下
|
||||||
|
# herdr 就是按分隔线形状命名的,而 tmux 的 -v/-h 按“排列方向”命名,所以二者相反:
|
||||||
|
# tmux splitw -v(上下堆叠)= 横线 = herdr split_horizontal(向下切)
|
||||||
|
# tmux splitw -h(左右并排)= 竖线 = herdr split_vertical (向右切)
|
||||||
|
# tmux: bind - splitw -v -c '#{pane_current_path}' → 切成上下
|
||||||
|
split_horizontal = "prefix+minus"
|
||||||
|
# tmux: bind | splitw -h -c '#{pane_current_path}' → 切成左右
|
||||||
|
split_vertical = "prefix+|"
|
||||||
|
# tmux 的 -c '#{pane_current_path}' 对应 [terminal] new_cwd = "follow"(默认值)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pane 切换
|
||||||
|
# 注释写的是“绑定 hjkl 键为面板切换”,其中 h/l 两行被注释掉了,这里按原意启用
|
||||||
|
focus_pane_left = "prefix+h"
|
||||||
|
focus_pane_down = "prefix+j"
|
||||||
|
focus_pane_up = "prefix+k"
|
||||||
|
focus_pane_right = "prefix+l"
|
||||||
|
|
||||||
|
# tmux: bind -r K/J/H/L resizep -U/-D/-L/-R 10
|
||||||
|
# herdr 可直接绑定 resize,不必先进入 resize mode;步长不可配(tmux 那边是 10 格)
|
||||||
|
resize_pane_up = "prefix+shift+k"
|
||||||
|
resize_pane_down = "prefix+shift+j"
|
||||||
|
resize_pane_left = "prefix+shift+h"
|
||||||
|
# 注: tmux 里 prefix+shift+l 被后面的 `bind L switch-client -l` 覆盖,此处留给“回上一个”
|
||||||
|
# 向右 resize 用 prefix+r 进 resize mode,或在此写回 "prefix+shift+l"
|
||||||
|
|
||||||
|
# tmux: bind L switch-client -l (注释写“返回上一个窗口”)
|
||||||
|
previous_tab = "prefix+shift+l"
|
||||||
|
# 想要真正的“来回切换”(不按顺序),可改为 last_pane = "prefix+shift+l"
|
||||||
|
|
||||||
|
# tmux: bind M command-prompt -p "插入窗口到位置:" "move-window -b -t ':%%'"
|
||||||
|
# herdr 没有“输入索引插入”这种交互,改成相邻平移
|
||||||
|
move_tab_previous = "alt+shift+left"
|
||||||
|
move_tab_next = "alt+shift+right"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 复制模式
|
||||||
|
# tmux: setw -g mode-keys vi(tmux 默认用 prefix+[ 进入 copy mode)
|
||||||
|
# herdr 0.8.2 支持 copy_mode 键(未写进 --default-config,但 herdr config check 认可)
|
||||||
|
copy_mode = "prefix+["
|
||||||
|
# tmux 里没有的: 把 scrollback 丢给 $EDITOR(herdr 默认 prefix+e)
|
||||||
|
# edit_scrollback = "prefix+e"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 故意不复刻的 tmux 绑定
|
||||||
|
# tmux: bind -r j select-pane -t .+1 \; resize-pane -Z
|
||||||
|
# 它覆盖了上面的 `bind j select-pane -D`,含义是“切到下一个 pane 且每次都 toggle zoom”,
|
||||||
|
# 语义含糊,不复刻。循环切换用 herdr 默认的 prefix+tab / prefix+shift+tab,
|
||||||
|
# zoom 用 prefix+z。
|
||||||
|
# tmux: bind m (切换鼠标模式)
|
||||||
|
# herdr 没有鼠标开关的运行时按键: 行为由 [ui] mouse_capture 决定,
|
||||||
|
# 运行时可开 prefix+s(设置)面板调整。
|
||||||
|
# herdr 自带、tmux 那边靠插件实现的能力(均为默认键):
|
||||||
|
# prefix+w workspace picker(≈ tmux-fzf)
|
||||||
|
# prefix+g goto
|
||||||
|
# prefix+? 列出所有有效绑定
|
||||||
|
|
||||||
|
[terminal]
|
||||||
|
# tmux 新建 pane/window 都带 -c '#{pane_current_path}'
|
||||||
|
new_cwd = "follow"
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
# tmux: set -g mouse on
|
||||||
|
mouse_capture = true
|
||||||
|
# tmux: set -s set-clipboard on
|
||||||
|
copy_on_select = true
|
||||||
|
# tmux: set-titles on / set-titles-string "#{session_name}"
|
||||||
|
window_title = "{workspace}"
|
||||||
|
# tmux: automatic-rename on(窗口名跟随当前目录)→ 关掉 herdr 的新 tab 命名询问
|
||||||
|
prompt_new_tab_name = false
|
||||||
|
# tmux 风格: 只要内部分隔线,不要外面那圈边框
|
||||||
|
pane_outer_borders = false
|
||||||
|
|
||||||
|
# tmux: set -as terminal-features ",*:osc9" + allow-passthrough,让外层终端弹通知
|
||||||
|
# herdr 直接把 pane 里的通知转发给外层终端(Ghostty)
|
||||||
|
[ui.toast]
|
||||||
|
delivery = "terminal"
|
||||||
|
|
||||||
|
[session]
|
||||||
|
# 近似替代 tmux-resurrect 的会话恢复
|
||||||
|
# (原生会话恢复需要对应 agent 装了 herdr integration,如 herdr integration install claude)
|
||||||
|
resume_agents_on_restore = true
|
||||||
|
|
||||||
|
[experimental]
|
||||||
|
# 近似替代 tmux-resurrect 的 scrollback 恢复: 重启 server 后保留 pane 屏幕历史
|
||||||
|
pane_history = true
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
# 跨机器共享。本机工具写在 ~/.config/mise/config.toml(mise use -g)
|
||||||
[tools]
|
[tools]
|
||||||
|
|||||||
+19
-19
@@ -1,51 +1,51 @@
|
|||||||
{
|
{
|
||||||
"FixCursorHold.nvim": { "branch": "master", "commit": "1900f89dc17c603eec29960f57c00bd9ae696495" },
|
"FixCursorHold.nvim": { "branch": "master", "commit": "1900f89dc17c603eec29960f57c00bd9ae696495" },
|
||||||
"LazyVim": { "branch": "main", "commit": "fca0af57cc3851b14f96a795a9c9bfafc5096dd1" },
|
"LazyVim": { "branch": "main", "commit": "fca0af57cc3851b14f96a795a9c9bfafc5096dd1" },
|
||||||
"SchemaStore.nvim": { "branch": "main", "commit": "4f1c58a59d3a4da095743dd6f04b73caa0cefc3d" },
|
"SchemaStore.nvim": { "branch": "main", "commit": "4a0e1b74096937d366031bc1e2b244546af39f8e" },
|
||||||
"avante.nvim": { "branch": "main", "commit": "e89eb79abf5754645e20aa6074da10ed20bba33c" },
|
"avante.nvim": { "branch": "main", "commit": "e89eb79abf5754645e20aa6074da10ed20bba33c" },
|
||||||
"barbar.nvim": { "branch": "master", "commit": "53b5a2f34b68875898f0531032fbf090e3952ad7" },
|
"barbar.nvim": { "branch": "master", "commit": "53b5a2f34b68875898f0531032fbf090e3952ad7" },
|
||||||
"blink-cmp-avante": { "branch": "master", "commit": "4f494c6e124acbe31a8f5d58effa0c14aa38a6d5" },
|
"blink-cmp-avante": { "branch": "master", "commit": "4f494c6e124acbe31a8f5d58effa0c14aa38a6d5" },
|
||||||
"blink.cmp": { "branch": "main", "commit": "451168851e8e2466bc97ee3e026c3dcb9141ce07" },
|
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
|
||||||
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
|
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
|
||||||
"catppuccin": { "branch": "main", "commit": "a7788416689dbefea659d9e9415c87926b0ba5d8" },
|
"catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" },
|
||||||
"codecompanion.nvim": { "branch": "main", "commit": "e0780fa9fda504ffb89307cabcb6cbe1ce8eb60c" },
|
"codecompanion.nvim": { "branch": "main", "commit": "e0780fa9fda504ffb89307cabcb6cbe1ce8eb60c" },
|
||||||
"conform.nvim": { "branch": "master", "commit": "086a40dc7ed8242c03be9f47fbcee68699cc2395" },
|
"conform.nvim": { "branch": "master", "commit": "086a40dc7ed8242c03be9f47fbcee68699cc2395" },
|
||||||
"dashboard-nvim": { "branch": "master", "commit": "0775e567b6c0be96d01a61795f7b64c1758262f6" },
|
"dashboard-nvim": { "branch": "master", "commit": "0775e567b6c0be96d01a61795f7b64c1758262f6" },
|
||||||
"diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
|
"diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
|
||||||
"dressing.nvim": { "branch": "master", "commit": "2d7c2db2507fa3c4956142ee607431ddb2828639" },
|
"dressing.nvim": { "branch": "master", "commit": "2d7c2db2507fa3c4956142ee607431ddb2828639" },
|
||||||
"flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
|
"flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" },
|
||||||
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
|
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
|
||||||
"fzf-lua": { "branch": "main", "commit": "c9e7b7bfbd01f949164988ee1684035468e1995c" },
|
"fzf-lua": { "branch": "main", "commit": "c9e7b7bfbd01f949164988ee1684035468e1995c" },
|
||||||
"gemini-cli.nvim": { "branch": "main", "commit": "c9fd62adda823628f5131a939d9c56ef7a898600" },
|
"gemini-cli.nvim": { "branch": "main", "commit": "c9fd62adda823628f5131a939d9c56ef7a898600" },
|
||||||
"gitsigns.nvim": { "branch": "main", "commit": "caa98e2e5e4c23a1652c23be839d2e1722cecc29" },
|
"gitsigns.nvim": { "branch": "main", "commit": "caa98e2e5e4c23a1652c23be839d2e1722cecc29" },
|
||||||
"grug-far.nvim": { "branch": "main", "commit": "dc4684e163971bb1f5bfb93fbd4bb3e892bf9b15" },
|
"grug-far.nvim": { "branch": "main", "commit": "11595bf747edc270bce2069d1020502ad4ae56cf" },
|
||||||
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
|
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
|
||||||
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
|
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
|
||||||
"lsp_signature.nvim": { "branch": "master", "commit": "af7e4074d85d785cf6614352ba9ad3b28a1f8a56" },
|
"lsp_signature.nvim": { "branch": "master", "commit": "af7e4074d85d785cf6614352ba9ad3b28a1f8a56" },
|
||||||
"lualine.nvim": { "branch": "master", "commit": "47f91c416daef12db467145e16bed5bbfe00add8" },
|
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
|
||||||
"mason-lspconfig.nvim": { "branch": "main", "commit": "25f609e7fca78af7cede4f9fa3af8a94b1c4950b" },
|
"mason-lspconfig.nvim": { "branch": "main", "commit": "40276c4df7e6bdce6801d6c035c6227f9115a855" },
|
||||||
"mason-nvim-dap.nvim": { "branch": "main", "commit": "9a10e096703966335bd5c46c8c875d5b0690dade" },
|
"mason-nvim-dap.nvim": { "branch": "main", "commit": "9a10e096703966335bd5c46c8c875d5b0690dade" },
|
||||||
"mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" },
|
"mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" },
|
||||||
"mini.ai": { "branch": "main", "commit": "4b0a6207341d895b6cfe9bcb1e4d3e8607bfe4f4" },
|
"mini.ai": { "branch": "main", "commit": "25248c6aa002391936a6200f12d1466015987133" },
|
||||||
"mini.icons": { "branch": "main", "commit": "5b9076dae1bfbe47ba4a14bc8b967cde0ab5d77e" },
|
"mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" },
|
||||||
"mini.pairs": { "branch": "main", "commit": "b7fde3719340946feb75017ef9d75edebdeb0566" },
|
"mini.pairs": { "branch": "main", "commit": "b1c5a726921b7a8c9321e9a7a208aa0571de5810" },
|
||||||
"neo-tree.nvim": { "branch": "main", "commit": "84c75e7a7e443586f60508d12fc50f90d9aee14e" },
|
"neo-tree.nvim": { "branch": "main", "commit": "f3f3bf73414e400cf9fc13fda50f00404a8f8ab1" },
|
||||||
"neotest": { "branch": "master", "commit": "72bc8f1ec62a590fb47368c81f89610e0f353e28" },
|
"neotest": { "branch": "master", "commit": "72bc8f1ec62a590fb47368c81f89610e0f353e28" },
|
||||||
"neotest-golang": { "branch": "main", "commit": "58d07a76d714f8f768bbe27711b06d41ca237728" },
|
"neotest-golang": { "branch": "main", "commit": "58d07a76d714f8f768bbe27711b06d41ca237728" },
|
||||||
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
|
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
|
||||||
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" },
|
"nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" },
|
||||||
"nvim-cmp": { "branch": "main", "commit": "f17d9b4394027ff4442b298398dfcaab97e40c4f" },
|
"nvim-cmp": { "branch": "main", "commit": "f17d9b4394027ff4442b298398dfcaab97e40c4f" },
|
||||||
"nvim-dap": { "branch": "master", "commit": "4f5deb110d9ff8994d96c21df95e2271d11214f9" },
|
"nvim-dap": { "branch": "master", "commit": "c9a0738e45f1bd41d792a126941348dce661cf9b" },
|
||||||
"nvim-dap-go": { "branch": "main", "commit": "b4421153ead5d726603b02743ea40cf26a51ed5f" },
|
"nvim-dap-go": { "branch": "main", "commit": "b4421153ead5d726603b02743ea40cf26a51ed5f" },
|
||||||
"nvim-dap-ui": { "branch": "master", "commit": "f5b6673f374626515401c5bc51b005f784a4f252" },
|
"nvim-dap-ui": { "branch": "master", "commit": "cc9dd33aade7f20bae414d0cba163bc60d4d4b43" },
|
||||||
"nvim-dap-virtual-text": { "branch": "master", "commit": "fbdb48c2ed45f4a8293d0d483f7730d24467ccb6" },
|
"nvim-dap-virtual-text": { "branch": "master", "commit": "fbdb48c2ed45f4a8293d0d483f7730d24467ccb6" },
|
||||||
"nvim-lint": { "branch": "master", "commit": "4b03656c09c1561f89b6aa0665c15d292ba9499d" },
|
"nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" },
|
||||||
"nvim-lspconfig": { "branch": "master", "commit": "5492a24edd68f78d8a778edcb8905d0a28993abc" },
|
"nvim-lspconfig": { "branch": "master", "commit": "5492a24edd68f78d8a778edcb8905d0a28993abc" },
|
||||||
"nvim-nio": { "branch": "master", "commit": "21f5324bfac14e22ba26553caf69ec76ae8a7662" },
|
"nvim-nio": { "branch": "master", "commit": "21f5324bfac14e22ba26553caf69ec76ae8a7662" },
|
||||||
"nvim-tree.lua": { "branch": "master", "commit": "3603f069ff783c31986894a56c2a6755331b0802" },
|
"nvim-tree.lua": { "branch": "master", "commit": "3603f069ff783c31986894a56c2a6755331b0802" },
|
||||||
"nvim-treesitter": { "branch": "main", "commit": "7caec274fd19c12b55902a5b795100d21531391f" },
|
"nvim-treesitter": { "branch": "main", "commit": "7caec274fd19c12b55902a5b795100d21531391f" },
|
||||||
"nvim-treesitter-textobjects": { "branch": "main", "commit": "93d60a475f0b08a8eceb99255863977d3a25f310" },
|
"nvim-treesitter-textobjects": { "branch": "main", "commit": "5c7b0263797dfd1bd6202f2b219f3b53a80b2187" },
|
||||||
"nvim-ts-autotag": { "branch": "main", "commit": "8e1c0a389f20bf7f5b0dd0e00306c1247bda2595" },
|
"nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" },
|
||||||
"nvim-web-devicons": { "branch": "master", "commit": "d7462543c9e366c0d196c7f67a945eaaf5d99414" },
|
"nvim-web-devicons": { "branch": "master", "commit": "d7462543c9e366c0d196c7f67a945eaaf5d99414" },
|
||||||
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
|
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
|
||||||
"playground": { "branch": "master", "commit": "ba48c6a62a280eefb7c85725b0915e021a1a0749" },
|
"playground": { "branch": "master", "commit": "ba48c6a62a280eefb7c85725b0915e021a1a0749" },
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
"symbols-outline.nvim": { "branch": "master", "commit": "564ee65dfc9024bdde73a6621820866987cbb256" },
|
"symbols-outline.nvim": { "branch": "master", "commit": "564ee65dfc9024bdde73a6621820866987cbb256" },
|
||||||
"telescope-file-browser.nvim": { "branch": "master", "commit": "3610dc7dc91f06aa98b11dca5cc30dfa98626b7e" },
|
"telescope-file-browser.nvim": { "branch": "master", "commit": "3610dc7dc91f06aa98b11dca5cc30dfa98626b7e" },
|
||||||
"telescope-fzf-native.nvim": { "branch": "main", "commit": "6fea601bd2b694c6f2ae08a6c6fab14930c60e2c" },
|
"telescope-fzf-native.nvim": { "branch": "main", "commit": "6fea601bd2b694c6f2ae08a6c6fab14930c60e2c" },
|
||||||
"telescope.nvim": { "branch": "master", "commit": "a8c2223ea6b185701090ccb1ebc7f4e41c4c9784" },
|
"telescope.nvim": { "branch": "master", "commit": "40aedd8a68c78a656a10a8d62d80c54af59420fb" },
|
||||||
"todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
|
"todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
|
||||||
"tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" },
|
"tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" },
|
||||||
"trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
|
"trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
|
||||||
"ts-comments.nvim": { "branch": "main", "commit": "123a9fb12e7229342f807ec9e6de478b1102b041" },
|
"ts-comments.nvim": { "branch": "main", "commit": "a59d6092213447450191122c9346f309161504cb" },
|
||||||
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
|
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ usage() {
|
|||||||
|
|
||||||
备份已有 ~/.gitconfig,软链接到 ~/.dotfile/git/.gitconfig。
|
备份已有 ~/.gitconfig,软链接到 ~/.dotfile/git/.gitconfig。
|
||||||
同时为 git-credential.sh 添加可执行权限。
|
同时为 git-credential.sh 添加可执行权限。
|
||||||
|
若本机还没有 ~/.gitconfig.work,则生成空模板(~/workrepo 公司身份,需自行填写)。
|
||||||
|
|
||||||
凭据由 ~/.dotfile/git/git-credential.sh 处理(兼容 Git < 2.36,低版本回退 store)。
|
凭据由 ~/.dotfile/git/git-credential.sh 处理(兼容 Git < 2.36,低版本回退 store)。
|
||||||
|
|
||||||
@@ -41,3 +42,17 @@ fi
|
|||||||
chmod +x "$HOME/.dotfile/git/git-credential.sh"
|
chmod +x "$HOME/.dotfile/git/git-credential.sh"
|
||||||
# 兼容旧路径:若仍存在则保持可执行
|
# 兼容旧路径:若仍存在则保持可执行
|
||||||
chmod +x "$HOME/.dotfile/git/git-credential-linux.sh" 2>/dev/null || true
|
chmod +x "$HOME/.dotfile/git/git-credential-linux.sh" 2>/dev/null || true
|
||||||
|
|
||||||
|
work_cfg="$HOME/.gitconfig.work"
|
||||||
|
if [ -e "$work_cfg" ]; then
|
||||||
|
echo " $work_cfg already exists, skip"
|
||||||
|
else
|
||||||
|
cat > "$work_cfg" <<'EOF'
|
||||||
|
# 公司 Git 身份(仅本机,不要提交到 dotfile)
|
||||||
|
# ~/workrepo 下的仓库会加载本文件。取消注释并填写后再 commit:
|
||||||
|
# [user]
|
||||||
|
# name = Your Name
|
||||||
|
# email = you@company.com
|
||||||
|
EOF
|
||||||
|
echo " Created $work_cfg — fill in company user.name / user.email"
|
||||||
|
fi
|
||||||
|
|||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 将 herdr 配置链接到 dotfile
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/common/help.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法: herdr-init
|
||||||
|
|
||||||
|
将 ~/.dotfile/herdr/config.toml 软链接到 herdr 配置路径:
|
||||||
|
$XDG_CONFIG_HOME/herdr/config.toml(默认 ~/.config/herdr/config.toml)
|
||||||
|
|
||||||
|
已有配置会备份为 *.backup.<时间戳>。
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-h, --help 显示此帮助
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
dotfile_help_requested "${1:-}" && dotfile_show_help
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SRC="$HOME/.dotfile/herdr/config.toml"
|
||||||
|
CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||||
|
DEST_DIR="$CONFIG_HOME/herdr"
|
||||||
|
DEST="$DEST_DIR/config.toml"
|
||||||
|
|
||||||
|
if [ ! -f "$SRC" ]; then
|
||||||
|
echo "错误: 未找到 $SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "初始化 herdr 配置..."
|
||||||
|
echo "目标路径: $DEST"
|
||||||
|
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
|
||||||
|
if [ -L "$DEST" ] && [ "$(readlink -f "$DEST")" = "$(readlink -f "$SRC")" ]; then
|
||||||
|
echo "软链接已存在且指向正确位置,无需操作。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$DEST" ] || [ -L "$DEST" ]; then
|
||||||
|
BACKUP="$DEST.backup.$(date +%Y%m%d%H%M%S)"
|
||||||
|
mv "$DEST" "$BACKUP"
|
||||||
|
echo "已将现有配置备份到 $BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "$SRC" "$DEST"
|
||||||
|
|
||||||
|
if [ -L "$DEST" ] && [ "$(readlink -f "$DEST")" = "$(readlink -f "$SRC")" ]; then
|
||||||
|
echo "软链接创建成功: $DEST -> $SRC"
|
||||||
|
else
|
||||||
|
echo "软链接创建失败,请检查路径和权限。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v herdr >/dev/null 2>&1; then
|
||||||
|
echo "校验配置:"
|
||||||
|
herdr config check || true
|
||||||
|
if pgrep -x herdr >/dev/null 2>&1; then
|
||||||
|
echo "herdr 正在运行,可在 herdr 内用 prefix+shift+r 热重载"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "未找到 herdr,跳过配置校验"
|
||||||
|
fi
|
||||||
+39
-13
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 安装 mise 并链接 dotfile 配置、安装 config.toml 中的工具
|
# 安装 mise,链接共享配置到 conf.d,安装工具
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
@@ -10,8 +10,9 @@ usage() {
|
|||||||
用法: mise-install
|
用法: mise-install
|
||||||
|
|
||||||
1. 若未安装则通过官方脚本安装 mise
|
1. 若未安装则通过官方脚本安装 mise
|
||||||
2. 链接 ~/.dotfile/mise/config.toml
|
2. 链接 ~/.dotfile/mise/config.toml 到 ~/.config/mise/conf.d/dotfile.toml
|
||||||
3. 执行 mise install 安装配置中的工具
|
3. 若全局 config.toml 仍指向仓库,则改为本机实文件(mise use -g 写这里)
|
||||||
|
4. 执行 mise install 安装配置中的工具
|
||||||
|
|
||||||
选项:
|
选项:
|
||||||
-h, --help 显示此帮助
|
-h, --help 显示此帮助
|
||||||
@@ -20,18 +21,28 @@ EOF
|
|||||||
|
|
||||||
dotfile_help_requested "${1:-}" && dotfile_show_help
|
dotfile_help_requested "${1:-}" && dotfile_show_help
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
|
mise_fail() {
|
||||||
|
echo -e "${RED}mise 安装失败,请检查错误信息${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
if ! command -v mise &>/dev/null; then
|
if ! command -v mise &>/dev/null; then
|
||||||
echo -e "${YELLOW}mise 未安装. 正在安装 mise...${NC}"
|
echo -e "${YELLOW}mise 未安装. 正在安装 mise...${NC}"
|
||||||
curl https://mise.run | sh
|
# 无 pipefail 时 curl 失败 sh 仍可能退出 0,会误报「安装完成」
|
||||||
|
curl -fL --retry 3 https://mise.run | sh || mise_fail
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
if ! command -v mise &>/dev/null; then
|
||||||
|
mise_fail
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f ~/.config/fish/config.fish ]; then
|
if [ -f ~/.config/fish/config.fish ]; then
|
||||||
if ! grep -q "mise activate" ~/.config/fish/config.fish; then
|
if ! grep -q "mise activate" ~/.config/fish/config.fish; then
|
||||||
echo -e "${YELLOW}正在为 fish shell 配置 mise...${NC}"
|
echo -e "${YELLOW}正在为 fish shell 配置 mise...${NC}"
|
||||||
@@ -52,15 +63,31 @@ fi
|
|||||||
|
|
||||||
MISE_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mise"
|
MISE_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mise"
|
||||||
DOTFILE_MISE_CONFIG="$HOME/.dotfile/mise/config.toml"
|
DOTFILE_MISE_CONFIG="$HOME/.dotfile/mise/config.toml"
|
||||||
mkdir -p "$MISE_CONFIG_DIR"
|
MISE_GLOBAL_CONFIG="$MISE_CONFIG_DIR/config.toml"
|
||||||
|
MISE_SHARED_LINK="$MISE_CONFIG_DIR/conf.d/dotfile.toml"
|
||||||
|
|
||||||
|
mkdir -p "$MISE_CONFIG_DIR/conf.d"
|
||||||
|
|
||||||
if [ -f "$DOTFILE_MISE_CONFIG" ]; then
|
if [ -f "$DOTFILE_MISE_CONFIG" ]; then
|
||||||
if [ -f "$MISE_CONFIG_DIR/config.toml" ] && [ ! -L "$MISE_CONFIG_DIR/config.toml" ]; then
|
# 旧布局:全局 config.toml 软链进仓库,mise use -g 会弄脏 git
|
||||||
mv "$MISE_CONFIG_DIR/config.toml" "$MISE_CONFIG_DIR/config.toml.bak"
|
if [ -L "$MISE_GLOBAL_CONFIG" ] &&
|
||||||
echo -e "${YELLOW}已备份原有配置到 config.toml.bak${NC}"
|
[ "$(readlink -f "$MISE_GLOBAL_CONFIG")" = "$(readlink -f "$DOTFILE_MISE_CONFIG")" ]; then
|
||||||
|
rm -f "$MISE_GLOBAL_CONFIG"
|
||||||
|
echo -e "${YELLOW}已解开指向仓库的全局 config.toml 软链,改为本机文件${NC}"
|
||||||
fi
|
fi
|
||||||
ln -sf "$DOTFILE_MISE_CONFIG" "$MISE_CONFIG_DIR/config.toml"
|
|
||||||
echo -e "${GREEN}已链接 mise 配置: $MISE_CONFIG_DIR/config.toml${NC}"
|
ln -sf "$DOTFILE_MISE_CONFIG" "$MISE_SHARED_LINK"
|
||||||
|
echo -e "${GREEN}已链接共享 mise 配置: $MISE_SHARED_LINK${NC}"
|
||||||
mise trust "$DOTFILE_MISE_CONFIG" 2>/dev/null || true
|
mise trust "$DOTFILE_MISE_CONFIG" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ ! -e "$MISE_GLOBAL_CONFIG" ]; then
|
||||||
|
cat >"$MISE_GLOBAL_CONFIG" <<'EOF'
|
||||||
|
# 本机工具(mise use -g)。共享配置: ~/.dotfile/mise/config.toml
|
||||||
|
[tools]
|
||||||
|
EOF
|
||||||
|
echo -e "${GREEN}已创建本机 mise 配置: $MISE_GLOBAL_CONFIG${NC}"
|
||||||
|
fi
|
||||||
|
mise trust "$MISE_GLOBAL_CONFIG" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v mise &>/dev/null && [ -f "$MISE_CONFIG_DIR/config.toml" ]; then
|
if command -v mise &>/dev/null && [ -f "$MISE_CONFIG_DIR/config.toml" ]; then
|
||||||
@@ -72,6 +99,5 @@ fi
|
|||||||
if command -v mise &>/dev/null; then
|
if command -v mise &>/dev/null; then
|
||||||
echo -e "${GREEN}mise 安装成功!${NC}"
|
echo -e "${GREEN}mise 安装成功!${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${RED}mise 安装失败,请检查错误信息${NC}"
|
mise_fail
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 从 git.yumee.top 克隆并安装 pouch 到 ~/.pouch
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/common/help.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法: pouch-install
|
||||||
|
|
||||||
|
从 https://git.yumee.top/laily/.pouch 克隆到 ~/.pouch,
|
||||||
|
并运行仓库内 install.sh 安装 CLI。
|
||||||
|
|
||||||
|
~/.pouch 已是 pouch 仓库时跳过克隆,直接跑 install.sh。
|
||||||
|
路径已占用且不是 pouch 仓库时拒绝覆盖。
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-h, --help 显示此帮助
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
dotfile_help_requested "${1:-}" && dotfile_show_help
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
POUCH_DIR="${HOME}/.pouch"
|
||||||
|
POUCH_URL="https://git.yumee.top/laily/.pouch"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}缺少命令: $1${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_pouch_repo() {
|
||||||
|
[ -d "$POUCH_DIR/skills" ] && [ -f "$POUCH_DIR/install.sh" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd git
|
||||||
|
need_cmd python3
|
||||||
|
|
||||||
|
if [ -e "$POUCH_DIR" ] || [ -L "$POUCH_DIR" ]; then
|
||||||
|
if is_pouch_repo; then
|
||||||
|
echo -e "${GREEN}${POUCH_DIR} 已存在,跳过克隆${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}${POUCH_DIR} 已存在且不是 pouch 仓库,拒绝覆盖${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}正在克隆 ${POUCH_URL} 到 ${POUCH_DIR}...${NC}"
|
||||||
|
git clone "$POUCH_URL" "$POUCH_DIR"
|
||||||
|
echo -e "${GREEN}已克隆到 ${POUCH_DIR}${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_pouch_repo; then
|
||||||
|
echo -e "${RED}${POUCH_DIR} 缺少 skills/ 或 install.sh,仓库可能不完整${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}正在运行 ${POUCH_DIR}/install.sh...${NC}"
|
||||||
|
bash "$POUCH_DIR/install.sh"
|
||||||
|
export PATH="${HOME}/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
if command -v pouch >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}pouch 安装成功: $(command -v pouch)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}pouch 已写入 ~/.local/bin,当前会话请执行:${NC}"
|
||||||
|
echo ' export PATH="$HOME/.local/bin:$PATH"'
|
||||||
|
fi
|
||||||
+99
-11
@@ -11,6 +11,7 @@ usage() {
|
|||||||
|
|
||||||
扫描局域网主机:
|
扫描局域网主机:
|
||||||
无参数 ICMP 扫描本网段 .1-.254,并尽力显示名称、MAC 和厂商
|
无参数 ICMP 扫描本网段 .1-.254,并尽力显示名称、MAC 和厂商
|
||||||
|
检测到多个 IPv4 网卡时会先让你选择扫描源
|
||||||
--identify 额外通过 mDNS 识别设备(macOS,约增加 3 秒)
|
--identify 额外通过 mDNS 识别设备(macOS,约增加 3 秒)
|
||||||
10.0.0 扫描指定网段前缀
|
10.0.0 扫描指定网段前缀
|
||||||
22 扫描本网段 TCP 22 端口
|
22 扫描本网段 TCP 22 端口
|
||||||
@@ -92,15 +93,101 @@ if [ -n "$1" ] && [ -z "$PORT_SPEC" ] && [ -z "$PREFIX_ARG" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v ip &>/dev/null; then
|
list_ipv4_interfaces() {
|
||||||
LOCAL_IP=$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -n 1)
|
if [ "$(uname -s)" = "Darwin" ] && command -v ifconfig &>/dev/null; then
|
||||||
elif command -v ifconfig &>/dev/null; then
|
ifconfig | awk '
|
||||||
LOCAL_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)
|
/^[^[:space:]][^:]*:/ {
|
||||||
else
|
iface=$1
|
||||||
echo "错误: 找不到 ip 或 ifconfig 命令" && exit 1
|
sub(/:$/, "", iface)
|
||||||
fi
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+inet / {
|
||||||
|
ip=$2
|
||||||
|
if (iface !~ /^lo/ && ip != "127.0.0.1" && ip !~ /^169\.254\./) {
|
||||||
|
print iface "\t" ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'
|
||||||
|
elif command -v ip &>/dev/null; then
|
||||||
|
ip -o -4 addr show up scope global 2>/dev/null \
|
||||||
|
| awk '{split($4, address, "/"); print $2 "\t" address[1]}'
|
||||||
|
elif command -v ifconfig &>/dev/null; then
|
||||||
|
ifconfig | awk '
|
||||||
|
/^[^[:space:]][^:]*:/ {
|
||||||
|
iface=$1
|
||||||
|
sub(/:$/, "", iface)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]+inet / {
|
||||||
|
ip=$2
|
||||||
|
if (iface !~ /^lo/ && ip != "127.0.0.1" && ip !~ /^169\.254\./) {
|
||||||
|
print iface "\t" ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
[ -z "$LOCAL_IP" ] && echo "错误: 未能检测到本地 IP" && exit 1
|
select_ipv4_interface() {
|
||||||
|
local iface ip choice entry default_iface default_label index
|
||||||
|
local candidates=()
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r iface ip; do
|
||||||
|
[ -n "$iface" ] && [ -n "$ip" ] || continue
|
||||||
|
candidates+=("$iface|$ip")
|
||||||
|
done < <(list_ipv4_interfaces)
|
||||||
|
|
||||||
|
if [ ${#candidates[@]} -eq 0 ]; then
|
||||||
|
echo "错误: 未能检测到可用的 IPv4 网卡" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
default_iface=""
|
||||||
|
if [ "$(uname -s)" = "Darwin" ] && command -v route &>/dev/null; then
|
||||||
|
default_iface=$(route -n get default 2>/dev/null \
|
||||||
|
| awk '/^[[:space:]]*interface:/{print $2; exit}')
|
||||||
|
elif command -v ip &>/dev/null; then
|
||||||
|
default_iface=$(ip route show default 2>/dev/null \
|
||||||
|
| awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#candidates[@]} -gt 1 ]; then
|
||||||
|
echo "检测到多个可用 IPv4 网卡,请选择扫描源:" >&2
|
||||||
|
for index in "${!candidates[@]}"; do
|
||||||
|
entry=${candidates[$index]}
|
||||||
|
iface=${entry%%|*}
|
||||||
|
ip=${entry#*|}
|
||||||
|
default_label=""
|
||||||
|
[ "$iface" = "$default_iface" ] && default_label=" (默认路由)"
|
||||||
|
printf ' %d) %s %s%s\n' "$((index + 1))" "$iface" "$ip" "$default_label" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
printf '请选择 [1-%d]: ' "${#candidates[@]}" >&2
|
||||||
|
IFS= read -r choice || {
|
||||||
|
echo >&2
|
||||||
|
echo "错误: 未选择扫描源网卡" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if [[ "$choice" =~ ^[0-9]+$ ]] \
|
||||||
|
&& [ "$choice" -ge 1 ] \
|
||||||
|
&& [ "$choice" -le ${#candidates[@]} ]; then
|
||||||
|
entry=${candidates[$((choice - 1))]}
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "错误: 请输入 1-${#candidates[@]} 之间的编号" >&2
|
||||||
|
done
|
||||||
|
else
|
||||||
|
entry=${candidates[0]}
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOCAL_IFACE=${entry%%|*}
|
||||||
|
LOCAL_IP=${entry#*|}
|
||||||
|
}
|
||||||
|
|
||||||
|
select_ipv4_interface || exit 1
|
||||||
|
|
||||||
if [ -n "$PREFIX_ARG" ]; then
|
if [ -n "$PREFIX_ARG" ]; then
|
||||||
PREFIX="$PREFIX_ARG"
|
PREFIX="$PREFIX_ARG"
|
||||||
@@ -127,7 +214,8 @@ if [ -n "$PORT_SPEC" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "本机 IP: $LOCAL_IP,扫描网段: $PREFIX.1-254"
|
echo "扫描源网卡: ${LOCAL_IFACE} (${LOCAL_IP})"
|
||||||
|
echo "本机 IP: ${LOCAL_IP},扫描网段: ${PREFIX}.1-254"
|
||||||
if [ ${#PORTS[@]} -gt 0 ]; then
|
if [ ${#PORTS[@]} -gt 0 ]; then
|
||||||
echo "扫描端口: ${PORTS[*]} (TCP,超时 ${TIMEOUT}s)"
|
echo "扫描端口: ${PORTS[*]} (TCP,超时 ${TIMEOUT}s)"
|
||||||
else
|
else
|
||||||
@@ -398,7 +486,7 @@ if [ ${#PORTS[@]} -eq 0 ]; then
|
|||||||
for i in $(seq 1 254); do
|
for i in $(seq 1 254); do
|
||||||
(
|
(
|
||||||
ip="$PREFIX.$i"
|
ip="$PREFIX.$i"
|
||||||
if ping -c 1 -W 1 -I "$LOCAL_IP" "$ip" >/dev/null 2>&1; then
|
if ping -c 1 -W 1 -I "$LOCAL_IFACE" "$ip" >/dev/null 2>&1; then
|
||||||
printf '%s 在线\n' "$(format_host "$ip")" >"$RESULT_DIR/$i"
|
printf '%s 在线\n' "$(format_host "$ip")" >"$RESULT_DIR/$i"
|
||||||
fi
|
fi
|
||||||
) &
|
) &
|
||||||
@@ -418,7 +506,7 @@ if ! command -v nc &>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NC_OPTS=(-z -w "$TIMEOUT")
|
NC_OPTS=(-z -w "$TIMEOUT" -s "$LOCAL_IP")
|
||||||
if [ "$(uname -s)" = "Darwin" ]; then
|
if [ "$(uname -s)" = "Darwin" ]; then
|
||||||
NC_OPTS+=(-G "$TIMEOUT")
|
NC_OPTS+=(-G "$TIMEOUT")
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ if [[ $NO_SUDO -eq 0 && $EUID -ne 0 ]]; then
|
|||||||
exec sudo -- "$0"
|
exec sudo -- "$0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 仅在输出到终端时着色;重定向到文件或设置 NO_COLOR 时保持纯文本
|
||||||
|
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
|
||||||
|
COLOR_WARN=$'\033[1;33m'
|
||||||
|
COLOR_HIGH=$'\033[0;31m'
|
||||||
|
COLOR_RESET=$'\033[0m'
|
||||||
|
else
|
||||||
|
COLOR_WARN=""
|
||||||
|
COLOR_HIGH=""
|
||||||
|
COLOR_RESET=""
|
||||||
|
fi
|
||||||
|
|
||||||
PASS_COUNT=0
|
PASS_COUNT=0
|
||||||
WARN_COUNT=0
|
WARN_COUNT=0
|
||||||
HIGH_COUNT=0
|
HIGH_COUNT=0
|
||||||
@@ -70,12 +81,12 @@ pass() {
|
|||||||
|
|
||||||
warn() {
|
warn() {
|
||||||
WARN_COUNT=$((WARN_COUNT + 1))
|
WARN_COUNT=$((WARN_COUNT + 1))
|
||||||
printf '[警告] %s\n' "$*"
|
printf '%s[警告] %s%s\n' "$COLOR_WARN" "$*" "$COLOR_RESET"
|
||||||
}
|
}
|
||||||
|
|
||||||
high() {
|
high() {
|
||||||
HIGH_COUNT=$((HIGH_COUNT + 1))
|
HIGH_COUNT=$((HIGH_COUNT + 1))
|
||||||
printf '[高风险] %s\n' "$*"
|
printf '%s[高风险] %s%s\n' "$COLOR_HIGH" "$*" "$COLOR_RESET"
|
||||||
}
|
}
|
||||||
|
|
||||||
info() {
|
info() {
|
||||||
@@ -103,11 +114,42 @@ find_sshd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_sshd_setting() {
|
check_sshd_setting() {
|
||||||
local setting="$1" value
|
local setting value
|
||||||
value=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk -v key="$setting" '$1 == key { print $2; exit }')
|
setting=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
||||||
|
value=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk -v key="$setting" 'tolower($1) == key { print tolower($2); exit }')
|
||||||
printf '%s' "$value"
|
printf '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SSHD_CONFIG_FILE=/etc/ssh/sshd_config
|
||||||
|
SSHD_CONFIG_DIR=/etc/ssh/sshd_config.d
|
||||||
|
|
||||||
|
# 列出配置文件中显式设置某个参数的行,用于解释“明明改过了为何还不生效”。
|
||||||
|
# sshd 对同一参数只采用先读到的一条:Include /etc/ssh/sshd_config.d/*.conf
|
||||||
|
# 通常位于主配置前部,因此 drop-in 会覆盖主配置后部的同名设置;Match 块内的设置
|
||||||
|
# 也不作用于全局配置。
|
||||||
|
check_ssh_setting_sources() {
|
||||||
|
local key file matches files
|
||||||
|
for key in "$@"; do
|
||||||
|
files=()
|
||||||
|
[[ -r "$SSHD_CONFIG_FILE" ]] && files+=("$SSHD_CONFIG_FILE")
|
||||||
|
if [[ -d "$SSHD_CONFIG_DIR" ]]; then
|
||||||
|
for file in "$SSHD_CONFIG_DIR"/*.conf; do
|
||||||
|
[[ -r "$file" ]] && files+=("$file")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
((${#files[@]} > 0)) || continue
|
||||||
|
|
||||||
|
matches=$(grep -inHE "^[[:space:]]*${key}([[:space:]]|=)" "${files[@]}" 2>/dev/null || true)
|
||||||
|
if [[ -z "$matches" ]]; then
|
||||||
|
info "${key} 未在配置文件中显式设置,生效值可能来自 sshd 默认值"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "${key} 的配置行(sshd 只采用先读到的一条;Match 块内的设置不作用于全局):"
|
||||||
|
printf '%s\n' "$matches" | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
check_ssh() {
|
check_ssh() {
|
||||||
local sshd_bin root_login password_login ssh_ports
|
local sshd_bin root_login password_login ssh_ports
|
||||||
|
|
||||||
@@ -121,6 +163,7 @@ check_ssh() {
|
|||||||
SSHD_EFFECTIVE_CONFIG=$("$sshd_bin" -T 2>/dev/null)
|
SSHD_EFFECTIVE_CONFIG=$("$sshd_bin" -T 2>/dev/null)
|
||||||
if [[ -z "$SSHD_EFFECTIVE_CONFIG" ]]; then
|
if [[ -z "$SSHD_EFFECTIVE_CONFIG" ]]; then
|
||||||
warn "无法读取 sshd 生效配置;请使用 sudo 重试或检查 sshd 配置"
|
warn "无法读取 sshd 生效配置;请使用 sudo 重试或检查 sshd 配置"
|
||||||
|
check_ssh_setting_sources permitrootlogin passwordauthentication
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -132,14 +175,17 @@ check_ssh() {
|
|||||||
no|forced-commands-only)
|
no|forced-commands-only)
|
||||||
pass "SSH 已禁止 root 交互式登录(PermitRootLogin: $root_login)"
|
pass "SSH 已禁止 root 交互式登录(PermitRootLogin: $root_login)"
|
||||||
;;
|
;;
|
||||||
prohibit-password)
|
prohibit-password|without-password)
|
||||||
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: prohibit-password)"
|
# without-password 是 prohibit-password 的旧别名,含义相同
|
||||||
|
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: $root_login)"
|
||||||
;;
|
;;
|
||||||
yes)
|
yes)
|
||||||
high "SSH 允许 root 直接登录;建议设为 PermitRootLogin no"
|
high "SSH 允许 root 直接登录;建议设为 PermitRootLogin no"
|
||||||
|
check_ssh_setting_sources permitrootlogin
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
warn "无法确定 PermitRootLogin 的安全状态(当前: ${root_login:-未设置})"
|
warn "无法确定 PermitRootLogin 的安全状态(当前: ${root_login:-未设置})"
|
||||||
|
check_ssh_setting_sources permitrootlogin
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -149,9 +195,11 @@ check_ssh() {
|
|||||||
;;
|
;;
|
||||||
yes)
|
yes)
|
||||||
warn "SSH 允许密码登录;公网服务器建议改用密钥认证"
|
warn "SSH 允许密码登录;公网服务器建议改用密钥认证"
|
||||||
|
check_ssh_setting_sources passwordauthentication
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
warn "无法确定 PasswordAuthentication 的安全状态(当前: ${password_login:-未设置})"
|
warn "无法确定 PasswordAuthentication 的安全状态(当前: ${password_login:-未设置})"
|
||||||
|
check_ssh_setting_sources passwordauthentication
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -381,11 +429,16 @@ check_privileged_accounts
|
|||||||
check_recent_logins
|
check_recent_logins
|
||||||
|
|
||||||
printf '\n== 汇总 ==\n'
|
printf '\n== 汇总 ==\n'
|
||||||
printf '通过: %d 警告: %d 高风险: %d 信息: %d\n' "$PASS_COUNT" "$WARN_COUNT" "$HIGH_COUNT" "$INFO_COUNT"
|
|
||||||
if ((HIGH_COUNT > 0)); then
|
if ((HIGH_COUNT > 0)); then
|
||||||
echo "请优先处理“高风险”项。"
|
SUMMARY_COLOR="$COLOR_HIGH"
|
||||||
|
SUMMARY_ADVICE='请优先处理“高风险”项。'
|
||||||
elif ((WARN_COUNT > 0)); then
|
elif ((WARN_COUNT > 0)); then
|
||||||
echo "建议逐项确认“警告”项是否符合服务器的实际部署方式。"
|
SUMMARY_COLOR="$COLOR_WARN"
|
||||||
|
SUMMARY_ADVICE='建议逐项确认“警告”项是否符合服务器的实际部署方式。'
|
||||||
else
|
else
|
||||||
echo "未发现需要立即处理的项。"
|
SUMMARY_COLOR=""
|
||||||
|
SUMMARY_ADVICE='未发现需要立即处理的项。'
|
||||||
fi
|
fi
|
||||||
|
printf '%s通过: %d 警告: %d 高风险: %d 信息: %d%s\n' \
|
||||||
|
"$SUMMARY_COLOR" "$PASS_COUNT" "$WARN_COUNT" "$HIGH_COUNT" "$INFO_COUNT" "$COLOR_RESET"
|
||||||
|
printf '%s%s%s\n' "$SUMMARY_COLOR" "$SUMMARY_ADVICE" "$COLOR_RESET"
|
||||||
|
|||||||
+29
-15
@@ -166,6 +166,21 @@ find_alias_file() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Include 出现在 Host/Match 之后会被当成该块的条件引入,必须写在文件最前面。
|
||||||
|
ssh_config_has_toplevel_include() {
|
||||||
|
awk '
|
||||||
|
tolower($1) == "host" || tolower($1) == "match" { saw_block = 1 }
|
||||||
|
tolower($1) == "include" {
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
|
if (!saw_block) found_ok = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit(found_ok ? 0 : 1) }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
ensure_config_include() {
|
ensure_config_include() {
|
||||||
mkdir -p "$SSH_CONFIG_DIR"
|
mkdir -p "$SSH_CONFIG_DIR"
|
||||||
chmod 700 "$SSH_DIR" "$SSH_CONFIG_DIR"
|
chmod 700 "$SSH_DIR" "$SSH_CONFIG_DIR"
|
||||||
@@ -177,17 +192,8 @@ ensure_config_include() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if awk '
|
|
||||||
tolower($1) == "include" {
|
|
||||||
for (i = 2; i <= NF; i++) {
|
|
||||||
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
|
|
||||||
found = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
END { exit(found ? 0 : 1) }
|
|
||||||
' "$SSH_CONFIG"; then
|
|
||||||
chmod 600 "$SSH_CONFIG"
|
chmod 600 "$SSH_CONFIG"
|
||||||
|
if ssh_config_has_toplevel_include "$SSH_CONFIG"; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -198,18 +204,26 @@ ensure_config_include() {
|
|||||||
cp -p "$SSH_CONFIG" "$backup"
|
cp -p "$SSH_CONFIG" "$backup"
|
||||||
|
|
||||||
awk -v include_line="$INCLUDE_LINE" '
|
awk -v include_line="$INCLUDE_LINE" '
|
||||||
BEGIN {
|
function is_config_d_include() {
|
||||||
print "# Managed SSH config fragments"
|
if (tolower($1) != "include") {
|
||||||
print include_line
|
return 0
|
||||||
print ""
|
|
||||||
}
|
}
|
||||||
|
for (i = 2; i <= NF; i++) {
|
||||||
|
if (tolower($i) ~ /(^|\/)config\.d\/\*/) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
BEGIN { print include_line }
|
||||||
|
is_config_d_include() { next }
|
||||||
{ print }
|
{ print }
|
||||||
' "$SSH_CONFIG" >"$tmp"
|
' "$SSH_CONFIG" >"$tmp"
|
||||||
|
|
||||||
cp "$tmp" "$SSH_CONFIG"
|
cp "$tmp" "$SSH_CONFIG"
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
chmod 600 "$SSH_CONFIG"
|
chmod 600 "$SSH_CONFIG"
|
||||||
echo "已在 $SSH_CONFIG 中加入 config.d,并备份到 $backup"
|
echo "已在 $SSH_CONFIG 第一行加入 config.d Include,并备份到 $backup"
|
||||||
}
|
}
|
||||||
|
|
||||||
alias_name=""
|
alias_name=""
|
||||||
|
|||||||
Reference in New Issue
Block a user