cmd / laptop
I maintain a laptop repo that configures a macOS machine as a software development environment.
Install
export LAPTOP="$HOME/laptop"
git clone https://github.com/croaky/laptop.git $LAPTOP
cd $LAPTOP
./laptop.sh
The script links the dotfiles to the main clone, ~/laptop, also when I
run it from a change worktree. It asks git for the
common git directory of its checkout. For a worktree, the parent of that
directory is the main clone. So the links stay after the merge deletes
the worktree. A change to a dotfile takes effect after the merge and a
git pull in ~/laptop.
What it sets up
The script is idempotent. I run it on macOS on Apple Silicon. It:
- installs system packages with Homebrew
- sets the shell to zsh
- symlinks dotfiles to
$HOME - changes macOS settings and adds window shortcuts
- installs Go, Rust, and Go tools: deadcode, goimports, gopls
- installs tsgo and dprint as single binaries
- installs CLIs: awscli, bat, cb, fd, fzf, gh, git, ripgrep, tree, ubi
- installs my own commands, such as browse, from
main(see go / checks) - manages local apps and blocks trackers in
/etc/hostswith hostctl - sets up Neovim
- starts Postgres dev and test clusters
macOS settings
The script turns off all four hot corners. A hot corner set to Mission Control opens when a drag crosses a display corner. A dragged screenshot then cannot reach its window. The F3 key still opens Mission Control.
The script also turns off "Click wallpaper to reveal desktop". When this setting is on, a click on the wallpaper moves all windows to the screen edges. A screenshot drag that starts on the wallpaper or crosses it can do this.
The script writes each setting only when it has a different value. A
change in System Settings stays until the next run of laptop.sh.
Window shortcuts
Every macOS app has Left, Right, and Fill in its "Window > Move & Resize" menu. The script gives them keys as App Shortcuts, so I use no window manager app:
- Control-Option-Left: Left (left half)
- Control-Option-Right: Right (right half)
- Control-Option-Return: Fill (full screen area)
defaults write -g NSUserKeyEquivalents -dict-add \
"Left" '^~\UF702' \
"Right" '^~\UF703' \
"Fill" '^~\U21A9'
^ is Control and ~ is Option. defaults reads \UF702, \UF703,
and \U21A9 as the left arrow, right arrow, and return keys. An app
reads the shortcuts when it starts. Fill does not go back to the old
size. "Window > Move & Resize > Return to Previous Size" does that.
Editor formatting
Neovim formats Go with goimports and SQL with pgfmt on save. It runs the version that the file's module pins, the same version that the repo's checks run (see go / checks). So a version bump in a repo needs no change in this repo.
For SQL, Neovim runs go tool pgfmt in the directory of the file's
go.mod. A module that does not pin pgfmt gets no SQL formatting on
save. For Go, a script runs go tool -modfile=tools.mod goimports in a
library and go tool goimports in an app. A module that pins neither
gets the goimports that this script installs.
The repo commits Neovim's lazy-lock.json, so each machine loads the
same plugin commits.
No JavaScript runtime
The machine has no node, npm, or bun. tsgo, dprint, esbuild's Go API, and native language servers cover what those ran. See web / no js runtime for the native toolchain.
Hosts file
hostctl manages /etc/hosts for local development and DNS-level ad blocking.
laptop.sh runs it.
Local apps
Browsers treat http://localhost and http://*.localhost as
potentially trustworthy origins,
so Secure Context features work without TLS.
I run apps on different subdomains and ports
(site.localhost:2000, htmz.localhost:2002) to test
cross-origin security:
SameSite=StrictcookiesSec-Fetch-Site: same-originandOriginheadersReferrer-Policy: strict-origin-when-cross-originheader
Ad blocking
A hosts file blocks ads and trackers across every app on the laptop.
hostctl enables blocking by default:
hostctl
To disable blocking while keeping local app entries:
hostctl --unblock
Core script logic:
#!/bin/bash
set -euo pipefail
custom_hosts_entries() {
cat <<EOF
# macOS defaults
255.255.255.255 broadcasthost
# local apps
127.0.0.1 site.localhost # :2000
127.0.0.1 htmz.localhost # :2002
EOF
}
if [[ "${1:-}" == "--unblock" ]]; then
cat <<EOF | sudo tee /etc/hosts >/dev/null
127.0.0.1 localhost
::1 localhost
$(custom_hosts_entries)
EOF
else
curl -s https://winhelp2002.mvps.org/hosts.txt | tr -d '\r' | sudo tee /etc/hosts >/dev/null
(echo && custom_hosts_entries) | sudo tee -a /etc/hosts >/dev/null
fi
sudo killall -HUP mDNSResponder
The script holds the local app entries, so no other file has a copy.
Server configuration
A Go server binds to a port, and the hosts file resolves the subdomain:
fmt.Println("Serving at http://site.localhost:2000")
http.ListenAndServe(":2000", handler)
I use the 2000 port range for Go servers and 3000 for Ruby servers.