Repository navigation
Expand file tree
/
Copy pathbootstrap
More file actions
executable file
·393 lines (354 loc) · 14.6 KB
/
Copy pathbootstrap
File metadata and controls
executable file
·393 lines (354 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#!/usr/bin/env bash
# =========================================================
# bootstrap — fresh Mac to working config
#
# ./bootstrap symlink configs, then report missing tools
# ./bootstrap --check report only, change nothing
#
# This repo IS $XDG_CONFIG_HOME (~/.config), so nvim, alacritty, tmux,
# starship and friends already sit at the paths their tools look in.
# Nothing to link for those. The only symlinks needed are the handful of
# files that must live outside ~/.config because their tool has no XDG
# support — that's the LINKS table below, and it should stay short.
#
# Idempotent: re-running is a no-op. Never clobbers a real file.
# =========================================================
set -uo pipefail
# Resolve the repo from this script's own location, not from $HOME/.config —
# so a clone sitting anywhere (~/dotfiles, a worktree) still links correctly.
CONFIG="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
# Everything below assumes Homebrew and real symlinks. Windows gets its
# own installer instead of a screenful of brew lines that cannot work.
case ${OSTYPE:-} in
msys*|cygwin*)
printf 'bootstrap is macOS/Linux only. On Windows run: make windows\n' >&2
printf '(scripts/windows-setup — scoop + MSYS2 zsh/tmux, see docs/windows.md)\n' >&2
exit 1 ;;
esac
# repo-relative source : absolute target
LINKS=(
"zsh/.zshrc:$HOME/.zshrc"
"tmux/tmux.conf:$HOME/.tmux.conf"
"git/gitconfig:$HOME/.gitconfig"
"ssh/config:$HOME/.ssh/config"
)
# Tools the configs actually reference. Missing ones degrade gracefully
# (.zshrc guards every integration with `command -v`), so this reports
# rather than installs — you stay in control of what lands on the machine.
BREW_FORMULAE=(bat delta eza fd fzf gh k9s lazydocker lazygit neovim ripgrep starship tmux tree-sitter-cli
zoxide zsh-autosuggestions zsh-syntax-highlighting)
# Tools the config uses that Homebrew is not necessarily the source of.
# Each can arrive by several routes -- a native installer, npm, pip,
# uv, cargo, a direct download -- so there is no single `brew install`
# line that fixes them and no `brew upgrade` that keeps them current.
#
# They get their own section because the failure mode is different.
# A missing tool announces itself; a tool installed three different
# ways on three machines does not, it just quietly runs three
# different versions. So this prints the resolved path, the route it
# implies, and the version -- run `make verify` on each machine and
# the versions are there to compare.
#
# label : binary : version flag : where to get it
EXTERNAL_TOOLS=(
"Claude Code:claude:--version:https://claude.com/claude-code (native installer)"
"mkdocs:mkdocs:--version:brew install mkdocs-material — or pip, hence the drift"
)
# Ghostty is the daily-driver terminal; Alacritty is the fallback. Only
# Ghostty can blur its background on macOS, which is the whole reason
# both are here — see ghostty/config.
BREW_CASKS=(ghostty alacritty font-jetbrains-mono-nerd-font)
# `./bootstrap --tools` prints the install lines straight from the
# arrays above, so there is one source of truth for what to install.
# The README used to carry its own copy of this list and drifted --
# it was missing gh and k9s while claiming to be in sync.
if [[ "${1:-}" == "--tools" ]]; then
printf 'brew install %s\n' "${BREW_FORMULAE[*]}"
printf 'brew install --cask %s\n' "${BREW_CASKS[*]}"
exit 0
fi
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
linked=0 created=0 skipped=0
link_one() {
local src="$CONFIG/$1" target="$2"
if [[ ! -e $src ]]; then
# ssh/config is gitignored, so it legitimately may not exist on a
# fresh clone. Not an error — just nothing to link.
dim " – ${1}: not in repo, nothing to link"
((skipped++))
return
fi
if [[ -L $target ]]; then
local current
current=$(readlink "$target")
if [[ $current == "$src" ]]; then
green " ✓ ${target/#$HOME/~} already linked"
((linked++))
return
fi
red " ! ${target/#$HOME/~} points elsewhere ($current)"
dim " remove it by hand if you want this repo's version"
((skipped++))
return
fi
if [[ -e $target ]]; then
# A real file, not a link. Could be the only copy of something —
# refuse to touch it and let the user decide.
red " ! ${target/#$HOME/~} is a real file — left alone"
dim " back it up and re-run, or merge it into $1 yourself"
((skipped++))
return
fi
if (( CHECK_ONLY )); then
dim " → would link ${target/#$HOME/~} -> ${src/#$HOME/~}"
((skipped++))
return
fi
mkdir -p "$(dirname "$target")"
ln -s "$src" "$target"
green " + linked ${target/#$HOME/~} -> ${src/#$HOME/~}"
((created++))
}
bold "Symlinks"
for entry in "${LINKS[@]}"; do
link_one "${entry%%:*}" "${entry#*:}"
done
# Homebrew's prefix differs per machine -- /opt/homebrew on Apple
# Silicon, /usr/local on Intel, /home/linuxbrew/.linuxbrew on Linux --
# and these tools are just as often installed by cargo, npm, a distro
# package or a curl|sh script. Hardcoding one layout makes this report
# confidently wrong in both directions: "missing" for something that is
# installed elsewhere, or a silent pass on a copy brew cannot upgrade.
BREW_PREFIX=${HOMEBREW_PREFIX:-}
if [[ -z $BREW_PREFIX ]]; then
for p in /opt/homebrew /usr/local /home/linuxbrew/.linuxbrew; do
[[ -x $p/bin/brew ]] && BREW_PREFIX=$p && break
done
fi
bold $'\nTools'
if [[ -n $BREW_PREFIX ]]; then
dim " homebrew prefix: $BREW_PREFIX"
else
red " ! no homebrew found — the install lines below will not work"
fi
missing=()
foreign=()
for tool in "${BREW_FORMULAE[@]}"; do
# Formula name and binary name differ for some; the two zsh plugins
# are sourced by path and never run as commands at all.
case $tool in
neovim) bin=nvim ;;
ripgrep) bin=rg ;;
*) bin=$tool ;;
esac
case $tool in
zsh-autosuggestions|zsh-syntax-highlighting)
# Checked in the same order .zshrc sources them, so this agrees
# with what the shell will actually find.
found=""
for d in ${BREW_PREFIX:+$BREW_PREFIX/share/$tool} \
"/usr/share/$tool" \
"$CONFIG/zsh/plugins/$tool"; do
[[ -n $d && -d $d ]] && found=$d && break
done
[[ -z $found ]] && missing+=("$tool")
# A plugin outside the brew prefix is fine — .zshrc finds it —
# but `brew upgrade` will never touch it, so say so.
[[ -n $found && -n $BREW_PREFIX && $found != "$BREW_PREFIX"/* ]] \
&& foreign+=("$tool → $found")
continue
;;
esac
resolved=$(command -v "$bin" 2>/dev/null)
if [[ -z $resolved ]]; then
missing+=("$tool")
continue
fi
# Installed, but not by the brew we found — cargo, npm, a direct
# download, or a system copy. Works, but is not brew-managed.
[[ -n $BREW_PREFIX && $resolved != "$BREW_PREFIX"/* ]] \
&& foreign+=("$bin → $resolved")
done
if (( ${#missing[@]} == 0 )); then
green " ✓ all ${#BREW_FORMULAE[@]} tools present"
else
red " ${#missing[@]} missing:"
printf ' brew install %s\n' "${missing[*]}"
fi
# Present but installed some other way. Not a problem to fix — just the
# thing you want to know before wondering why `brew upgrade` changed
# nothing, or why two machines run different versions of the same tool.
if (( ${#foreign[@]} > 0 )); then
dim " ${#foreign[@]} not managed by this homebrew:"
printf ' %s\n' "${foreign[@]}"
fi
bold $'\nCasks'
missing_casks=()
for cask in "${BREW_CASKS[@]}"; do
case $cask in
font-jetbrains-mono-nerd-font)
# Fonts land in either the user or the system font directory,
# depending on how they were installed.
if /bin/ls "$HOME/Library/Fonts" /Library/Fonts 2>/dev/null \
| grep -qi 'JetBrainsMonoNerdFont'; then
continue
fi
missing_casks+=("$cask")
continue
;;
ghostty) app=Ghostty.app ;;
alacritty) app=Alacritty.app ;;
*) app='' ;;
esac
# An app can be installed per-user as easily as system-wide, and a
# direct .dmg download lands wherever it was dragged. Check both of
# the standard locations before declaring it missing.
if [[ -n $app ]]; then
if [[ -d /Applications/$app || -d $HOME/Applications/$app ]]; then
continue
fi
fi
missing_casks+=("$cask")
done
if (( ${#missing_casks[@]} == 0 )); then
green " ✓ all ${#BREW_CASKS[@]} casks present"
else
red " ${#missing_casks[@]} missing:"
printf ' brew install --cask %s\n' "${missing_casks[*]}"
fi
# --- External tools --------------------------------------
# Classify an absolute binary path into the install route it implies.
# Inferred from location, which is the only evidence available --
# nothing records how a binary got there.
install_route() {
local p=$1
case $p in
"${BREW_PREFIX:-/nonexistent}"/*) echo "homebrew" ;;
"$HOME"/.local/bin/*) echo "native installer / pipx / uv" ;;
"$HOME"/.cargo/bin/*) echo "cargo" ;;
"$HOME"/.bun/bin/*) echo "bun" ;;
*) case $p in
*/node_modules/*|*/.npm*/*|*/.nvm/*) echo "npm" ;;
/usr/local/bin/*) echo "direct install or Intel brew" ;;
/usr/bin/*|/bin/*) echo "macOS system copy" ;;
*) echo "unknown" ;;
esac ;;
esac
}
bold $'\nExternal tools'
dim " not from this homebrew — compare versions across machines"
for entry in "${EXTERNAL_TOOLS[@]}"; do
label=${entry%%:*}; rest=${entry#*:}
bin=${rest%%:*}; rest=${rest#*:}
vflag=${rest%%:*}
hint=${rest#*:}
resolved=$(command -v "$bin" 2>/dev/null)
if [[ -z $resolved ]]; then
red " – $label: not found"
dim " $hint"
continue
fi
# Only the first line, and only the first version-ish token — these
# tools disagree wildly about what `--version` should print.
ver=$("$bin" $vflag 2>/dev/null | head -1 \
| grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
green " ✓ $label ${ver:-(version unknown)}"
dim " $resolved — $(install_route "$resolved")"
done
# --- Theme -----------------------------------------------
# theme/palettes/*.sh is the single source of truth for colours; the
# per-tool fragments (ghostty/theme.conf, alacritty/theme-current.toml,
# git/theme.gitconfig) are generated from it and gitignored, so a fresh
# clone has none of them. Generate them now, or Ghostty starts on its
# own default palette and delta falls back to bat's.
#
# This also fetches the tokyonight .tmTheme that bat needs, since bat
# ships Catppuccin and Solarized but not Tokyo Night.
bold $'\nTheme'
if [[ ! -x $CONFIG/scripts/theme ]]; then
red " ! scripts/theme missing or not executable"
elif (( CHECK_ONLY )); then
dim " → would run 'theme regen' for $("$CONFIG/scripts/theme" current)"
else
if "$CONFIG/scripts/theme" regen; then
green " ✓ generated theme files for $("$CONFIG/scripts/theme" current)"
dim " switch with: theme list / theme <name>"
else
red " ! theme regen failed"
fi
fi
# --- macOS key repeat ------------------------------------
# Holding j/k in Neovim on a stock Mac crawls one line at a time. Two
# separate causes, both per-user macOS defaults rather than files, so
# neither can live in this repo and `make install` cannot carry them
# between machines -- which is exactly how one machine ends up feeling
# broken while another is fine.
#
# KeyRepeat repeat interval; 1 is the fastest the UI
# offers, stock is 6 (~6x slower)
# InitialKeyRepeat delay before repeating starts
# ApplePressAndHoldEnabled when true, holding a key opens the accent
# picker INSTEAD of repeating -- this one is
# the real culprit, not the rate
#
# Reported, not written: these are system-wide and need a logout to
# apply, so they are yours to run. Same rule as the brew lists.
#
# NB: a green tick here only means the values are STORED. It cannot
# tell you the running login session is using them -- `defaults read`
# returns the new value the instant it is written, while the session
# keeps the old rate until you log out. Verified the hard way: an
# emulator launched 26 minutes after the write was still slow.
if [[ $OSTYPE == darwin* ]]; then
bold $'\nmacOS key repeat'
# key:target:description — flagged when unset, or slower than target.
macos_fixes=()
check_default() {
local key=$1 target=$2 desc=$3 current
current=$(defaults read -g "$key" 2>/dev/null)
if [[ -z $current ]]; then
macos_fixes+=("$key:$target:unset — $desc")
elif [[ $key == ApplePressAndHoldEnabled ]]; then
[[ $current != 0 ]] && macos_fixes+=("$key:$target:enabled — $desc")
elif (( current > target )); then
macos_fixes+=("$key:$target:$current — $desc")
fi
}
check_default KeyRepeat 1 "repeat interval"
check_default InitialKeyRepeat 15 "delay before repeat"
check_default ApplePressAndHoldEnabled 0 "accent picker steals the repeat"
if (( ${#macos_fixes[@]} == 0 )); then
green " ✓ key repeat values are stored"
dim " (stored ≠ active — if j/k still crawls, you have not logged out)"
else
red " ${#macos_fixes[@]} to fix — holding j/k in Neovim will crawl."
dim " FIRST: System Settings > Keyboard — drag 'Key Repeat Rate' to"
dim " the fastest notch and 'Delay Until Repeat' to the shortest."
dim " Writing the prefs without doing that does not take. Then run:"
for fix in "${macos_fixes[@]}"; do
key=${fix%%:*}; rest=${fix#*:}; target=${rest%%:*}; why=${rest#*:}
dim " $key ($why)"
if [[ $key == ApplePressAndHoldEnabled ]]; then
printf ' defaults write -g %s -bool false\n' "$key"
else
printf ' defaults write -g %s -int %s\n' "$key" "$target"
fi
done
dim " FINALLY: log out and back in. Relaunching the terminal is"
dim " not enough — macOS latches the rate for the login session."
fi
fi
bold $'\nSummary'
printf ' %d already linked, %d created, %d skipped\n' "$linked" "$created" "$skipped"
(( CHECK_ONLY )) && dim " (--check: nothing was changed)"
# ~/.zprofile is a real file managed by the Python installer, not this
# repo. Flag it so it doesn't silently shadow PATH changes in .zshrc.
if [[ -f $HOME/.zprofile && ! -L $HOME/.zprofile ]]; then
dim $'\n note: ~/.zprofile is unmanaged (Python installer wrote it) and'
dim ' prepends to PATH before .zshrc runs.'
fi