Capture your current install info

#!/bin/zsh
# Always run in zsh, even if started with "sh" or "bash"
if [ -z "$ZSH_VERSION" ]; then exec /bin/zsh "$0" "$@"; fi
# =====================================================================
# capture.sh - STEP 1. Run this on your OLD Mac.
#
# Creates two things in this folder:
# install-list.txt The ONE list of everything to put on the new Mac:
# apps, command-line tools, libraries, global
# packages, editor extensions and app settings.
# Delete any line you don't want.
# Settings/ Copies of the app settings listed below.
#
# Usage: zsh capture.sh
# =====================================================================
# ---- App settings to capture (edit freely) --------------------------
# One entry per app: "Name|path|path|..." Paths must start with ~
# "defaults:" saves an app's preferences file.
# Entries whose paths don't exist on this Mac are skipped automatically.
SETTINGS=(
"Final Cut Pro|~/Movies/Motion Templates.localized|~/Library/Application Support/ProApps|~/Library/Application Support/Final Cut Pro"
"Compressor|~/Library/Application Support/Compressor"
"Fonts|~/Library/Fonts"
"Audio plug-ins|~/Library/Audio/Plug-Ins"
"VS Code|~/Library/Application Support/Code/User/settings.json|~/Library/Application Support/Code/User/keybindings.json|~/Library/Application Support/Code/User/snippets"
"Cursor|~/Library/Application Support/Cursor/User/settings.json|~/Library/Application Support/Cursor/User/keybindings.json|~/Library/Application Support/Cursor/User/snippets"
"Git|~/.gitconfig|~/.gitignore_global"
"SSH config|~/.ssh/config"
)
# ---------------------------------------------------------------------
cd "${0:A:h}" || exit 1
HERE=$PWD
LIST="$HERE/install-list.txt"
WORK="$HERE/.capture"
rm -rf "$WORK"; mkdir -p "$WORK"
# Make sure tools installed by Homebrew, mise, cargo, uv and pipx are findable
[[ -x /opt/homebrew/bin/brew ]] && eval "$(/opt/homebrew/bin/brew shellenv)"
for d in "$HOME/.local/share/mise/shims" "$HOME/.cargo/bin" "$HOME/.local/bin"; do
[[ -d $d ]] && path=("$d" $path)
done
[[ -s $HOME/.nvm/nvm.sh ]] && ! command -v npm >/dev/null && source "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
have() { command -v "$1" >/dev/null 2>&1 }
plist() { /usr/libexec/PlistBuddy -c "Print :$2" "$1/Contents/Info.plist" 2>/dev/null }
emit() { printf '%-9s| %-34s| %s\n' "$2" "$3" "$4" >> "$WORK/$1.txt" } # section type key notes
if ! have jq; then
have brew && brew install jq >/dev/null 2>&1
have jq || { echo "This script needs jq. Install Homebrew, then run: brew install jq"; exit 1; }
fi
[[ -f $LIST ]] && cp "$LIST" "$HERE/install-list.previous.txt" && echo "Your previous list was saved as install-list.previous.txt"
# =====================================================================
print -P "\n%B1/4 Apps%b"
# =====================================================================
# Apps already installed through Homebrew
typeset -A CASK_FOR_APP FDESC API_CASK API_DESC SEEN_CASK MAS_ID
if have brew; then
brew info --json=v2 --installed > "$WORK/brew-installed.json" 2>/dev/null
while IFS= \t' read -r tok name desc; do
[[ -z $tok ]] && continue
SEEN_CASK[$tok]=1
emit cask cask "$tok" "$name${desc:+ - $desc}"
done < <(jq -r '.casks[]? | [.full_token, (.name[0] // .token), (.desc // "")] | @tsv' "$WORK/brew-installed.json")
while IFS= \t' read -r app tok; do
[[ -n $app ]] && CASK_FOR_APP[$app]=$tok
done < <(jq -r '.casks[]? | .full_token as $t | .artifacts[]? | select(type=="object" and has("app")) | .app[]
| (if type=="string" then . else (.target // empty) end) | split("/") | last | [., $t] | @tsv' "$WORK/brew-installed.json")
while IFS= \t' read -r n d; do
[[ -n $n ]] && FDESC[$n]=$d
done < <(jq -r '.formulae[]? | [.full_name, (.desc // "")] | @tsv' "$WORK/brew-installed.json")
fi
# Homebrew's full app catalogue, to find casks for apps you installed by hand
echo " Downloading Homebrew's app catalogue..."
if curl -fsSL https://formulae.brew.sh/api/cask.json -o "$WORK/cask.json"; then
while IFS= \t' read -r app tok desc; do
[[ -n $app && -z ${API_CASK[$app]} ]] && { API_CASK[$app]=$tok; API_DESC[$tok]=$desc; }
done < <(jq -r '.[] | .token as $t | (.desc // "") as $d | .artifacts[]? | select(type=="object" and has("app")) | .app[]
| (if type=="string" then . else (.target // empty) end) | split("/") | last | [., $t, $d] | @tsv' "$WORK/cask.json")
else
echo " (Couldn't download it. Apps you installed by hand will be listed under 'install by hand'.)"
fi
# App Store IDs, three ways: mas, Spotlight, then Apple's lookup service
if have mas; then
while IFS= \t' read -r n id; do
[[ -n $n ]] && MAS_ID[$n]=$id
done < <(mas list 2>/dev/null | awk '{ id=$1; l=$0; sub(/^[ \t]*[0-9]+[ \t]+/,"",l); sub(/[ \t]+\([^)]*\)[ \t]*$/,"",l); sub(/[ \t]+$/,"",l); if (id ~ /^[0-9]+$/) print tolower(l) "\t" id }')
fi
CC=$(defaults read -g AppleLocale 2>/dev/null | sed -nE 's/^[a-zA-Z]+_([A-Za-z]{2}).*/\1/p' | tr '[:upper:]' '[:lower:]')
[[ -z $CC ]] && CC=us
lookup_id() {
local b=$1 c id
for c in $CC us; do
id=$(curl -fsS "https://itunes.apple.com/lookup?bundleId=$b&country=$c&entity=desktopSoftware" 2>/dev/null \
| jq -r --arg b "$b" '[.results[]? | select(.bundleId==$b)][0].trackId // empty' 2>/dev/null)
sleep 3 # Apple allows about 20 lookups a minute
[[ -n $id ]] && { print -r -- "$id"; return }
[[ $c == us ]] && return
done
}
apps=( ${(f)"$(find /Applications ~/Applications -maxdepth 2 \( -path /Applications/Utilities -prune \) -o \( -name '*.app' -prune -print \) 2>/dev/null | sort -f)"} )
for app in $apps; do
file=${app:t}; name=${file%.app}
bid=$(plist "$app" CFBundleIdentifier)
receipt=0; [[ -d $app/Contents/_MASReceipt ]] && receipt=1
# Apple's own apps that come with macOS
(( ! receipt )) && [[ $bid == com.apple.* ]] && continue
note=""
exe=$(plist "$app" CFBundleExecutable)
archs=$(lipo -archs "$app/Contents/MacOS/$exe" 2>/dev/null)
[[ -n $archs && $archs != *arm64* ]] && note=" [Intel-only, needs Rosetta]"
if (( receipt )); then
id=${MAS_ID[${name:l}]}
if [[ -z $id ]]; then id=$(mdls -raw -name kMDItemAppStoreAdamID "$app" 2>/dev/null); [[ $id == "(null)" ]] && id=""; fi
if [[ -z $id && -n $bid ]]; then echo " Looking up App Store ID for $name..."; id=$(lookup_id "$bid"); fi
emit appstore appstore "${id:-?}" "$name$note"
continue
fi
[[ -n ${CASK_FOR_APP[$file]} ]] && continue # already listed as a Homebrew app
tok=${API_CASK[$file]}
if [[ -n $tok ]]; then
[[ -n ${SEEN_CASK[$tok]} ]] && continue
SEEN_CASK[$tok]=1
emit cask cask "$tok" "$name (you installed this by hand)${API_DESC[$tok]:+ - ${API_DESC[$tok]}}$note"
continue
fi
hint=""
[[ $app == */Setapp/* ]] && hint="install from the Setapp app"
if [[ -z $hint ]]; then
feed=$(plist "$app" SUFeedURL)
if [[ -n $feed ]]; then d=${feed#*://}; hint="get it from ${d%%/*}"; fi
fi
emit manual manual "$name" "${hint:-download from the developer's website}$note"
done
# =====================================================================
print -P "%B2/4 Command-line tools and libraries%b"
# =====================================================================
if have brew; then
brew tap 2>/dev/null | while read -r t; do
[[ -n $t && $t != homebrew/* ]] && emit tap tap "$t" "Homebrew source needed by some tools/apps"
done
brew leaves --installed-on-request 2>/dev/null | while read -r f; do
[[ -n $f ]] && emit brew brew "$f" "${FDESC[$f]}"
done
fi
# =====================================================================
print -P "%B3/4 Runtimes, global packages and editor extensions%b"
# =====================================================================
have mise && mise ls --global --no-header 2>/dev/null | awk 'NF>=2 {print $1"@"$2}' | while read -r m; do [[ -n $m ]] && emit mise mise "$m" ""; done
have npm && npm ls -g --depth=0 --json 2>/dev/null | jq -r '.dependencies // {} | keys[]' | grep -vxE 'npm|corepack' | while read -r p; do [[ -n $p ]] && emit npm npm "$p" ""; done
have uv && uv tool list 2>/dev/null | awk '/^[^- ]/ && $1 != "No" {print $1}' | while read -r p; do [[ -n $p ]] && emit uv uv "$p" ""; done
have pipx && pipx list --short 2>/dev/null | awk '{print $1}' | while read -r p; do [[ -n $p ]] && emit pipx pipx "$p" ""; done
have cargo && cargo install --list 2>/dev/null | awk '/^[^ ]/ {print $1}' | while read -r p; do [[ -n $p ]] && emit cargo cargo "$p" ""; done
CODE=$(command -v code 2>/dev/null); [[ -z $CODE ]] && CODE="/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
CURS=$(command -v cursor 2>/dev/null); [[ -z $CURS ]] && CURS="/Applications/Cursor.app/Contents/Resources/app/bin/cursor"
[[ -x $CODE ]] && "$CODE" --list-extensions 2>/dev/null | while read -r e; do [[ -n $e ]] && emit editors vscode "$e" ""; done
[[ -x $CURS ]] && "$CURS" --list-extensions 2>/dev/null | while read -r e; do [[ -n $e ]] && emit editors cursor "$e" ""; done
for d in .nvm .pyenv .rbenv .asdf .volta .sdkman; do
[[ -d $HOME/$d ]] && echo "# ~/$d exists. Versions installed with it aren't reinstalled automatically." >> "$WORK/notes.txt"
done
[[ -d $HOME/go/bin ]] && echo "# ~/go/bin exists. Go tools aren't reinstalled automatically." >> "$WORK/notes.txt"
# =====================================================================
print -P "%B4/4 App settings%b"
# =====================================================================
mkdir -p "$HERE/Settings"
for entry in $SETTINGS; do
sname=${entry%%|*}
spaths=( "${(@s:|:)${entry#*|}}" )
dest="$HERE/Settings/$sname"
rm -rf "$dest"
found=0
for p in $spaths; do
if [[ $p == defaults:* ]]; then
domain=${p#defaults:}
if defaults read "$domain" >/dev/null 2>&1; then
mkdir -p "$dest"; defaults export "$domain" "$dest/$domain.plist" && { echo "$p" >> "$dest/.manifest"; found=1; }
fi
elif [[ $p == "~/"* ]]; then
src="$HOME/${p#\~/}"
if [[ -e $src ]]; then
(( found )) || echo " Copying $sname..."
mkdir -p "$dest/files"
ditto "$src" "$dest/files/${p#\~/}" && { echo "$p" >> "$dest/.manifest"; found=1; }
fi
fi
done
if (( found )); then
emit settings settings "$sname" "$(du -sh "$dest" 2>/dev/null | awk '{print $1}') saved in Settings/$sname"
else
rm -rf "$dest"
fi
done
# =====================================================================
# Write install-list.txt
# =====================================================================
section() { # title file sort-field [explanation]
local f="$WORK/$2.txt"
[[ -s $f ]] || return
print "\n## $1 ($(wc -l < "$f" | tr -d ' '))"
[[ -n $4 ]] && print "# $4"
sort -t'|' -k"$3" -f -u "$f"
}
{
cat <<'EOF'
# =====================================================================
# INSTALL LIST - everything to put on your new Mac
#
# Delete any line you don't want. Lines starting with # are ignored.
# Then, on the new Mac, run: zsh install.sh
#
# Format: type | name or ID | notes
# =====================================================================
EOF
section "App Store apps" appstore 3 "A ? instead of an ID means it couldn't be found; it will be listed in the report."
section "Apps (installed with Homebrew)" cask 2
section "Apps to install by hand" manual 2 "No automatic installer exists for these. They're kept here as a checklist."
section "Command-line tools and libraries (Homebrew)" brew 2 "Only what you installed yourself. Their dependencies install automatically."
section "Homebrew sources (taps)" tap 2
section "Language runtimes (mise)" mise 2
section "Global npm packages" npm 2
section "Python tools (uv)" uv 2
section "Python tools (pipx)" pipx 2
section "Rust tools (cargo)" cargo 2
section "Editor extensions" editors 2
section "App settings to restore" settings 2
if [[ -s $WORK/notes.txt ]]; then print "\n## Notes (not installed automatically)"; cat "$WORK/notes.txt"; fi
} > "$LIST"
# =====================================================================
count() { grep -c "^$1 " "$LIST" }
unknown=$(grep -cE '^appstore \| \?' "$LIST")
print -P "\n%BDone.%b Saved %Binstall-list.txt%b:"
printf ' %-28s %s\n' "App Store apps" "$(count appstore)" "Homebrew apps" "$(count cask)" \
"Apps to install by hand" "$(count manual)" "Tools and libraries" "$(count brew)" \
"Global packages" "$(( $(count npm) + $(count uv) + $(count pipx) + $(count cargo) + $(count mise) ))" \
"Editor extensions" "$(( $(count vscode) + $(count cursor) ))" "App settings" "$(count settings)"
(( unknown )) && echo " ($unknown App Store app(s) had no ID found; they're marked with ?)"
cat <<'EOF'
Before you wipe this Mac:
1. Open install-list.txt and delete anything you don't want on the new Mac.
2. Export settings that can only be exported from inside an app, into this folder:
Raycast: Settings > Advanced > Export
BetterTouchTool: Presets > Export
Browsers: make sure your browser's sync is on (restores extensions)
3. Deactivate licenses that are limited to a number of Macs.
4. Copy this whole folder to an external drive or cloud storage.
EOF
open -e "$LIST" 2>/dev/null
参与讨论