15 Commits

Author SHA1 Message Date
laily f9e8a50bd9 Configure Vim for systemctl editing 2026-10-30 09:27:18 +08:00
laily 39cf5c6d19 fix: confirm SSH alias updates and keep key handling consistent 2026-10-19 16:57:44 +08:00
laily 59e2356f5c fix: pull ~/.dotfile automatically from ordinary ff commands
Daily ff subcommands never updated the repo; only `ff update` did.
Add a pouch-style interval auto-update at the ff entry (default 24h),
skip dirty/fail/timeout without aborting the current command, and
cover the skip/pull cases with a fish regression.
2026-10-13 16:25:05 +08:00
laily 2ede67d1c9 feat: add ff pouch to clone and install pouch
Clone https://git.yumee.top/laily/.pouch to ~/.pouch and run install.sh.
2026-10-13 16:01:34 +08:00
laily 4fc664d243 feat: split mise shared config from per-machine tools
Link mise/config.toml into ~/.config/mise/conf.d/dotfile.toml so
mise use -g writes the local config.toml instead of dirtying git.
2026-10-12 16:00:33 +08:00
laily 89ad569581 fix: select scan-ip network interface 2026-09-21 19:59:15 +08:00
laily cffe3f01f4 fix: 把 SSH config.d Include 写到 ~/.ssh/config 第一行
Include 落在 Host/Match 之后会被当成该块的条件引入。ssh2 与 ff ssh
改为检测顶层 Include,缺失时插入文件首行并去掉块内重复项。
2026-09-17 14:32:14 +08:00
laily 8c80e5b630 fix: 修正 server-security-check 的 SSH 误报并标注高风险
- PermitRootLogin: 接受 without-password 别名(OpenSSH -T 会输出该旧别名),
  避免把仅允许密钥的 root 登录误报为“无法确定安全状态”
- 检查未通过时列出配置文件中的相关设置行:sshd 只采用先读到的一条,
  主配置 Include 之前的 drop-in 以及 Match 块内的设置都会造成“改过但不生效”
- sshd -T 读取失败时同样给出配置文件设置,便于定位
- 参数匹配改为大小写不敏感
- 高风险整行红色、警告黄色,汇总同步着色;非 TTY 或 NO_COLOR 时禁用
2026-09-17 12:08:24 +08:00
laily 2060bbd23a feat: udpate fish 2026-09-16 21:44:25 +08:00
laily 0b2d2b6c6d feat(ff-ssh): add host from ssh args with ssh-copy-id
- ff ssh <ssh-args> parses target like ssh, runs ssh-copy-id, then
  writes ~/.ssh/config.d/<alias>.conf (HostName/User/Port/IdentityFile/
  ProxyJump/ProxyCommand); supports --no-copy, add and run subcommands
- record IdentityFile whenever -i / -o IdentityFile= is given and pass
  the same key to ssh-copy-id; omit it otherwise so ssh falls back to
  default identities
- ff ssh re-sources ff-ssh.fish each call, ff reload clears cached
  dotfile functions so edits take effect
- fix broken copy subcommand and ssh-arg parsing caused by
  --argument-names argv shadowing fish $argv
- fix ssh subcommand completion guards; wrap ssh completions for add/run
2026-09-16 15:51:48 +08:00
laily 0b04a4de50 feat: split git user identity by repo directory
Leave global name/email empty with useConfigOnly so unmatched repos cannot commit. Personal identity applies under ~/.dotfile, ~/code, and ~/.pouch; work identity stays in local ~/.gitconfig.work for ~/workrepo.
2026-09-14 14:55:44 +08:00
laily 097fce7827 fix: make git merge2 work across worktrees
Checkout of the target branch fails when it is already used by another
worktree. Detect that worktree and merge there instead.
2026-09-10 23:04:13 +08:00
laily a834c46b89 feat: update nvim dep 2026-09-08 14:35:58 +08:00
laily 2dedb9573c fix: invoke git credential helpers via absolute path
Apple Git osxkeychain exclusive-locks argv[0]. A relative name looks in
cwd and fails with "failed to lock git-credential-osxkeychain".
2026-09-08 11:39:25 +08:00
laily dc302bff1c fix: fail mise-install when curl pipeline errors
Do not print success after a failed curl | sh install; enable pipefail and verify mise is on PATH before continuing.
2026-09-08 11:34:46 +08:00
22 changed files with 2204 additions and 130 deletions
+12 -1
View File
@@ -10,6 +10,8 @@ ff script <name> # 运行脚本
make -f Makefile.scripts <name> # 同上(make 入口)
ff region # 切换 CN_REGION(国内/国外镜像)
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) |
| `~/.config/fish/config.fish` | 本机 wrapper,仅 `source` default.fish + 可选 `custom.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` |
## 脚本命名
@@ -51,16 +54,22 @@ ff reload # 重载 fish 配置
- 优先用系统包管理器(brew/apt),其次官方安装脚本或 Release 二进制到 `~/.local/bin`
- Debian/Ubuntu 安装类脚本应支持 `CN_REGION` 切换镜像(参考 `docker-install.sh`)
- 需要 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` 入口
- 跑脚本:`ff script <name>` 或 `ff s <name>`(走 `execute-script.fish`)
- 列脚本:`ff script -h`
- 新增 `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/.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 不展开 `~`)
- macOS:`osxkeychain` → 回退 `store`(`~/.git-credentials`)
- Linux:`libsecret` → 回退 `store`
@@ -71,6 +80,8 @@ ff reload # 重载 fish 配置
- 不要把密钥、webhook、本机专用路径写进 git(`script/clash.sh` 等已在 `.gitignore`)
- 不要改 `~/.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
- 脚本改动保持最小 diff,风格与相邻脚本一致(bash、`set -euo pipefail` 视情况)
- 未经明确要求不要 `git commit` / `git push`
+20 -7
View File
@@ -1,6 +1,6 @@
# Completions for ff command
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 "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"
@@ -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 "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 "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 -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 "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 "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 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" -a "(__fish_complete_directories)" -d "Directory"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a init -d "Ensure SSH key and config.d Include"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a copy -d "Copy specified SSH config file(s) to remote"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -a help -d "Show ssh subcommand help"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a init -d "Ensure SSH key and config.d Include"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a add -d "Copy key + add host from ssh-style args"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a run -d "Copy key + add host, then connect"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a copy -d "Copy specified SSH config file(s) to remote"
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help; and test (count (commandline -opc)) -eq 2" -a help -d "Show ssh subcommand help"
# 裸参数与 ssh 原生参数一致:复用 ssh 自带的补全(选项 + 主机名)
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy add run help" -w ssh
function __ff_ssh_config_d_files
for d in $HOME/.ssh/config.d $HOME/.dotfile/ssh/config.d
@@ -40,8 +49,12 @@ function __ff_ssh_config_d_files
end
end
complete -c ff -n "__fish_seen_subcommand_from ssh copy; and test (count (commandline -opc)) -eq 2" -a "(__fish_ssh_hosts 2>/dev/null; or __fish_print_hostnames)" -d "SSH host"
complete -c ff -n "__fish_seen_subcommand_from ssh copy; and test (count (commandline -opc)) -ge 3" -a "(__ff_ssh_config_d_files)" -d "Config file"
complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from copy; and test (count (commandline -opc)) -eq 3" -a "(__fish_print_hostnames)" -d "SSH host"
complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from copy; and test (count (commandline -opc)) -ge 4" -a "(__ff_ssh_config_d_files)" -d "Config file"
# add/run 使用与 ssh 相同的参数:直接复用 ssh 的选项与主机补全
complete -c ff -n "__fish_seen_subcommand_from ssh; and __fish_seen_subcommand_from add run" -w ssh
complete -c ff -n "__fish_seen_subcommand_from ssh; and not __fish_seen_subcommand_from init copy help" -l no-copy -d "Skip ssh-copy-id"
# # make 子命令的补全 - 直接使用脚本文件名
# complete -c ff -n "__fish_seen_subcommand_from make" -a "help" -d "Show all make targets"
+7 -1
View File
@@ -89,7 +89,11 @@ end
set -gx GOPATH $HOME/go:$HOME/workrepo/go
set -gx GOPROXY "goproxy.cn,direct"
set -gx GOSUMDB "sum.golang.google.cn"
# editor
set -gx SYSTEMD_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/.yarn/bin $HOME/.config/yarn/global/node_modules/.bin
function gtr
@@ -184,8 +188,10 @@ function sudo --wraps sudo
if test (count $argv) -gt 0
set cmd $argv[1]
switch $cmd
case systemctl
command sudo env SYSTEMD_EDITOR=$SYSTEMD_EDITOR systemctl $argv[2..-1]
case sc
command sudo systemctl $argv[2..-1]
command sudo env SYSTEMD_EDITOR=$SYSTEMD_EDITOR systemctl $argv[2..-1]
case jc
command sudo journalctl $argv[2..-1]
case dc
+557 -31
View File
@@ -1,4 +1,4 @@
# ff ssh: local init (keys + config.d) and copy config to remote
# ff ssh: local init (keys + config.d), copy key + add host, copy config to remote
function __ff_ssh_has_key
for f in $HOME/.ssh/id_*
@@ -22,13 +22,50 @@ function __ff_ssh_ensure_keys
echo "No SSH key found, generating ed25519: $key_path"
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
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"
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
set -l ssh_config "$HOME/.ssh/config"
set -l include_line "Include ~/.ssh/config.d/*"
@@ -36,15 +73,22 @@ function __ff_ssh_ensure_config_d
chmod 700 "$HOME/.ssh"
if not test -f $ssh_config
printf '%s\n' $include_line > $ssh_config
printf '%s\n' "$include_line" > $ssh_config
chmod 600 $ssh_config
echo "Created $ssh_config with $include_line"
else if grep -qE 'Include[[:space:]]+.*config\.d' $ssh_config
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"
return
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
function __ff_ssh_resolve_path --argument path
@@ -58,22 +102,62 @@ function __ff_ssh_resolve_path --argument path
end
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
function __ff_ssh_remote_ensure_include --argument host
ssh $host 'mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh
if [ -f ~/.ssh/config ] && grep -qE "Include[[:space:]]+.*config\.d" ~/.ssh/config; then
echo "Remote config.d Include already exists"
elif [ -f ~/.ssh/config ]; then
printf "\n# config.d\nInclude ~/.ssh/config.d/*\n" >> ~/.ssh/config
chmod 600 ~/.ssh/config
echo "Appended config.d Include on remote"
else
printf "%s\n" "Include ~/.ssh/config.d/*" > ~/.ssh/config
set -l script 'mkdir -p ~/.ssh/config.d && chmod 700 ~/.ssh
include_line="Include ~/.ssh/config.d/*"
has_toplevel() {
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
}
if [ ! -f ~/.ssh/config ]; then
printf "%s\n" "$include_line" > ~/.ssh/config
chmod 600 ~/.ssh/config
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
function __ff_ssh_resolve_copy_src --argument file
@@ -95,7 +179,7 @@ function __ff_ssh_resolve_copy_src --argument file
return 1
end
function __ff_ssh_copy_remote --argument-names argv
function __ff_ssh_copy_remote
set -l host $argv[1]
set -l files $argv[2..-1]
set -l ssh_dir "$HOME/.ssh"
@@ -158,24 +242,466 @@ function __ff_ssh_copy_remote --argument-names argv
echo "Done."
end
function ff_ssh --argument-names argv
# ---- ssh-style argument parsing -------------------------------------------
# ssh short options that consume a following value
function __ff_ssh_opt_takes_value --argument-names flag
contains -- $flag -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w
end
# First non-option argument of `ssh`-style args (the [user@]host)
function __ff_ssh_find_target
set -l n (count $argv)
set -l i 1
while test $i -le $n
set -l a $argv[$i]
if test "$a" = '--'
set i (math $i + 1)
if test $i -le $n
echo $argv[$i]
end
return 0
end
if string match -q -- '-*' $a
set -l flag (string sub -s 1 -l 2 -- $a)
if test (string length -- $a) -eq 2; and __ff_ssh_opt_takes_value $flag
set i (math $i + 2)
continue
end
set i (math $i + 1)
continue
end
echo $a
return 0
end
return 1
end
# Value of a short option, supporting both `-i file` and `-ifile`
function __ff_ssh_argv_value --argument-names flag
set -l args $argv[2..-1]
set -l n (count $args)
set -l flen (string length -- $flag)
set -l i 1
while test $i -le $n
set -l a $args[$i]
if test "$a" = "$flag"
set -l j (math $i + 1)
if test $j -le $n
echo $args[$j]
end
return 0
end
if test (string length -- $a) -gt $flen; and string match -q -- "$flag*" $a
string sub -s (math $flen + 1) -- $a
return 0
end
set i (math $i + 1)
end
return 1
end
# One value from `ssh -G` output (stdin)
function __ff_ssh_gval --argument-names key
while read -l line
set -l trimmed (string trim -- $line)
set -l k (string split -m1 ' ' -- $trimmed)[1]
if test (string lower -- $k) = (string lower -- $key)
string replace -r '^\S+\s+' '' -- $trimmed
return 0
end
end
end
# 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)
switch $sub
case init ''
__ff_ssh_ensure_keys
__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
__ff_ssh_copy_remote $argv[2..-1]
case help -h
echo "Usage: ff ssh [init|copy <host> <file>...|help]"
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 help -h --help
__ff_ssh_usage
case '*'
echo "Unknown subcommand: $sub"
echo "Run 'ff ssh help' for usage"
return 1
# 裸参数与 ssh 原生参数一致:有主机就记录配置,否则透传给 ssh
if __ff_ssh_find_target $argv >/dev/null
__ff_ssh_add_host $argv
else
command ssh $argv
end
end
end
+244 -5
View File
@@ -1,4 +1,214 @@
# 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
# 定义命令和描述(格式:命令|描述)
set -l commands \
@@ -12,12 +222,28 @@ function ff
"make|Run dotfile Makefile commands" \
"script|Run script from script directory" \
"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)" \
"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)" \
"pouch|Clone and install pouch to ~/.pouch" \
"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 自动加载前手动引入一次)
if not functions -q execute-script
set -l execute_script_path ~/.dotfile/fish/functions/execute-script.fish
@@ -28,7 +254,8 @@ function ff
switch $argv[1]
case reload
source ~/.config/fish/config.fish
# autoload 会缓存函数定义,先清掉 dotfile 里的函数再重载
_ff_reload
echo "Fish configuration reloaded!"
case edit
vim ~/.config/fish/custom.fish
@@ -77,6 +304,10 @@ function ff
set -l parts (string split '|' $cmd)
printf " %-10s %s\n" $parts[1] $parts[2]
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
set -l dotfile_dir ~/.dotfile
if not test -d $dotfile_dir
@@ -86,6 +317,7 @@ function ff
set -l prev_dir (pwd)
cd $dotfile_dir
if git pull origin main
_ff_record_auto_update
cd $prev_dir
echo "Reloading fish configuration..."
ff reload
@@ -95,9 +327,8 @@ function ff
return 1
end
case ssh
if not functions -q ff_ssh
# 每次都重新 source,避免 autoload 缓存旧定义
source ~/.dotfile/fish/functions/ff-ssh.fish
end
ff_ssh $argv[2..-1]
case dirsize
set -l script_path ~/.dotfile/script/dirsize.sh
@@ -107,6 +338,14 @@ function ff
echo "Error: dirsize script not found at $script_path"
return 1
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
if not functions -q region_toggle
source ~/.dotfile/fish/functions/region.fish
+181
View File
@@ -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
+488
View File
@@ -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
View File
@@ -1,6 +1,7 @@
# 不设全局 user.name / user.email,避免未匹配目录误用身份。
# useConfigOnly(Git 2.8+):未配置时拒绝 commit,而不是猜系统用户名。
[user]
name = laily
email = i@laily.net
useConfigOnly = true
[core]
autocrlf = input
quotepath = false
@@ -42,8 +43,18 @@
[mergetool "nvimdiff"]
cmd = nvim -d \"$LOCAL\" \"$REMOTE\" -ancestor \"$BASE\" -merge \"$MERGED\"
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/"]
path = ~/.workgitconfig
path = ~/.gitconfig.work
[includeIf "gitdir:~/repo/"]
path = ~/.gitconfig.work
[rebase]
autoSquash = true
autoStash = true
@@ -73,7 +84,8 @@
pt = push origin --tags
ph = push origin HEAD
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"
delbr = "!f(){ git branch -D $(git branch | grep \"\b$1\"); };f"
can = commit --amend --no-edit
+4
View File
@@ -0,0 +1,4 @@
# 个人身份:~/.dotfile、~/code、~/.pouch(由 includeIf gitdir 加载)
[user]
name = laily
email = i@laily.net
+26 -9
View File
@@ -21,23 +21,40 @@ warn_store_fallback() {
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
Darwin)
if command -v git-credential-osxkeychain >/dev/null 2>&1; then
exec git-credential-osxkeychain "$@"
fi
dotfile_git_credential_exec osxkeychain "$@" || true
warn_store_fallback "未找到 osxkeychain"
exec git-credential-store "$@"
dotfile_git_credential_exec store "$@" || true
;;
Linux)
if command -v git-credential-libsecret >/dev/null 2>&1; then
exec git-credential-libsecret "$@"
fi
dotfile_git_credential_exec libsecret "$@" || true
warn_store_fallback "未找到 git-credential-libsecret"
exec git-credential-store "$@"
dotfile_git_credential_exec store "$@" || true
;;
*)
warn_store_fallback "未知系统 $(uname -s)"
exec git-credential-store "$@"
dotfile_git_credential_exec store "$@" || true
;;
esac
echo "git credential helper 不可用" >&2
exit 1
+72
View File
@@ -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
+57
View File
@@ -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`
+106
View File
@@ -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
View File
@@ -1 +1,2 @@
# 跨机器共享。本机工具写在 ~/.config/mise/config.toml(mise use -g)
[tools]
+19 -19
View File
@@ -1,51 +1,51 @@
{
"FixCursorHold.nvim": { "branch": "master", "commit": "1900f89dc17c603eec29960f57c00bd9ae696495" },
"LazyVim": { "branch": "main", "commit": "fca0af57cc3851b14f96a795a9c9bfafc5096dd1" },
"SchemaStore.nvim": { "branch": "main", "commit": "4f1c58a59d3a4da095743dd6f04b73caa0cefc3d" },
"SchemaStore.nvim": { "branch": "main", "commit": "4a0e1b74096937d366031bc1e2b244546af39f8e" },
"avante.nvim": { "branch": "main", "commit": "e89eb79abf5754645e20aa6074da10ed20bba33c" },
"barbar.nvim": { "branch": "master", "commit": "53b5a2f34b68875898f0531032fbf090e3952ad7" },
"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" },
"catppuccin": { "branch": "main", "commit": "a7788416689dbefea659d9e9415c87926b0ba5d8" },
"catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" },
"codecompanion.nvim": { "branch": "main", "commit": "e0780fa9fda504ffb89307cabcb6cbe1ce8eb60c" },
"conform.nvim": { "branch": "master", "commit": "086a40dc7ed8242c03be9f47fbcee68699cc2395" },
"dashboard-nvim": { "branch": "master", "commit": "0775e567b6c0be96d01a61795f7b64c1758262f6" },
"diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
"dressing.nvim": { "branch": "master", "commit": "2d7c2db2507fa3c4956142ee607431ddb2828639" },
"flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
"flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" },
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
"fzf-lua": { "branch": "main", "commit": "c9e7b7bfbd01f949164988ee1684035468e1995c" },
"gemini-cli.nvim": { "branch": "main", "commit": "c9fd62adda823628f5131a939d9c56ef7a898600" },
"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" },
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
"lsp_signature.nvim": { "branch": "master", "commit": "af7e4074d85d785cf6614352ba9ad3b28a1f8a56" },
"lualine.nvim": { "branch": "master", "commit": "47f91c416daef12db467145e16bed5bbfe00add8" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "25f609e7fca78af7cede4f9fa3af8a94b1c4950b" },
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "40276c4df7e6bdce6801d6c035c6227f9115a855" },
"mason-nvim-dap.nvim": { "branch": "main", "commit": "9a10e096703966335bd5c46c8c875d5b0690dade" },
"mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" },
"mini.ai": { "branch": "main", "commit": "4b0a6207341d895b6cfe9bcb1e4d3e8607bfe4f4" },
"mini.icons": { "branch": "main", "commit": "5b9076dae1bfbe47ba4a14bc8b967cde0ab5d77e" },
"mini.pairs": { "branch": "main", "commit": "b7fde3719340946feb75017ef9d75edebdeb0566" },
"neo-tree.nvim": { "branch": "main", "commit": "84c75e7a7e443586f60508d12fc50f90d9aee14e" },
"mini.ai": { "branch": "main", "commit": "25248c6aa002391936a6200f12d1466015987133" },
"mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" },
"mini.pairs": { "branch": "main", "commit": "b1c5a726921b7a8c9321e9a7a208aa0571de5810" },
"neo-tree.nvim": { "branch": "main", "commit": "f3f3bf73414e400cf9fc13fda50f00404a8f8ab1" },
"neotest": { "branch": "master", "commit": "72bc8f1ec62a590fb47368c81f89610e0f353e28" },
"neotest-golang": { "branch": "main", "commit": "58d07a76d714f8f768bbe27711b06d41ca237728" },
"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-dap": { "branch": "master", "commit": "4f5deb110d9ff8994d96c21df95e2271d11214f9" },
"nvim-dap": { "branch": "master", "commit": "c9a0738e45f1bd41d792a126941348dce661cf9b" },
"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-lint": { "branch": "master", "commit": "4b03656c09c1561f89b6aa0665c15d292ba9499d" },
"nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" },
"nvim-lspconfig": { "branch": "master", "commit": "5492a24edd68f78d8a778edcb8905d0a28993abc" },
"nvim-nio": { "branch": "master", "commit": "21f5324bfac14e22ba26553caf69ec76ae8a7662" },
"nvim-tree.lua": { "branch": "master", "commit": "3603f069ff783c31986894a56c2a6755331b0802" },
"nvim-treesitter": { "branch": "main", "commit": "7caec274fd19c12b55902a5b795100d21531391f" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "93d60a475f0b08a8eceb99255863977d3a25f310" },
"nvim-ts-autotag": { "branch": "main", "commit": "8e1c0a389f20bf7f5b0dd0e00306c1247bda2595" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "5c7b0263797dfd1bd6202f2b219f3b53a80b2187" },
"nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" },
"nvim-web-devicons": { "branch": "master", "commit": "d7462543c9e366c0d196c7f67a945eaaf5d99414" },
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
"playground": { "branch": "master", "commit": "ba48c6a62a280eefb7c85725b0915e021a1a0749" },
@@ -56,10 +56,10 @@
"symbols-outline.nvim": { "branch": "master", "commit": "564ee65dfc9024bdde73a6621820866987cbb256" },
"telescope-file-browser.nvim": { "branch": "master", "commit": "3610dc7dc91f06aa98b11dca5cc30dfa98626b7e" },
"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" },
"tokyonight.nvim": { "branch": "main", "commit": "cdc07ac78467a233fd62c493de29a17e0cf2b2b6" },
"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" }
}
+15
View File
@@ -11,6 +11,7 @@ usage() {
备份已有 ~/.gitconfig,软链接到 ~/.dotfile/git/.gitconfig。
同时为 git-credential.sh 添加可执行权限。
若本机还没有 ~/.gitconfig.work,则生成空模板(~/workrepo 公司身份,需自行填写)。
凭据由 ~/.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-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
+69
View File
@@ -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
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# 安装 mise 并链接 dotfile 配置、安装 config.toml 中的工具
# 安装 mise,链接共享配置到 conf.d,安装工具
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
@@ -10,8 +10,9 @@ usage() {
用法: mise-install
1. 若未安装则通过官方脚本安装 mise
2. 链接 ~/.dotfile/mise/config.toml
3. 执行 mise install 安装配置中的工具
2. 链接 ~/.dotfile/mise/config.toml 到 ~/.config/mise/conf.d/dotfile.toml
3. 若全局 config.toml 仍指向仓库,则改为本机实文件(mise use -g 写这里)
4. 执行 mise install 安装配置中的工具
选项:
-h, --help 显示此帮助
@@ -20,18 +21,28 @@ EOF
dotfile_help_requested "${1:-}" && dotfile_show_help
set -e
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
mise_fail() {
echo -e "${RED}mise 安装失败,请检查错误信息${NC}" >&2
exit 1
}
if ! command -v mise &>/dev/null; then
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"
if ! command -v mise &>/dev/null; then
mise_fail
fi
if [ -f ~/.config/fish/config.fish ]; then
if ! grep -q "mise activate" ~/.config/fish/config.fish; then
echo -e "${YELLOW}正在为 fish shell 配置 mise...${NC}"
@@ -52,15 +63,31 @@ fi
MISE_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mise"
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 "$MISE_CONFIG_DIR/config.toml" ] && [ ! -L "$MISE_CONFIG_DIR/config.toml" ]; then
mv "$MISE_CONFIG_DIR/config.toml" "$MISE_CONFIG_DIR/config.toml.bak"
echo -e "${YELLOW}已备份原有配置到 config.toml.bak${NC}"
# 旧布局:全局 config.toml 软链进仓库,mise use -g 会弄脏 git
if [ -L "$MISE_GLOBAL_CONFIG" ] &&
[ "$(readlink -f "$MISE_GLOBAL_CONFIG")" = "$(readlink -f "$DOTFILE_MISE_CONFIG")" ]; then
rm -f "$MISE_GLOBAL_CONFIG"
echo -e "${YELLOW}已解开指向仓库的全局 config.toml 软链,改为本机文件${NC}"
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
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
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
echo -e "${GREEN}mise 安装成功!${NC}"
else
echo -e "${RED}mise 安装失败,请检查错误信息${NC}"
exit 1
mise_fail
fi
+76
View File
@@ -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
View File
@@ -11,6 +11,7 @@ usage() {
扫描局域网主机:
无参数 ICMP 扫描本网段 .1-.254,并尽力显示名称、MAC 和厂商
检测到多个 IPv4 网卡时会先让你选择扫描源
--identify 额外通过 mDNS 识别设备(macOS,约增加 3 秒)
10.0.0 扫描指定网段前缀
22 扫描本网段 TCP 22 端口
@@ -92,15 +93,101 @@ if [ -n "$1" ] && [ -z "$PORT_SPEC" ] && [ -z "$PREFIX_ARG" ]; then
exit 1
fi
if command -v ip &>/dev/null; then
LOCAL_IP=$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -n 1)
elif command -v ifconfig &>/dev/null; then
LOCAL_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)
else
echo "错误: 找不到 ip 或 ifconfig 命令" && exit 1
fi
list_ipv4_interfaces() {
if [ "$(uname -s)" = "Darwin" ] && 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
}
}
'
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
PREFIX="$PREFIX_ARG"
@@ -127,7 +214,8 @@ if [ -n "$PORT_SPEC" ]; then
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
echo "扫描端口: ${PORTS[*]} (TCP,超时 ${TIMEOUT}s)"
else
@@ -398,7 +486,7 @@ if [ ${#PORTS[@]} -eq 0 ]; then
for i in $(seq 1 254); do
(
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"
fi
) &
@@ -418,7 +506,7 @@ if ! command -v nc &>/dev/null; then
exit 1
fi
NC_OPTS=(-z -w "$TIMEOUT")
NC_OPTS=(-z -w "$TIMEOUT" -s "$LOCAL_IP")
if [ "$(uname -s)" = "Darwin" ]; then
NC_OPTS+=(-G "$TIMEOUT")
fi
+63 -10
View File
@@ -58,6 +58,17 @@ if [[ $NO_SUDO -eq 0 && $EUID -ne 0 ]]; then
exec sudo -- "$0"
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
WARN_COUNT=0
HIGH_COUNT=0
@@ -70,12 +81,12 @@ pass() {
warn() {
WARN_COUNT=$((WARN_COUNT + 1))
printf '[警告] %s\n' "$*"
printf '%s[警告] %s%s\n' "$COLOR_WARN" "$*" "$COLOR_RESET"
}
high() {
HIGH_COUNT=$((HIGH_COUNT + 1))
printf '[高风险] %s\n' "$*"
printf '%s[高风险] %s%s\n' "$COLOR_HIGH" "$*" "$COLOR_RESET"
}
info() {
@@ -103,11 +114,42 @@ find_sshd() {
}
check_sshd_setting() {
local setting="$1" value
value=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk -v key="$setting" '$1 == key { print $2; exit }')
local setting value
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"
}
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() {
local sshd_bin root_login password_login ssh_ports
@@ -121,6 +163,7 @@ check_ssh() {
SSHD_EFFECTIVE_CONFIG=$("$sshd_bin" -T 2>/dev/null)
if [[ -z "$SSHD_EFFECTIVE_CONFIG" ]]; then
warn "无法读取 sshd 生效配置;请使用 sudo 重试或检查 sshd 配置"
check_ssh_setting_sources permitrootlogin passwordauthentication
return
fi
@@ -132,14 +175,17 @@ check_ssh() {
no|forced-commands-only)
pass "SSH 已禁止 root 交互式登录(PermitRootLogin: $root_login)"
;;
prohibit-password)
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: prohibit-password)"
prohibit-password|without-password)
# without-password 是 prohibit-password 的旧别名,含义相同
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: $root_login)"
;;
yes)
high "SSH 允许 root 直接登录;建议设为 PermitRootLogin no"
check_ssh_setting_sources permitrootlogin
;;
*)
warn "无法确定 PermitRootLogin 的安全状态(当前: ${root_login:-未设置})"
check_ssh_setting_sources permitrootlogin
;;
esac
@@ -149,9 +195,11 @@ check_ssh() {
;;
yes)
warn "SSH 允许密码登录;公网服务器建议改用密钥认证"
check_ssh_setting_sources passwordauthentication
;;
*)
warn "无法确定 PasswordAuthentication 的安全状态(当前: ${password_login:-未设置})"
check_ssh_setting_sources passwordauthentication
;;
esac
@@ -381,11 +429,16 @@ check_privileged_accounts
check_recent_logins
printf '\n== 汇总 ==\n'
printf '通过: %d 警告: %d 高风险: %d 信息: %d\n' "$PASS_COUNT" "$WARN_COUNT" "$HIGH_COUNT" "$INFO_COUNT"
if ((HIGH_COUNT > 0)); then
echo "请优先处理“高风险”项。"
SUMMARY_COLOR="$COLOR_HIGH"
SUMMARY_ADVICE='请优先处理“高风险”项。'
elif ((WARN_COUNT > 0)); then
echo "建议逐项确认“警告”项是否符合服务器的实际部署方式。"
SUMMARY_COLOR="$COLOR_WARN"
SUMMARY_ADVICE='建议逐项确认“警告”项是否符合服务器的实际部署方式。'
else
echo "未发现需要立即处理的项。"
SUMMARY_COLOR=""
SUMMARY_ADVICE='未发现需要立即处理的项。'
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
View File
@@ -166,6 +166,21 @@ find_alias_file() {
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() {
mkdir -p "$SSH_CONFIG_DIR"
chmod 700 "$SSH_DIR" "$SSH_CONFIG_DIR"
@@ -177,17 +192,8 @@ ensure_config_include() {
return
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"
if ssh_config_has_toplevel_include "$SSH_CONFIG"; then
return
fi
@@ -198,18 +204,26 @@ ensure_config_include() {
cp -p "$SSH_CONFIG" "$backup"
awk -v include_line="$INCLUDE_LINE" '
BEGIN {
print "# Managed SSH config fragments"
print include_line
print ""
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 "已在 $SSH_CONFIG 中加入 config.d,并备份到 $backup"
echo "已在 $SSH_CONFIG 第一行加入 config.d Include,并备份到 $backup"
}
alias_name=""