From 9d500c35077585de5294eb0e8e0db15d6389b420 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 02:03:11 -0700 Subject: [PATCH 01/20] Make initialization repeatable and bootstrap fresh machines safely --- .github/workflows/test-init.yml | 24 ++ .gitignore | 3 + README.md | 64 ++++-- fish/.config/fish/conf.d/00-homebrew.fish | 11 + fish/.config/fish/conf.d/fnm.fish | 4 +- fish/.config/fish/config.fish | 8 +- homebrew/Brewfile | 21 +- justfile | 78 ++----- scripts/init.sh | 268 ++++++++++++++++++++++ tests/fake_command.py | 134 +++++++++++ tests/test_init.py | 231 +++++++++++++++++++ 11 files changed, 751 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/test-init.yml create mode 100644 fish/.config/fish/conf.d/00-homebrew.fish create mode 100644 scripts/init.sh create mode 100644 tests/fake_command.py create mode 100644 tests/test_init.py diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml new file mode 100644 index 00000000..50a1733a --- /dev/null +++ b/.github/workflows/test-init.yml @@ -0,0 +1,24 @@ +name: Test setup +on: + push: + pull_request: +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install test dependencies (disposable runner only) + run: | + if [[ "$RUNNER_OS" == macOS ]]; then + brew install stow shellcheck fish + else + sudo apt-get update + sudo apt-get install -y stow shellcheck fish + fi + - run: bash -n scripts/init.sh && shellcheck scripts/init.sh + - run: python3 -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index 0ac742c6..1d483c04 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ pi/.pi/agent/* !pi/.pi/agent/settings.json !pi/.pi/agent/themes pi/.pi/agent/extensions/node_modules/ + +# Python setup-test bytecode +__pycache__/ diff --git a/README.md b/README.md index 7251be6c..08f69d27 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,63 @@ This is where I tweak config files till 4am like a goblin. It's pretty cozy in h - **[claude](https://code.claude.com/)** - **[fish](https://fishshell.com/)** -- **[gh-dash](https://www.gh-dash.dev/)** - **[homebrew](https://brew.sh/)** - **[neovim](https://neovim.io/)** - **[pi](https://github.com/badlogic/pi-mono)** - **[wezterm](https://wezterm.org/index.html)** - **[stow](https://www.gnu.org/software/stow/)** -- **[SwiftBar](https://github.com/swiftbar/SwiftBar)** - **[yazi](https://yazi-rs.github.io/)** ## Setup + +From a clone of this repo on macOS (primarily Apple Silicon): + ```bash -brew install just -just init +bash scripts/init.sh bootstrap ``` -### GitHub dashboard +The bootstrap installs Homebrew and `just` if missing, loads Homebrew's PATH, +then runs `just init`. Homebrew may request administrator access or Apple's +Command Line Tools; follow its instructions and rerun if installation stops. +A network connection is needed for missing packages. If Homebrew and `just` +are already available, run `just init` directly. + +Setup installs missing Brewfile packages without requesting upgrades of existing ones, +links configs without overwriting existing files, and installs extension dependencies. +Existing configuration conflicts stop with instructions to move/back up the files. +Repeated runs report what is already configured and only apply missing setup. +GitHub authentication and an interactive login-shell change can be deferred; +warnings include the exact commands to finish those steps. Fish is installed +before shell setup, and its actual executable path and account login shell are checked. +Run `just init-fish` in a terminal to allow any required `sudo`/`chsh` prompts. -`just init` installs gh-dash and gh-stack and links the dashboard configuration. On an existing machine, run -`just init-gh-extensions` and `just stow`, then restart Fish and Neovim. -Authenticate with `gh auth login` if needed. +Individual `init-*` recipes also install their missing tool dependencies. +Pi requires Node 22.19 or newer; an older active runtime produces a warning with +a follow-up command instead of attempting an incompatible install. +`just init-obscura` and `just init-tailscale-cli` remain optional. +Use `NO_COLOR=1 just init` for plain output, or `DOTS_NONINTERACTIVE=1 just init` +to explicitly defer login-shell changes. Failures retain command diagnostics; +a failed required step returns a nonzero status, while optional skipped steps +are counted in the final summary. Linux support is best-effort; macOS app setup +is skipped there. -Open the dashboard with `gh dash` (or `ghd` in Fish), or press `gd` in -Neovim for a floating terminal. Press `q` to quit and `?` for dashboard help. -The shared config includes your open PRs, review requests, and assigned issues. -Update the extension with `gh extension upgrade dash`. -The stacking extension is available as `gh stack`; update it with `gh extension upgrade stack`. +## Testing setup safely + +```bash +just test +# Without just: +python3 -m unittest discover -s tests -v +``` -### Insomnia +Tests copy the setup files into temporary directories with disposable homes. +External installers, authentication, shell changes, and macOS services are +replaced with stateful command doubles on a restricted PATH. Homebrew discovery +paths in the copied script are redirected into the fixture too. They exercise +fresh setup, repeated runs, missing prerequisites, and failures without touching +your packages, login shell, preferences, or running apps. A separate integration +test uses real GNU Stow when available, targeting only the disposable home. +CI runs the same tests on macOS and Linux. -On macOS, `just init` installs Insomnia in the menu bar and starts SwiftBar at login. -Run `just init-insomnia` to set up only Insomnia on an existing machine. -Click **Keep Mac Awake** in the eye menu, or press Control–Command–I. -The eye is outlined when off and filled when on. -Insomnia keeps the display and Mac awake until you turn it off. Its state resets after a reboot. +These tests validate orchestration, not live Homebrew downloads or macOS privilege +prompts. For a full installation smoke test, use a disposable macOS VM and run +`bash scripts/init.sh bootstrap` twice; do not use your daily account as a sandbox. diff --git a/fish/.config/fish/conf.d/00-homebrew.fish b/fish/.config/fish/conf.d/00-homebrew.fish new file mode 100644 index 00000000..3958a677 --- /dev/null +++ b/fish/.config/fish/conf.d/00-homebrew.fish @@ -0,0 +1,11 @@ +# Login shells on a fresh machine may not inherit Homebrew's PATH. +if command -q brew + brew shellenv fish | source +else + for brew_path in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew + if test -x $brew_path + $brew_path shellenv fish | source + break + end + end +end diff --git a/fish/.config/fish/conf.d/fnm.fish b/fish/.config/fish/conf.d/fnm.fish index dab58c67..18e21d64 100644 --- a/fish/.config/fish/conf.d/fnm.fish +++ b/fish/.config/fish/conf.d/fnm.fish @@ -1 +1,3 @@ -fnm env --use-on-cd --shell fish | source +if command -q fnm + fnm env --use-on-cd --shell fish | source +end diff --git a/fish/.config/fish/config.fish b/fish/.config/fish/config.fish index 6f5609f0..6560e98a 100644 --- a/fish/.config/fish/config.fish +++ b/fish/.config/fish/config.fish @@ -107,7 +107,9 @@ end source ~/.orbstack/shell/init.fish 2>/dev/null || : -zoxide init fish | source +if command -q zoxide + zoxide init fish | source +end alias j="z" source ~/.orbstack/shell/init2.fish 2>/dev/null || : @@ -128,7 +130,9 @@ end yes | fish_config theme save None function fnm_clean_up --on-event fish_exit - rm -r $FNM_MULTISHELL_PATH + if set -q FNM_MULTISHELL_PATH; and test -d "$FNM_MULTISHELL_PATH" + rm -r -- "$FNM_MULTISHELL_PATH" + end end function __source_local_config --on-variable PWD --description 'Source config.local.fish if present in current directory' diff --git a/homebrew/Brewfile b/homebrew/Brewfile index 95410247..e8c077a2 100644 --- a/homebrew/Brewfile +++ b/homebrew/Brewfile @@ -20,6 +20,7 @@ brew "lazygit" brew "lua" brew "modal" brew "neovim" +brew "node" brew "pkl" brew "pkl-lsp" brew "pi-coding-agent" @@ -35,12 +36,14 @@ brew "yazi" brew "yt-dlp" brew "zoxide" -cask "audacity" -cask "claude-code@latest" -cask "handy" -cask "orbstack" -cask "raycast" -cask "swiftbar" -cask "tamnd/tap/kage" -cask "vlc" -cask "wezterm@nightly" +if OS.mac? + cask "audacity" + cask "claude-code@latest" + cask "handy" + cask "orbstack" + cask "raycast" + cask "swiftbar" + cask "tamnd/tap/kage" + cask "vlc" + cask "wezterm@nightly" +end diff --git a/justfile b/justfile index e75eaf84..a0eb371c 100644 --- a/justfile +++ b/justfile @@ -1,79 +1,29 @@ -brew_prefix := if os() == "macos" { "/opt/homebrew" } else { "/home/linuxbrew/.linuxbrew" } - brew: - brew bundle install --file=homebrew/Brewfile + @bash scripts/init.sh brew -init: brew init-gh-extensions init-pi-extensions init-fish init-insomnia - @echo "✓ Initialization complete!" +init: + @bash scripts/init.sh init init-gh-extensions: - #!/usr/bin/env bash - set -euo pipefail - extensions=$(gh extension list) - for extension in dlvhdr/gh-dash github/gh-stack; do - if ! printf '%s\n' "$extensions" | grep -qE "(^|[[:space:]])${extension}([[:space:]]|$)"; then - gh extension install "$extension" - fi - done - stow -t "$HOME" gh-dash + @bash scripts/init.sh gh init-insomnia: - #!/usr/bin/env bash - set -euo pipefail - if [ "$(uname -s)" != Darwin ]; then echo "skip: macOS only"; exit 0; fi - if ! brew list --cask swiftbar >/dev/null 2>&1; then brew install --cask swiftbar; fi - stow -t "$HOME" swiftbar - defaults write com.ameba.SwiftBar PluginDirectory -string "$HOME/.config/swiftbar/plugins" - pkill -x SwiftBar 2>/dev/null || true - launchctl bootout "gui/$UID" "$HOME/Library/LaunchAgents/com.wu-json.swiftbar-login.plist" 2>/dev/null || true - launchctl bootstrap "gui/$UID" "$HOME/Library/LaunchAgents/com.wu-json.swiftbar-login.plist" + @bash scripts/init.sh insomnia init-fish: - grep -qxF "{{brew_prefix}}/bin/fish" /etc/shells || echo "{{brew_prefix}}/bin/fish" | sudo tee -a /etc/shells - chsh -s {{brew_prefix}}/bin/fish + @bash scripts/init.sh fish init-obscura: - #!/usr/bin/env bash - set -euo pipefail - obscura_version="0.1.8" - case "$(uname -s)" in - Darwin) os=macos ;; - Linux) os=linux ;; - *) echo "unsupported OS: $(uname -s)"; exit 1 ;; - esac - case "$(uname -m)" in - arm64|aarch64) arch=aarch64 ;; - x86_64) arch=x86_64 ;; - *) echo "unsupported arch: $(uname -m)"; exit 1 ;; - esac - asset="obscura-${arch}-${os}.tar.gz" - url="https://github.com/h4ckf0r0day/obscura/releases/download/v${obscura_version}/${asset}" - mkdir -p ~/.local/bin - tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT - echo "downloading $asset ..." - curl -fsL "$url" -o "$tmp/o.tar.gz" - tar xzf "$tmp/o.tar.gz" -C ~/.local/bin obscura obscura-worker - chmod +x ~/.local/bin/obscura ~/.local/bin/obscura-worker - # Strip Gatekeeper quarantine on macOS so the binary runs without a prompt. - [ "$os" = macos ] && xattr -d com.apple.quarantine ~/.local/bin/obscura ~/.local/bin/obscura-worker 2>/dev/null || true - echo "✓ installed $(~/.local/bin/obscura --version)" + @bash scripts/init.sh obscura -init-pi-extensions: stow - npm ci --prefix pi/.pi/agent/extensions +init-pi-extensions: + @bash scripts/init.sh pi init-tailscale-cli: - #!/usr/bin/env bash - set -euo pipefail - if [ "$(uname -s)" != Darwin ]; then echo "skip: macOS only"; exit 0; fi - if [ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then echo "Tailscale.app not found"; exit 1; fi - sudo rm -f /usr/local/bin/tailscale - fish -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' - echo "✓ Tailscale MacOS dir added to fish's universal PATH" + @bash scripts/init.sh tailscale stow: - stow -t ~ fish - stow -t ~ gh-dash - stow -t ~ nvim - stow -t ~ pi - stow -t ~ wezterm - stow -t ~ yazi + @bash scripts/init.sh stow + +test: + @python3 -m unittest discover -s tests -v diff --git a/scripts/init.sh b/scripts/init.sh new file mode 100644 index 00000000..aa7be44b --- /dev/null +++ b/scripts/init.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# Compatible with macOS's Bash 3.2. +set -Eeuo pipefail +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" +skipped=0 +green='' yellow='' blue='' reset='' +if [[ -t 1 && -z "${NO_COLOR+x}" && "${TERM:-dumb}" != dumb ]]; then + green=$'\033[32m' yellow=$'\033[33m' blue=$'\033[36m' reset=$'\033[0m' +fi +ok() { printf ' %s✓%s %s\n' "$green" "$reset" "$*"; } +warn() { printf ' %s!%s %s\n' "$yellow" "$reset" "$*"; skipped=$((skipped + 1)); } +section() { printf '\n%s%s%s\n' "$blue" "$*" "$reset"; } +fail() { printf ' ✗ %s\n' "$*" >&2; exit 1; } +trap 'printf " ✗ Setup stopped. Fix the error above, then rerun the same command.\n" >&2' ERR + +# Quiet on success, full diagnostics on failure. +run() { + local label=$1 log + shift + printf ' → %s\n' "$label" + log=$(mktemp) + if "$@" >"$log" 2>&1; then + rm -f "$log" + ok "$label" + else + cat "$log" >&2 + rm -f "$log" + fail "$label failed. Fix the error above and rerun." + fi +} +need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required. $2"; } + +ensure_brew() { + local candidate installer shellenv + if ! command -v brew >/dev/null 2>&1; then + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi + done + fi + if ! command -v brew >/dev/null 2>&1; then + section 'Homebrew' + need curl 'Install curl, then rerun setup.' + installer=$(mktemp) + if ! curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$installer"; then + rm -f "$installer" + fail 'Could not download Homebrew. Check your network connection and rerun.' + fi + # Keep installer prerequisites and privilege prompts visible. + if ! /bin/bash "$installer"; then + rm -f "$installer" + fail 'Homebrew installation failed. Complete the prerequisites printed above and rerun.' + fi + rm -f "$installer" + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi + done + need brew 'Follow the Homebrew shellenv instructions, then rerun.' + fi + shellenv=$(brew shellenv bash) + eval "$shellenv" + export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 +} +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + ensure_brew + run "Install $2 (provides $1)" brew install "$2" + need "$1" "Homebrew installed $2 but $1 is not on PATH; check brew shellenv." + fi +} +init_brew() { + section 'Packages' + ensure_brew + if brew bundle check --no-upgrade --file="$ROOT/homebrew/Brewfile" >/dev/null 2>&1; then + ok 'All Brewfile packages already installed' + else + # Casks can prompt for passwords: leave this command attached to the terminal. + printf ' → Install missing Brewfile packages\n' + brew bundle install --no-upgrade --file="$ROOT/homebrew/Brewfile" + ok 'Brewfile packages installed' + fi +} +link_configs() { + require_tool stow stow + local preview + preview=$(mktemp) + # Check the entire group before applying links; never overwrite/adopt files. + if ! stow --dir="$ROOT" --target="$HOME" --simulate --verbose "$@" >"$preview" 2>&1; then + cat "$preview" >&2 + rm -f "$preview" + fail 'Dotfile conflicts: move or back up the listed files, then rerun. Existing files were preserved.' + fi + if grep -qE '(LINK|UNLINK|MKDIR|RMDIR):' "$preview"; then + rm -f "$preview" + run "Link configs ($*)" stow --dir="$ROOT" --target="$HOME" "$@" + else + rm -f "$preview" + ok "Configs already linked ($*)" + fi +} +init_stow() { section 'Dotfiles'; link_configs fish gh-dash nvim pi wezterm yazi; } +init_gh() { + section 'GitHub extensions' + require_tool gh gh + require_tool git git + link_configs gh-dash + if ! gh auth status --hostname github.com >/dev/null 2>&1; then + warn 'GitHub extensions skipped: run gh auth login, then just init-gh-extensions' + return + fi + local extensions extension + extensions=$(gh extension list) + for extension in dlvhdr/gh-dash github/gh-stack; do + if printf '%s\n' "$extensions" | grep -qE "(^|[[:space:]])${extension}([[:space:]]|$)"; then + ok "$extension already installed" + else + run "Install $extension" gh extension install "$extension" + fi + done +} +init_pi() { + section 'Pi extensions' + require_tool node node + require_tool npm node + # Minimum required by the locked Pi coding-agent dependency. + if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)'; then + warn 'Pi extensions skipped: Node >=22.19 is required. Run brew upgrade node (or select a newer runtime with fnm), then just init-pi-extensions' + return + fi + local dir fingerprint stamp + dir="$ROOT/pi/.pi/agent/extensions" + stamp="$dir/node_modules/.dots-install" + fingerprint=$(cat "$dir/package.json" "$dir/package-lock.json" | cksum) + fingerprint="$fingerprint $(node --version) $(npm --version) $(uname -sm)" + if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then + ok 'Pi dependencies already installed (manifests and runtime unchanged)' + else + run 'Install Pi dependencies' npm ci --prefix "$dir" --include=dev --no-audit --no-fund + printf '%s\n' "$fingerprint" > "$stamp" + fi + link_configs pi +} +init_fish() { + section 'Login shell' + require_tool fish fish + local fish_path current_shell username + fish_path=$(command -v fish) + username=$(id -un) + case "$(uname -s)" in + Darwin) + need dscl 'Use System Settings to change your login shell.' + current_shell=$(dscl . -read "/Users/$username" UserShell | awk '{print $2}') ;; + Linux) + need getent 'Install getent or change your login shell manually.' + current_shell=$(getent passwd "$username" | cut -d: -f7) ;; + *) warn 'Automatic login shell setup is unsupported on this OS'; return ;; + esac + [[ -n "$current_shell" ]] || fail 'Could not determine the account login shell.' + if [[ "$current_shell" == "$fish_path" ]] && grep -qxF "$fish_path" /etc/shells; then + ok 'Fish is already the registered login shell' + return + fi + if [[ ! -t 0 || "${DOTS_NONINTERACTIVE:-0}" == 1 ]]; then + warn 'Login shell change skipped: run just init-fish in an interactive terminal' + return + fi + need chsh 'Install chsh or change your login shell through system settings.' + if ! grep -qxF "$fish_path" /etc/shells; then + need sudo 'Register the Fish path in /etc/shells as an administrator, then rerun.' + printf '%s\n' "$fish_path" | sudo tee -a /etc/shells >/dev/null + ok 'Fish registered in /etc/shells' + fi + if [[ "$current_shell" != "$fish_path" ]]; then + chsh -s "$fish_path" + ok 'Login shell changed to Fish (takes effect on next login)' + fi +} +init_insomnia() { + section 'SwiftBar / Insomnia' + if [[ "$(uname -s)" != Darwin ]]; then warn 'SwiftBar skipped: macOS only'; return; fi + ensure_brew + if ! brew list --cask swiftbar >/dev/null 2>&1; then + run 'Install SwiftBar' brew install --cask swiftbar + else + ok 'SwiftBar already installed' + fi + link_configs swiftbar + local plugins domain plist reload=0 + plugins="$HOME/.config/swiftbar/plugins" + domain="gui/$(id -u)" + plist="$HOME/Library/LaunchAgents/com.wu-json.swiftbar-login.plist" + need defaults 'Run this recipe on macOS.' + need launchctl 'Run this recipe in a macOS desktop session.' + if [[ "$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null || true)" != "$plugins" ]]; then + run 'Set SwiftBar plugin directory' defaults write com.ameba.SwiftBar PluginDirectory -string "$plugins" + reload=1 + else + ok 'SwiftBar plugin directory already configured' + fi + if ! launchctl print "$domain" >/dev/null 2>&1; then + warn 'SwiftBar launch skipped: no GUI session; rerun just init-insomnia from the desktop' + return + fi + if ! launchctl print "$domain/com.wu-json.swiftbar-login" >/dev/null 2>&1; then + run 'Register SwiftBar login agent' launchctl bootstrap "$domain" "$plist" + else + ok 'SwiftBar login agent already registered' + fi + if [[ "$reload" == 1 ]] && pgrep -x SwiftBar >/dev/null 2>&1; then + run 'Quit SwiftBar to apply its plugin directory' osascript -e 'tell application "SwiftBar" to quit' + fi + if ! pgrep -x SwiftBar >/dev/null 2>&1; then + run 'Start SwiftBar' open -a SwiftBar + else + ok 'SwiftBar already running' + fi +} +init_obscura() { + section 'Obscura' + local version=0.1.8 os arch asset tmp + if [[ -x "$HOME/.local/bin/obscura" && -x "$HOME/.local/bin/obscura-worker" ]] && + [[ "$("$HOME/.local/bin/obscura" --version)" == "obscura $version" ]]; then + ok "Obscura $version already installed" + return + fi + need curl 'Install curl, then rerun just init-obscura.' + need tar 'Install tar, then rerun just init-obscura.' + case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) fail 'Unsupported OS' ;; esac + case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64) arch=x86_64 ;; *) fail 'Unsupported architecture' ;; esac + asset="obscura-${arch}-${os}.tar.gz" + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + run "Download Obscura $version" curl -fsSL "https://github.com/h4ckf0r0day/obscura/releases/download/v${version}/${asset}" -o "$tmp/obscura.tar.gz" + tar xzf "$tmp/obscura.tar.gz" -C "$tmp" obscura obscura-worker + mkdir -p "$HOME/.local/bin" + run 'Install Obscura binaries' install -m 755 "$tmp/obscura" "$tmp/obscura-worker" "$HOME/.local/bin/" + rm -rf "$tmp" + trap - EXIT +} +init_tailscale() { + section 'Tailscale CLI' + if [[ "$(uname -s)" != Darwin ]]; then warn 'Tailscale app integration skipped: macOS only'; return; fi + if [[ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then + warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just init-tailscale-cli' + return + fi + require_tool fish fish + # Leave existing /usr/local/bin executables alone. + # shellcheck disable=SC2016 # Expanded by Fish, not Bash. + if fish --no-config -c 'contains -- /Applications/Tailscale.app/Contents/MacOS $fish_user_paths'; then + ok 'Tailscale app directory already on Fish PATH' + else + run 'Add Tailscale app directory to Fish PATH' fish --no-config -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' + fi +} +case "${1:-init}" in + bootstrap) ensure_brew; require_tool just just; exec just --justfile "$ROOT/justfile" init ;; + init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; + brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; + fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; + *) fail "Unknown setup task: $1" ;; +esac +printf '\n' +if [[ "$skipped" -gt 0 ]]; then + ok "Setup finished with $skipped skipped step(s); follow the instructions above." +else + ok 'Setup complete.' +fi diff --git a/tests/fake_command.py b/tests/fake_command.py new file mode 100644 index 00000000..157acc10 --- /dev/null +++ b/tests/fake_command.py @@ -0,0 +1,134 @@ +"""Stateful external-command doubles. This file never calls a real command.""" +import json +import os +from pathlib import Path +import sys + +name = Path(sys.argv[0]).name +args = sys.argv[1:] +state_dir = Path(os.environ['TEST_STATE']) +state_file = state_dir / 'state.json' +state = json.loads(state_file.read_text()) +with (state_dir / 'calls.jsonl').open('a') as log: + log.write(json.dumps([name, *args]) + '\n') + + +def done(code=0, output=''): + state_file.write_text(json.dumps(state)) + if output: + print(output) + sys.exit(code) + + +if state.get('fail') == name: + done(1, 'simulated dependency failure') +if name == 'curl' and state.get('install_homebrew'): + installer = Path(args[args.index('-o') + 1]) + installer.write_text('ln -s "$TEST_STATE/driver" "$PATH/brew"\n') + done() +if name == 'brew': + if args in (['shellenv', 'bash'], ['shellenv', 'fish']): + done() + if args[:2] == ['bundle', 'check']: + done(0 if state.get('bundle') else 1) + if args[:2] == ['bundle', 'install']: + state['bundle'] = True + state['swiftbar'] = True + done() + if args[:2] == ['list', '--cask']: + done(0 if state.get('swiftbar') else 1) + if args[0] == 'install': + package = args[-1] + if package == 'swiftbar': + state['swiftbar'] = True + else: + for tool in (['node', 'npm'] if package == 'node' else [package]): + destination = Path(os.environ['PATH']) / tool + if not destination.exists(): + destination.symlink_to(state_dir / 'driver') + done() +if name == 'uname': + done(output={'-s': state.get('os', 'Darwin'), '-m': 'arm64', '-sm': 'Darwin arm64'}[args[0]]) +if name == 'id': + done(output='tester' if args == ['-un'] else '501') +if name in ('dscl', 'getent'): + shell = state.get('shell', '/bin/zsh') + done(output='UserShell: ' + shell if name == 'dscl' else 'tester:x:501:20::/tmp:' + shell) +if name == 'grep': + # Only /etc/shells is mocked; the other patterns are handled in Python. + if args[-1] == '/etc/shells': + done(0 if state.get('registered') else 1) + import re + if args[-1].startswith('('): + content = sys.stdin.read() + pattern = args[-1] + else: + content = Path(args[-1]).read_text() + pattern = args[-2] + pattern = pattern.replace('[[:space:]]', r'\s') + done(0 if re.search(pattern, content) else 1) +if name == 'sudo': + if args[:2] == ['tee', '-a'] and args[2:] == ['/etc/shells']: + sys.stdin.read() + state['registered'] = True + done() +if name == 'chsh': + state['shell'] = args[-1] + done() +if name == 'gh': + if args[:2] == ['auth', 'status']: + done(0 if state.get('auth', True) else 1) + if args == ['extension', 'list']: + done(output='\n'.join(state.get('extensions', []))) + if args[:2] == ['extension', 'install']: + state.setdefault('extensions', []).append(args[-1]) + done() +if name == 'stow': + packages = [a for a in args if not a.startswith('--')] + if state.get('conflict'): + done(1, 'existing target is neither a link nor a directory: .config/fish/config.fish') + if '--simulate' in args: + done(output='LINK: example => repo/example' if any(p not in state.get('linked', []) for p in packages) else '') + state['linked'] = sorted(set(state.get('linked', []) + packages)) + done() +if name == 'node': + if args[0] == '-e': + done(1 if state.get('old_node') else 0) + done(output='v24.0.0') +if name == 'npm': + if args == ['--version']: + done(output='11.0.0') + if args[0] == 'ci': + (Path(args[args.index('--prefix')+1]) / 'node_modules').mkdir(exist_ok=True) + state['npm'] = True + done() + if args[0] == 'ls': + done(0 if state.get('npm') else 1) +if name == 'defaults': + if args[0] == 'read': + done(0 if state.get('plugins') else 1, state.get('plugins', '')) + state['plugins'] = args[-1] + done() +if name == 'launchctl': + if args[0] == 'print': + done(0 if (state.get('agent') if args[1].count('/') == 2 else state.get('gui', True)) else 1) + if args[0] == 'bootstrap': + state['agent'] = True + done() +if name == 'pgrep': + done(0 if state.get('running') else 1) +if name == 'osascript': + state['running'] = False + done() +if name == 'open': + state['running'] = True + done() +if name == 'fish': + if 'contains' in args[-1]: + done(0 if state.get('fish_path') else 1) + state['fish_path'] = True + done() +if name == 'just': + done() # bootstrap handoff, never run a real installer +# Fail closed for all unexpected invocations, including network commands. +done(97, f'UNEXPECTED COMMAND: {name} {args}') diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 00000000..1956f7be --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,231 @@ +import json +import os +from pathlib import Path +import pty +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = 'brew uname id dscl getent grep sudo chsh gh git stow node npm defaults launchctl pgrep osascript open fish just curl'.split() + + +class SetupTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='dots-test-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.repo = self.base / 'repo with spaces' + self.repo.mkdir() + for item in ('scripts', 'homebrew', 'pi'): + shutil.copytree(ROOT / item, self.repo / item, ignore=shutil.ignore_patterns('node_modules', '__pycache__')) + shutil.copy(ROOT / 'justfile', self.repo) + # Redirect fallback Homebrew discovery inside the copied script as well; + # even tests with brew absent must never find the host installation. + script = self.repo / 'scripts/init.sh' + content = script.read_text() + for index, prefix in enumerate(('/opt/homebrew', '/usr/local', '/home/linuxbrew/.linuxbrew')): + content = content.replace(prefix + '/bin/brew', str(self.base / f'prefix{index}/bin/brew')) + script.write_text(content) + self.home = self.base / 'home' + self.home.mkdir() + self.bin = self.base / 'bin' + self.bin.mkdir() + self.state_file = self.base / 'state.json' + self.state_file.write_text('{}') + driver = self.base / 'driver' + driver.write_text(f'#!{sys.executable}\n' + (ROOT / 'tests/fake_command.py').read_text()) + driver.chmod(0o755) + for tool in TOOLS: + (self.bin / tool).symlink_to(driver) + # Only safe, local utilities can escape the doubles. No real package + # manager, network client, sudo, shell changer, or desktop tool on PATH. + for tool in ('bash', 'dirname', 'mktemp', 'rm', 'cat', 'awk', 'cut', 'cksum', 'mkdir', 'tar', 'install', 'ln'): + (self.bin / tool).symlink_to(shutil.which(tool)) + self.env = {'PATH': str(self.bin), 'HOME': str(self.home), 'TEST_STATE': str(self.base), 'NO_COLOR': '1', 'LC_ALL': 'C', 'TMPDIR': str(self.base)} + + def state(self, **updates): + state = json.loads(self.state_file.read_text()) + state.update(updates) + self.state_file.write_text(json.dumps(state)) + return state + + def calls(self): + path = self.base / 'calls.jsonl' + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def run_setup(self, task='init', tty=False, success=True): + command = ['/bin/bash', str(self.repo / 'scripts/init.sh'), task] + if tty: + master, slave = pty.openpty() + try: + result = subprocess.run(command, env=self.env, stdin=slave, capture_output=True, text=True, timeout=30) + finally: + os.close(master) + os.close(slave) + else: + result = subprocess.run(command, env=self.env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result.stdout + result.stderr + + def test_fresh_init_then_rerun_has_no_mutations(self): + first = self.run_setup(tty=True) + self.assertIn('Login shell changed', first) + before = len(self.calls()) + second = self.run_setup(tty=True) + self.assertIn('already', second) + self.assertNotIn('→', second) + self.assertNotIn('\x1b', second) + forbidden = {'sudo', 'chsh', 'open', 'osascript'} + for call in self.calls()[before:]: + self.assertNotIn(call[0], forbidden, call) + self.assertFalse(call[:2] in [['npm', 'ci'], ['brew', 'install'], ['defaults', 'write'], ['launchctl', 'bootstrap']], call) + self.assertNotEqual(call[:3], ['gh', 'extension', 'install']) + if call[0] == 'stow': + self.assertIn('--simulate', call) + self.assertTrue(any(c[:3] == ['brew', 'bundle', 'install'] and '--no-upgrade' in c for c in self.calls())) + + def test_fish_installs_missing_dependency_and_only_changes_once(self): + (self.bin / 'fish').unlink() + self.run_setup('fish', tty=True) + self.assertIn(['brew', 'install', 'fish'], self.calls()) + self.assertEqual(self.state()['shell'], str(self.bin / 'fish')) + before = len(self.calls()) + self.run_setup('fish', tty=True) + self.assertFalse(any(c[0] in ('sudo', 'chsh') for c in self.calls()[before:])) + + def test_fish_uses_account_shell_not_stale_environment(self): + self.env['SHELL'] = str(self.bin / 'fish') + self.run_setup('fish', tty=True) + self.assertTrue(any(c[0] == 'chsh' for c in self.calls())) + + def test_unauthed_headless_init_continues_with_instructions(self): + self.state(auth=False, gui=False) + output = self.run_setup() + for text in ('gh auth login', 'just init-fish', 'no GUI session', '3 skipped'): + self.assertIn(text, output) + self.assertTrue(self.state()['npm']) + self.assertFalse(any(c[0] in ('sudo', 'chsh', 'open') for c in self.calls())) + + def test_old_node_warns_without_attempting_npm_install(self): + self.state(old_node=True) + output = self.run_setup('pi') + self.assertIn('Node >=22.19', output) + self.assertIn('just init-pi-extensions', output) + self.assertFalse(any(c[:2] == ['npm', 'ci'] for c in self.calls())) + + def test_missing_node_installed_for_standalone_pi(self): + (self.bin / 'node').unlink() + (self.bin / 'npm').unlink() + self.run_setup('pi') + self.assertIn(['brew', 'install', 'node'], self.calls()) + + def test_pi_manifest_change_and_missing_dependencies_reinstall(self): + self.run_setup('pi') + manifest = self.repo / 'pi/.pi/agent/extensions/package.json' + manifest.write_text(manifest.read_text() + '\n') + self.run_setup('pi') + self.state(npm=False) + self.run_setup('pi') + self.assertEqual(sum(c[:2] == ['npm', 'ci'] for c in self.calls()), 3) + + def test_failure_reports_diagnostics_and_can_retry(self): + self.state(fail='npm') + output = self.run_setup('pi', success=False) + self.assertIn('simulated dependency failure', output) + self.assertFalse((self.repo / 'pi/.pi/agent/extensions/node_modules/.dots-install').exists()) + self.state(fail=None) + self.run_setup('pi') + + def test_conflict_preserves_files_and_stops(self): + self.state(conflict=True) + output = self.run_setup('stow', success=False) + self.assertIn('move or back up', output) + self.assertTrue(all('--simulate' in c for c in self.calls() if c[0] == 'stow')) + + def test_missing_sudo_has_actionable_error(self): + (self.bin / 'sudo').unlink() + output = self.run_setup('fish', tty=True, success=False) + self.assertIn('Register the Fish path in /etc/shells', output) + + def test_explicit_noninteractive_does_not_change_shell(self): + self.env['DOTS_NONINTERACTIVE'] = '1' + self.assertIn('skipped', self.run_setup('fish', tty=True)) + self.assertFalse(any(c[0] in ('sudo', 'chsh') for c in self.calls())) + + def test_linux_skips_desktop_recipes(self): + self.state(os='Linux') + self.assertIn('macOS only', self.run_setup('insomnia')) + self.assertIn('macOS only', self.run_setup('tailscale')) + + def test_bootstrap_without_homebrew_or_just(self): + (self.bin / 'brew').unlink() + (self.bin / 'just').unlink() + self.state(install_homebrew=True) + self.run_setup('bootstrap') + self.assertIn(['brew', 'install', 'just'], self.calls()) + self.assertTrue(any(c[0] == 'curl' for c in self.calls())) + self.assertTrue(any(c[0] == 'just' for c in self.calls())) + + def test_bootstrap_download_failure_explains_recovery(self): + (self.bin / 'brew').unlink() + self.state(fail='curl') + self.assertIn('Check your network connection and rerun', self.run_setup('bootstrap', success=False)) + self.assertFalse(any(c[0] == 'just' for c in self.calls())) + + def test_bootstrap_hands_off_to_just(self): + self.run_setup('bootstrap') + self.assertIn(['just', '--justfile', str(self.repo / 'justfile'), 'init'], self.calls()) + + def test_obscura_matching_version_does_not_download(self): + target = self.home / '.local/bin' + target.mkdir(parents=True) + for tool in ('obscura', 'obscura-worker'): + binary = target / tool + binary.write_text('#!/bin/bash\nprintf "obscura 0.1.8\\n"\n') + binary.chmod(0o755) + self.assertIn('already installed', self.run_setup('obscura')) + self.assertFalse(any(c[0] == 'curl' for c in self.calls())) + + @unittest.skipUnless(shutil.which('fish'), 'Install Fish to test startup prerequisites') + def test_fish_startup_with_missing_optional_tools(self): + config_dir = ROOT / 'fish/.config/fish/conf.d' + result = subprocess.run( + [shutil.which('fish'), '--no-config', '-c', + 'source "$argv[1]/00-homebrew.fish"; source "$argv[1]/fnm.fish"', str(config_dir)], + env=self.env, cwd=self.home, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, '') + self.assertIn(['brew', 'shellenv', 'fish'], self.calls()) + + @unittest.skipUnless(shutil.which('stow'), 'Install GNU Stow to run real symlink integration') + def test_real_stow_rerun_and_conflict(self): + for package in ('fish', 'gh-dash', 'nvim', 'wezterm', 'yazi'): + shutil.copytree(ROOT / package, self.repo / package) + (self.bin / 'stow').unlink() + (self.bin / 'stow').symlink_to(shutil.which('stow')) + self.run_setup('stow') + config = self.home / '.config/fish/config.fish' + self.assertTrue(config.exists()) + self.assertEqual(config.resolve(), (self.repo / 'fish/.config/fish/config.fish').resolve()) + self.assertIn('already linked', self.run_setup('stow')) + # Unfold the linked directory into independent files before creating a conflict. + fish_dir = self.home / '.config/fish' + if (self.home / '.config').is_symlink(): + target = (self.home / '.config').resolve() + (self.home / '.config').unlink() + shutil.copytree(target, self.home / '.config', symlinks=True) + if fish_dir.is_symlink(): + target = fish_dir.resolve() + fish_dir.unlink() + shutil.copytree(target, fish_dir, symlinks=True) + if config.is_symlink(): + config.unlink() + config.write_text('keep my config\n') + self.run_setup('stow', success=False) + self.assertEqual(config.read_text(), 'keep my config\n') From f560a60cc0a3d1d9ea37682f619603486ddf9df4 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 02:04:51 -0700 Subject: [PATCH 02/20] Run setup CI on macOS only --- .github/workflows/test-init.yml | 13 ++----------- README.md | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index 50a1733a..be4544f4 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -6,19 +6,10 @@ permissions: contents: read jobs: test: - strategy: - matrix: - os: [macos-latest, ubuntu-latest] - runs-on: ${{ matrix.os }} + runs-on: macos-latest steps: - uses: actions/checkout@v4 - name: Install test dependencies (disposable runner only) - run: | - if [[ "$RUNNER_OS" == macOS ]]; then - brew install stow shellcheck fish - else - sudo apt-get update - sudo apt-get install -y stow shellcheck fish - fi + run: brew install stow shellcheck fish - run: bash -n scripts/init.sh && shellcheck scripts/init.sh - run: python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index 08f69d27..e95b8119 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ paths in the copied script are redirected into the fixture too. They exercise fresh setup, repeated runs, missing prerequisites, and failures without touching your packages, login shell, preferences, or running apps. A separate integration test uses real GNU Stow when available, targeting only the disposable home. -CI runs the same tests on macOS and Linux. +CI runs the same tests on macOS. These tests validate orchestration, not live Homebrew downloads or macOS privilege prompts. For a full installation smoke test, use a disposable macOS VM and run From a82b97455bbe9f0cb9ad1653e222d73cd09be9e2 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:32:10 -0700 Subject: [PATCH 03/20] Update checkout action to v7 --- .github/workflows/test-init.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index be4544f4..c7b153fd 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -8,7 +8,7 @@ jobs: test: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install test dependencies (disposable runner only) run: brew install stow shellcheck fish - run: bash -n scripts/init.sh && shellcheck scripts/init.sh From 30fa0da3dfe5906a5602654f894951107a03f84e Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:37:19 -0700 Subject: [PATCH 04/20] Use fnm to provision the Pi Node runtime --- .github/workflows/test-init.yml | 2 +- README.md | 7 ++-- homebrew/Brewfile | 1 - scripts/init.sh | 26 ++++++++------ tests/fake_command.py | 20 ++++++++++- tests/test_init.py | 63 +++++++++++++++++++++++++-------- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index c7b153fd..c282074d 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -10,6 +10,6 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install test dependencies (disposable runner only) - run: brew install stow shellcheck fish + run: brew install stow shellcheck fish fnm - run: bash -n scripts/init.sh && shellcheck scripts/init.sh - run: python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index e95b8119..c3b17297 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ before shell setup, and its actual executable path and account login shell are c Run `just init-fish` in a terminal to allow any required `sudo`/`chsh` prompts. Individual `init-*` recipes also install their missing tool dependencies. -Pi requires Node 22.19 or newer; an older active runtime produces a warning with -a follow-up command instead of attempting an incompatible install. +Pi uses Node 24 through `fnm`, installing that runtime only when missing. +Setup runs npm through `fnm exec`, so it works without an initialized interactive +shell and preserves an existing default Node version. `just init-obscura` and `just init-tailscale-cli` remain optional. Use `NO_COLOR=1 just init` for plain output, or `DOTS_NONINTERACTIVE=1 just init` to explicitly defer login-shell changes. Failures retain command diagnostics; @@ -63,6 +64,8 @@ paths in the copied script are redirected into the fixture too. They exercise fresh setup, repeated runs, missing prerequisites, and failures without touching your packages, login shell, preferences, or running apps. A separate integration test uses real GNU Stow when available, targeting only the disposable home. +Real Fish startup and `fnm exec` are also checked when installed; the fnm check +uses a disposable fake runtime and blocks installation commands. CI runs the same tests on macOS. These tests validate orchestration, not live Homebrew downloads or macOS privilege diff --git a/homebrew/Brewfile b/homebrew/Brewfile index e8c077a2..00bcc2cf 100644 --- a/homebrew/Brewfile +++ b/homebrew/Brewfile @@ -20,7 +20,6 @@ brew "lazygit" brew "lua" brew "modal" brew "neovim" -brew "node" brew "pkl" brew "pkl-lsp" brew "pi-coding-agent" diff --git a/scripts/init.sh b/scripts/init.sh index aa7be44b..b95b7554 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -120,22 +120,28 @@ init_gh() { } init_pi() { section 'Pi extensions' - require_tool node node - require_tool npm node - # Minimum required by the locked Pi coding-agent dependency. - if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)'; then - warn 'Pi extensions skipped: Node >=22.19 is required. Run brew upgrade node (or select a newer runtime with fnm), then just init-pi-extensions' - return - fi + require_tool fnm fnm local dir fingerprint stamp + local runtime=(fnm exec --using 24) + # Reuse an installed Node 24 without resolving/downloading updates each run. + # fnm exec works in this noninteractive Bash process without sourcing Fish + # startup or changing the user's active/default Node version. + if "${runtime[@]}" node --version >/dev/null 2>&1; then + ok 'Node 24 already available through fnm' + else + run 'Install Node 24 with fnm (retry with fnm install 24 if needed)' fnm install 24 + fi + if ! "${runtime[@]}" npm --version >/dev/null 2>&1; then + fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just init-pi-extensions.' + fi dir="$ROOT/pi/.pi/agent/extensions" stamp="$dir/node_modules/.dots-install" fingerprint=$(cat "$dir/package.json" "$dir/package-lock.json" | cksum) - fingerprint="$fingerprint $(node --version) $(npm --version) $(uname -sm)" - if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then + fingerprint="$fingerprint $("${runtime[@]}" node --version) $("${runtime[@]}" npm --version) $(uname -sm)" + if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && "${runtime[@]}" npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then ok 'Pi dependencies already installed (manifests and runtime unchanged)' else - run 'Install Pi dependencies' npm ci --prefix "$dir" --include=dev --no-audit --no-fund + run 'Install Pi dependencies' "${runtime[@]}" npm ci --prefix "$dir" --include=dev --no-audit --no-fund printf '%s\n' "$fingerprint" > "$stamp" fi link_configs pi diff --git a/tests/fake_command.py b/tests/fake_command.py index 157acc10..f6c2d11d 100644 --- a/tests/fake_command.py +++ b/tests/fake_command.py @@ -22,6 +22,22 @@ def done(code=0, output=''): if state.get('fail') == name: done(1, 'simulated dependency failure') +if name == 'fnm': + if args == ['env', '--use-on-cd', '--shell', 'fish']: + done() + if args == ['install', '24']: + if state.get('fail_fnm_install'): + done(1, 'simulated fnm download failure') + state['fnm_node'] = True + done() + if args[:3] == ['exec', '--using', '24']: + if not state.get('fnm_node'): + done(1, 'Node 24 is not installed') + name, args = args[3], args[4:] + with (state_dir / 'calls.jsonl').open('a') as log: + log.write(json.dumps([name, *args]) + '\n') + if state.get('fail') == name: + done(1, 'simulated dependency failure') if name == 'curl' and state.get('install_homebrew'): installer = Path(args[args.index('-o') + 1]) installer.write_text('ln -s "$TEST_STATE/driver" "$PATH/brew"\n') @@ -42,7 +58,7 @@ def done(code=0, output=''): if package == 'swiftbar': state['swiftbar'] = True else: - for tool in (['node', 'npm'] if package == 'node' else [package]): + for tool in [package]: destination = Path(os.environ['PATH']) / tool if not destination.exists(): destination.symlink_to(state_dir / 'driver') @@ -99,6 +115,8 @@ def done(code=0, output=''): if args == ['--version']: done(output='11.0.0') if args[0] == 'ci': + if state.get('fail_npm_ci'): + done(1, 'simulated dependency failure') (Path(args[args.index('--prefix')+1]) / 'node_modules').mkdir(exist_ok=True) state['npm'] = True done() diff --git a/tests/test_init.py b/tests/test_init.py index 1956f7be..3bee86a5 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,7 +9,7 @@ import unittest ROOT = Path(__file__).resolve().parents[1] -TOOLS = 'brew uname id dscl getent grep sudo chsh gh git stow node npm defaults launchctl pgrep osascript open fish just curl'.split() +TOOLS = 'brew uname id dscl getent grep sudo chsh gh git stow fnm node npm defaults launchctl pgrep osascript open fish just curl'.split() class SetupTests(unittest.TestCase): @@ -84,7 +84,7 @@ def test_fresh_init_then_rerun_has_no_mutations(self): forbidden = {'sudo', 'chsh', 'open', 'osascript'} for call in self.calls()[before:]: self.assertNotIn(call[0], forbidden, call) - self.assertFalse(call[:2] in [['npm', 'ci'], ['brew', 'install'], ['defaults', 'write'], ['launchctl', 'bootstrap']], call) + self.assertFalse(call[:2] in [['npm', 'ci'], ['fnm', 'install'], ['brew', 'install'], ['defaults', 'write'], ['launchctl', 'bootstrap']], call) self.assertNotEqual(call[:3], ['gh', 'extension', 'install']) if call[0] == 'stow': self.assertIn('--simulate', call) @@ -112,18 +112,31 @@ def test_unauthed_headless_init_continues_with_instructions(self): self.assertTrue(self.state()['npm']) self.assertFalse(any(c[0] in ('sudo', 'chsh', 'open') for c in self.calls())) - def test_old_node_warns_without_attempting_npm_install(self): - self.state(old_node=True) - output = self.run_setup('pi') - self.assertIn('Node >=22.19', output) - self.assertIn('just init-pi-extensions', output) - self.assertFalse(any(c[:2] == ['npm', 'ci'] for c in self.calls())) + def test_fnm_runtime_reused_despite_old_ambient_node(self): + self.state(old_node=True, fnm_node=True) + self.run_setup('pi') + self.assertNotIn(['fnm', 'install', '24'], self.calls()) + self.assertTrue(self.state()['npm']) + self.assertFalse(any(c[:2] in (['fnm', 'use'], ['fnm', 'default']) for c in self.calls())) + + def test_missing_runtime_and_fnm_installed_for_standalone_pi(self): + for tool in ('node', 'npm', 'fnm'): + (self.bin / tool).unlink() + self.run_setup('pi') + self.assertIn(['brew', 'install', 'fnm'], self.calls()) + self.assertIn(['fnm', 'install', '24'], self.calls()) + self.assertNotIn(['brew', 'install', 'node'], self.calls()) + self.assertTrue(any(c[:5] == ['fnm', 'exec', '--using', '24', 'npm'] for c in self.calls())) - def test_missing_node_installed_for_standalone_pi(self): - (self.bin / 'node').unlink() - (self.bin / 'npm').unlink() + def test_fnm_install_failure_can_be_retried(self): + self.state(fail_fnm_install=True) + output = self.run_setup('pi', success=False) + self.assertIn('simulated fnm download failure', output) + self.assertIn('fnm install 24', output) + self.assertFalse(any(c[:2] == ['npm', 'ci'] for c in self.calls())) + self.state(fail_fnm_install=False) self.run_setup('pi') - self.assertIn(['brew', 'install', 'node'], self.calls()) + self.assertTrue(self.state()['npm']) def test_pi_manifest_change_and_missing_dependencies_reinstall(self): self.run_setup('pi') @@ -135,11 +148,11 @@ def test_pi_manifest_change_and_missing_dependencies_reinstall(self): self.assertEqual(sum(c[:2] == ['npm', 'ci'] for c in self.calls()), 3) def test_failure_reports_diagnostics_and_can_retry(self): - self.state(fail='npm') + self.state(fail_npm_ci=True) output = self.run_setup('pi', success=False) self.assertIn('simulated dependency failure', output) self.assertFalse((self.repo / 'pi/.pi/agent/extensions/node_modules/.dots-install').exists()) - self.state(fail=None) + self.state(fail_npm_ci=False) self.run_setup('pi') def test_conflict_preserves_files_and_stops(self): @@ -192,8 +205,30 @@ def test_obscura_matching_version_does_not_download(self): self.assertIn('already installed', self.run_setup('obscura')) self.assertFalse(any(c[0] == 'curl' for c in self.calls())) + @unittest.skipUnless(shutil.which('fnm'), 'Install fnm to test real runtime selection') + def test_real_fnm_exec_with_disposable_runtime(self): + runtime_dir = self.base / 'fnm' + runtime_bin = runtime_dir / 'node-versions/v24.0.0/installation/bin' + runtime_bin.mkdir(parents=True) + for tool in ('node', 'npm'): + (runtime_bin / tool).symlink_to(self.base / 'driver') + (self.bin / tool).unlink() + self.env['FNM_DIR'] = str(runtime_dir) + # Only exec may reach the real fnm: any attempted install fails closed. + real_fnm = shutil.which('fnm') + (self.bin / 'fnm').unlink() + (self.bin / 'fnm').write_text( + f'#!{sys.executable}\nimport os, sys\n' + 'if sys.argv[1:4] != ["exec", "--using", "24"]: sys.exit(97)\n' + f'os.execv({real_fnm!r}, [{real_fnm!r}, *sys.argv[1:]])\n') + (self.bin / 'fnm').chmod(0o755) + self.run_setup('pi') + self.assertIn('already installed', self.run_setup('pi')) + self.assertEqual(sum(c[:2] == ['npm', 'ci'] for c in self.calls()), 1) + @unittest.skipUnless(shutil.which('fish'), 'Install Fish to test startup prerequisites') def test_fish_startup_with_missing_optional_tools(self): + (self.bin / 'fnm').unlink() config_dir = ROOT / 'fish/.config/fish/conf.d' result = subprocess.run( [shutil.which('fish'), '--no-config', '-c', From 7f48edbc7f5611c5ace476d2646b9e22056d5dbe Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:40:40 -0700 Subject: [PATCH 05/20] Scope setup CI to relevant changes --- .github/workflows/test-init.yml | 23 +++++++++++++++++++++++ README.md | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index c282074d..30fc2da3 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -1,7 +1,30 @@ name: Test setup on: push: + branches: [main] + paths: + - '.github/workflows/test-init.yml' + - 'justfile' + - 'scripts/**' + - 'tests/**' + - 'homebrew/Brewfile' + - 'fish/.config/fish/config.fish' + - 'fish/.config/fish/conf.d/**' + - 'pi/.pi/agent/extensions/package*.json' + - 'swiftbar/Library/LaunchAgents/**' + - '**/.stow-local-ignore' pull_request: + paths: + - '.github/workflows/test-init.yml' + - 'justfile' + - 'scripts/**' + - 'tests/**' + - 'homebrew/Brewfile' + - 'fish/.config/fish/config.fish' + - 'fish/.config/fish/conf.d/**' + - 'pi/.pi/agent/extensions/package*.json' + - 'swiftbar/Library/LaunchAgents/**' + - '**/.stow-local-ignore' permissions: contents: read jobs: diff --git a/README.md b/README.md index c3b17297..29c26077 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ your packages, login shell, preferences, or running apps. A separate integration test uses real GNU Stow when available, targeting only the disposable home. Real Fish startup and `fnm exec` are also checked when installed; the fnm check uses a disposable fake runtime and blocks installation commands. -CI runs the same tests on macOS. +CI runs the same tests on macOS for PRs and main-branch pushes that change +setup scripts, dependencies, startup configuration, tests, or the workflow. These tests validate orchestration, not live Homebrew downloads or macOS privilege prompts. For a full installation smoke test, use a disposable macOS VM and run From 87b1e7e125b1e801a69f3475f00cae949faf9085 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:42:22 -0700 Subject: [PATCH 06/20] Simplify setup CI triggers --- .github/workflows/test-init.yml | 22 ---------------------- README.md | 3 +-- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index 30fc2da3..0b353614 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -2,29 +2,7 @@ name: Test setup on: push: branches: [main] - paths: - - '.github/workflows/test-init.yml' - - 'justfile' - - 'scripts/**' - - 'tests/**' - - 'homebrew/Brewfile' - - 'fish/.config/fish/config.fish' - - 'fish/.config/fish/conf.d/**' - - 'pi/.pi/agent/extensions/package*.json' - - 'swiftbar/Library/LaunchAgents/**' - - '**/.stow-local-ignore' pull_request: - paths: - - '.github/workflows/test-init.yml' - - 'justfile' - - 'scripts/**' - - 'tests/**' - - 'homebrew/Brewfile' - - 'fish/.config/fish/config.fish' - - 'fish/.config/fish/conf.d/**' - - 'pi/.pi/agent/extensions/package*.json' - - 'swiftbar/Library/LaunchAgents/**' - - '**/.stow-local-ignore' permissions: contents: read jobs: diff --git a/README.md b/README.md index 29c26077..173aeede 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,7 @@ your packages, login shell, preferences, or running apps. A separate integration test uses real GNU Stow when available, targeting only the disposable home. Real Fish startup and `fnm exec` are also checked when installed; the fnm check uses a disposable fake runtime and blocks installation commands. -CI runs the same tests on macOS for PRs and main-branch pushes that change -setup scripts, dependencies, startup configuration, tests, or the workflow. +CI runs the same tests on macOS for PRs and pushes to `main`. These tests validate orchestration, not live Homebrew downloads or macOS privilege prompts. For a full installation smoke test, use a disposable macOS VM and run From 2c0168298133c7af127011d8cca13f0d13c72098 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:43:20 -0700 Subject: [PATCH 07/20] Remove Homebrew startup comment --- fish/.config/fish/conf.d/00-homebrew.fish | 1 - 1 file changed, 1 deletion(-) diff --git a/fish/.config/fish/conf.d/00-homebrew.fish b/fish/.config/fish/conf.d/00-homebrew.fish index 3958a677..0fcb54a8 100644 --- a/fish/.config/fish/conf.d/00-homebrew.fish +++ b/fish/.config/fish/conf.d/00-homebrew.fish @@ -1,4 +1,3 @@ -# Login shells on a fresh machine may not inherit Homebrew's PATH. if command -q brew brew shellenv fish | source else From cbe168cae058275abcc018e5870dacc7fa0c55d1 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:44:12 -0700 Subject: [PATCH 08/20] Restore gh-dash and SwiftBar in README tools list --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 173aeede..95fee33d 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ This is where I tweak config files till 4am like a goblin. It's pretty cozy in h - **[claude](https://code.claude.com/)** - **[fish](https://fishshell.com/)** +- **[gh-dash](https://www.gh-dash.dev/)** - **[homebrew](https://brew.sh/)** - **[neovim](https://neovim.io/)** - **[pi](https://github.com/badlogic/pi-mono)** - **[wezterm](https://wezterm.org/index.html)** - **[stow](https://www.gnu.org/software/stow/)** +- **[SwiftBar](https://github.com/swiftbar/SwiftBar)** - **[yazi](https://yazi-rs.github.io/)** ## Setup From 589dc269f3e97436b89055a1532588fcb8ffe202 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:44:44 -0700 Subject: [PATCH 09/20] Keep README setup instructions minimal --- README.md | 51 --------------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/README.md b/README.md index 95fee33d..61cb27f8 100644 --- a/README.md +++ b/README.md @@ -19,57 +19,6 @@ This is where I tweak config files till 4am like a goblin. It's pretty cozy in h ## Setup -From a clone of this repo on macOS (primarily Apple Silicon): - ```bash bash scripts/init.sh bootstrap ``` - -The bootstrap installs Homebrew and `just` if missing, loads Homebrew's PATH, -then runs `just init`. Homebrew may request administrator access or Apple's -Command Line Tools; follow its instructions and rerun if installation stops. -A network connection is needed for missing packages. If Homebrew and `just` -are already available, run `just init` directly. - -Setup installs missing Brewfile packages without requesting upgrades of existing ones, -links configs without overwriting existing files, and installs extension dependencies. -Existing configuration conflicts stop with instructions to move/back up the files. -Repeated runs report what is already configured and only apply missing setup. -GitHub authentication and an interactive login-shell change can be deferred; -warnings include the exact commands to finish those steps. Fish is installed -before shell setup, and its actual executable path and account login shell are checked. -Run `just init-fish` in a terminal to allow any required `sudo`/`chsh` prompts. - -Individual `init-*` recipes also install their missing tool dependencies. -Pi uses Node 24 through `fnm`, installing that runtime only when missing. -Setup runs npm through `fnm exec`, so it works without an initialized interactive -shell and preserves an existing default Node version. -`just init-obscura` and `just init-tailscale-cli` remain optional. -Use `NO_COLOR=1 just init` for plain output, or `DOTS_NONINTERACTIVE=1 just init` -to explicitly defer login-shell changes. Failures retain command diagnostics; -a failed required step returns a nonzero status, while optional skipped steps -are counted in the final summary. Linux support is best-effort; macOS app setup -is skipped there. - -## Testing setup safely - -```bash -just test -# Without just: -python3 -m unittest discover -s tests -v -``` - -Tests copy the setup files into temporary directories with disposable homes. -External installers, authentication, shell changes, and macOS services are -replaced with stateful command doubles on a restricted PATH. Homebrew discovery -paths in the copied script are redirected into the fixture too. They exercise -fresh setup, repeated runs, missing prerequisites, and failures without touching -your packages, login shell, preferences, or running apps. A separate integration -test uses real GNU Stow when available, targeting only the disposable home. -Real Fish startup and `fnm exec` are also checked when installed; the fnm check -uses a disposable fake runtime and blocks installation commands. -CI runs the same tests on macOS for PRs and pushes to `main`. - -These tests validate orchestration, not live Homebrew downloads or macOS privilege -prompts. For a full installation smoke test, use a disposable macOS VM and run -`bash scripts/init.sh bootstrap` twice; do not use your daily account as a sandbox. From a8ffb373949c0517f994d59454d449f4a436dec5 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:46:45 -0700 Subject: [PATCH 10/20] Remove setup script and test comments --- scripts/init.sh | 12 +----------- tests/fake_command.py | 5 +---- tests/test_init.py | 6 ------ 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/scripts/init.sh b/scripts/init.sh index b95b7554..5223ff10 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# Compatible with macOS's Bash 3.2. set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) cd "$ROOT" @@ -14,7 +13,6 @@ section() { printf '\n%s%s%s\n' "$blue" "$*" "$reset"; } fail() { printf ' ✗ %s\n' "$*" >&2; exit 1; } trap 'printf " ✗ Setup stopped. Fix the error above, then rerun the same command.\n" >&2' ERR -# Quiet on success, full diagnostics on failure. run() { local label=$1 log shift @@ -46,7 +44,6 @@ ensure_brew() { rm -f "$installer" fail 'Could not download Homebrew. Check your network connection and rerun.' fi - # Keep installer prerequisites and privilege prompts visible. if ! /bin/bash "$installer"; then rm -f "$installer" fail 'Homebrew installation failed. Complete the prerequisites printed above and rerun.' @@ -74,7 +71,6 @@ init_brew() { if brew bundle check --no-upgrade --file="$ROOT/homebrew/Brewfile" >/dev/null 2>&1; then ok 'All Brewfile packages already installed' else - # Casks can prompt for passwords: leave this command attached to the terminal. printf ' → Install missing Brewfile packages\n' brew bundle install --no-upgrade --file="$ROOT/homebrew/Brewfile" ok 'Brewfile packages installed' @@ -84,7 +80,6 @@ link_configs() { require_tool stow stow local preview preview=$(mktemp) - # Check the entire group before applying links; never overwrite/adopt files. if ! stow --dir="$ROOT" --target="$HOME" --simulate --verbose "$@" >"$preview" 2>&1; then cat "$preview" >&2 rm -f "$preview" @@ -123,9 +118,6 @@ init_pi() { require_tool fnm fnm local dir fingerprint stamp local runtime=(fnm exec --using 24) - # Reuse an installed Node 24 without resolving/downloading updates each run. - # fnm exec works in this noninteractive Bash process without sourcing Fish - # startup or changing the user's active/default Node version. if "${runtime[@]}" node --version >/dev/null 2>&1; then ok 'Node 24 already available through fnm' else @@ -251,9 +243,7 @@ init_tailscale() { return fi require_tool fish fish - # Leave existing /usr/local/bin executables alone. - # shellcheck disable=SC2016 # Expanded by Fish, not Bash. - if fish --no-config -c 'contains -- /Applications/Tailscale.app/Contents/MacOS $fish_user_paths'; then + if fish --no-config -c "contains -- /Applications/Tailscale.app/Contents/MacOS \$fish_user_paths"; then ok 'Tailscale app directory already on Fish PATH' else run 'Add Tailscale app directory to Fish PATH' fish --no-config -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' diff --git a/tests/fake_command.py b/tests/fake_command.py index f6c2d11d..86a31e9b 100644 --- a/tests/fake_command.py +++ b/tests/fake_command.py @@ -1,4 +1,3 @@ -"""Stateful external-command doubles. This file never calls a real command.""" import json import os from pathlib import Path @@ -71,7 +70,6 @@ def done(code=0, output=''): shell = state.get('shell', '/bin/zsh') done(output='UserShell: ' + shell if name == 'dscl' else 'tester:x:501:20::/tmp:' + shell) if name == 'grep': - # Only /etc/shells is mocked; the other patterns are handled in Python. if args[-1] == '/etc/shells': done(0 if state.get('registered') else 1) import re @@ -147,6 +145,5 @@ def done(code=0, output=''): state['fish_path'] = True done() if name == 'just': - done() # bootstrap handoff, never run a real installer -# Fail closed for all unexpected invocations, including network commands. + done() done(97, f'UNEXPECTED COMMAND: {name} {args}') diff --git a/tests/test_init.py b/tests/test_init.py index 3bee86a5..2101bd73 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -22,8 +22,6 @@ def setUp(self): for item in ('scripts', 'homebrew', 'pi'): shutil.copytree(ROOT / item, self.repo / item, ignore=shutil.ignore_patterns('node_modules', '__pycache__')) shutil.copy(ROOT / 'justfile', self.repo) - # Redirect fallback Homebrew discovery inside the copied script as well; - # even tests with brew absent must never find the host installation. script = self.repo / 'scripts/init.sh' content = script.read_text() for index, prefix in enumerate(('/opt/homebrew', '/usr/local', '/home/linuxbrew/.linuxbrew')): @@ -40,8 +38,6 @@ def setUp(self): driver.chmod(0o755) for tool in TOOLS: (self.bin / tool).symlink_to(driver) - # Only safe, local utilities can escape the doubles. No real package - # manager, network client, sudo, shell changer, or desktop tool on PATH. for tool in ('bash', 'dirname', 'mktemp', 'rm', 'cat', 'awk', 'cut', 'cksum', 'mkdir', 'tar', 'install', 'ln'): (self.bin / tool).symlink_to(shutil.which(tool)) self.env = {'PATH': str(self.bin), 'HOME': str(self.home), 'TEST_STATE': str(self.base), 'NO_COLOR': '1', 'LC_ALL': 'C', 'TMPDIR': str(self.base)} @@ -214,7 +210,6 @@ def test_real_fnm_exec_with_disposable_runtime(self): (runtime_bin / tool).symlink_to(self.base / 'driver') (self.bin / tool).unlink() self.env['FNM_DIR'] = str(runtime_dir) - # Only exec may reach the real fnm: any attempted install fails closed. real_fnm = shutil.which('fnm') (self.bin / 'fnm').unlink() (self.bin / 'fnm').write_text( @@ -249,7 +244,6 @@ def test_real_stow_rerun_and_conflict(self): self.assertTrue(config.exists()) self.assertEqual(config.resolve(), (self.repo / 'fish/.config/fish/config.fish').resolve()) self.assertIn('already linked', self.run_setup('stow')) - # Unfold the linked directory into independent files before creating a conflict. fish_dir = self.home / '.config/fish' if (self.home / '.config').is_symlink(): target = (self.home / '.config').resolve() From 8edcf29d9485ed19aa80a93f84a51042a332a7e3 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:52:41 -0700 Subject: [PATCH 11/20] Replace command mocks with real macOS setup integration --- .github/workflows/test-init.yml | 8 +- justfile | 2 +- tests/fake_command.py | 149 ------------------ tests/integration.py | 131 ++++++++++++++++ tests/test_init.py | 260 -------------------------------- 5 files changed, 137 insertions(+), 413 deletions(-) delete mode 100644 tests/fake_command.py create mode 100644 tests/integration.py delete mode 100644 tests/test_init.py diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index 0b353614..f5f02e6b 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -8,9 +8,11 @@ permissions: jobs: test: runs-on: macos-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - - name: Install test dependencies (disposable runner only) - run: brew install stow shellcheck fish fnm + - name: Install test dependencies + run: brew install just stow shellcheck fish fnm - run: bash -n scripts/init.sh && shellcheck scripts/init.sh - - run: python3 -m unittest discover -s tests -v + - name: Test real setup and reruns in a disposable home + run: just test diff --git a/justfile b/justfile index a0eb371c..9bf26c21 100644 --- a/justfile +++ b/justfile @@ -26,4 +26,4 @@ stow: @bash scripts/init.sh stow test: - @python3 -m unittest discover -s tests -v + @python3 tests/integration.py diff --git a/tests/fake_command.py b/tests/fake_command.py deleted file mode 100644 index 86a31e9b..00000000 --- a/tests/fake_command.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -import os -from pathlib import Path -import sys - -name = Path(sys.argv[0]).name -args = sys.argv[1:] -state_dir = Path(os.environ['TEST_STATE']) -state_file = state_dir / 'state.json' -state = json.loads(state_file.read_text()) -with (state_dir / 'calls.jsonl').open('a') as log: - log.write(json.dumps([name, *args]) + '\n') - - -def done(code=0, output=''): - state_file.write_text(json.dumps(state)) - if output: - print(output) - sys.exit(code) - - -if state.get('fail') == name: - done(1, 'simulated dependency failure') -if name == 'fnm': - if args == ['env', '--use-on-cd', '--shell', 'fish']: - done() - if args == ['install', '24']: - if state.get('fail_fnm_install'): - done(1, 'simulated fnm download failure') - state['fnm_node'] = True - done() - if args[:3] == ['exec', '--using', '24']: - if not state.get('fnm_node'): - done(1, 'Node 24 is not installed') - name, args = args[3], args[4:] - with (state_dir / 'calls.jsonl').open('a') as log: - log.write(json.dumps([name, *args]) + '\n') - if state.get('fail') == name: - done(1, 'simulated dependency failure') -if name == 'curl' and state.get('install_homebrew'): - installer = Path(args[args.index('-o') + 1]) - installer.write_text('ln -s "$TEST_STATE/driver" "$PATH/brew"\n') - done() -if name == 'brew': - if args in (['shellenv', 'bash'], ['shellenv', 'fish']): - done() - if args[:2] == ['bundle', 'check']: - done(0 if state.get('bundle') else 1) - if args[:2] == ['bundle', 'install']: - state['bundle'] = True - state['swiftbar'] = True - done() - if args[:2] == ['list', '--cask']: - done(0 if state.get('swiftbar') else 1) - if args[0] == 'install': - package = args[-1] - if package == 'swiftbar': - state['swiftbar'] = True - else: - for tool in [package]: - destination = Path(os.environ['PATH']) / tool - if not destination.exists(): - destination.symlink_to(state_dir / 'driver') - done() -if name == 'uname': - done(output={'-s': state.get('os', 'Darwin'), '-m': 'arm64', '-sm': 'Darwin arm64'}[args[0]]) -if name == 'id': - done(output='tester' if args == ['-un'] else '501') -if name in ('dscl', 'getent'): - shell = state.get('shell', '/bin/zsh') - done(output='UserShell: ' + shell if name == 'dscl' else 'tester:x:501:20::/tmp:' + shell) -if name == 'grep': - if args[-1] == '/etc/shells': - done(0 if state.get('registered') else 1) - import re - if args[-1].startswith('('): - content = sys.stdin.read() - pattern = args[-1] - else: - content = Path(args[-1]).read_text() - pattern = args[-2] - pattern = pattern.replace('[[:space:]]', r'\s') - done(0 if re.search(pattern, content) else 1) -if name == 'sudo': - if args[:2] == ['tee', '-a'] and args[2:] == ['/etc/shells']: - sys.stdin.read() - state['registered'] = True - done() -if name == 'chsh': - state['shell'] = args[-1] - done() -if name == 'gh': - if args[:2] == ['auth', 'status']: - done(0 if state.get('auth', True) else 1) - if args == ['extension', 'list']: - done(output='\n'.join(state.get('extensions', []))) - if args[:2] == ['extension', 'install']: - state.setdefault('extensions', []).append(args[-1]) - done() -if name == 'stow': - packages = [a for a in args if not a.startswith('--')] - if state.get('conflict'): - done(1, 'existing target is neither a link nor a directory: .config/fish/config.fish') - if '--simulate' in args: - done(output='LINK: example => repo/example' if any(p not in state.get('linked', []) for p in packages) else '') - state['linked'] = sorted(set(state.get('linked', []) + packages)) - done() -if name == 'node': - if args[0] == '-e': - done(1 if state.get('old_node') else 0) - done(output='v24.0.0') -if name == 'npm': - if args == ['--version']: - done(output='11.0.0') - if args[0] == 'ci': - if state.get('fail_npm_ci'): - done(1, 'simulated dependency failure') - (Path(args[args.index('--prefix')+1]) / 'node_modules').mkdir(exist_ok=True) - state['npm'] = True - done() - if args[0] == 'ls': - done(0 if state.get('npm') else 1) -if name == 'defaults': - if args[0] == 'read': - done(0 if state.get('plugins') else 1, state.get('plugins', '')) - state['plugins'] = args[-1] - done() -if name == 'launchctl': - if args[0] == 'print': - done(0 if (state.get('agent') if args[1].count('/') == 2 else state.get('gui', True)) else 1) - if args[0] == 'bootstrap': - state['agent'] = True - done() -if name == 'pgrep': - done(0 if state.get('running') else 1) -if name == 'osascript': - state['running'] = False - done() -if name == 'open': - state['running'] = True - done() -if name == 'fish': - if 'contains' in args[-1]: - done(0 if state.get('fish_path') else 1) - state['fish_path'] = True - done() -if name == 'just': - done() -done(97, f'UNEXPECTED COMMAND: {name} {args}') diff --git a/tests/integration.py b/tests/integration.py new file mode 100644 index 00000000..9836d982 --- /dev/null +++ b/tests/integration.py @@ -0,0 +1,131 @@ +import os +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +def snapshot(root): + result = {} + for directory, folders, files in os.walk(root, followlinks=False): + for name in folders + files: + path = Path(directory) / name + stat = path.lstat() + result[str(path.relative_to(root))] = ( + stat.st_mode, stat.st_ino, stat.st_size, stat.st_mtime_ns, + os.readlink(path) if path.is_symlink() else None, + ) + return result + + +def main(): + if not ( + platform.system() == 'Darwin' + and os.environ.get('GITHUB_ACTIONS') == 'true' + and os.environ.get('RUNNER_ENVIRONMENT') == 'github-hosted' + ): + raise SystemExit('Real installation tests run only on disposable GitHub-hosted macOS runners.') + for tool in ('just', 'stow', 'fish', 'fnm'): + require(shutil.which(tool), f'The runner needs {tool}.') + + with tempfile.TemporaryDirectory(prefix='dots-integration-') as temporary: + base = Path(temporary).resolve() + repo = base / 'repo with spaces' + home = base / 'home' + home.mkdir() + for directory in ('scripts', 'homebrew', 'fish', 'gh-dash', 'nvim', 'pi', 'wezterm', 'yazi', 'swiftbar'): + shutil.copytree(ROOT / directory, repo / directory, + ignore=shutil.ignore_patterns('node_modules', '__pycache__')) + shutil.copy2(ROOT / 'justfile', repo / 'justfile') + for directory in ('tmp', 'runtime', 'cache'): + (base / directory).mkdir() + env = { + 'PATH': os.environ['PATH'], + 'HOME': str(home), + 'FNM_DIR': str(home / '.local/share/fnm'), + 'XDG_CONFIG_HOME': str(home / '.config'), + 'XDG_DATA_HOME': str(home / '.local/share'), + 'XDG_CACHE_HOME': str(base / 'cache'), + 'XDG_RUNTIME_DIR': str(base / 'runtime'), + 'npm_config_cache': str(base / 'cache/npm'), + 'TMPDIR': str(base / 'tmp'), + 'NO_COLOR': '1', + 'TERM': 'dumb', + 'DOTS_NONINTERACTIVE': '1', + 'HOMEBREW_NO_AUTO_UPDATE': '1', + 'HOMEBREW_NO_INSTALL_CLEANUP': '1', + } + + def run(*args, success=True): + print('→ ' + ' '.join(args), flush=True) + result = subprocess.run(args, cwd=repo, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=600) + print(result.stdout, flush=True) + require((result.returncode == 0) == success, + f'Unexpected exit {result.returncode}: {args}') + return result.stdout + + runtime = ('fnm', 'exec', '--using', '24') + require(not Path(env['FNM_DIR']).exists(), 'fnm must start empty') + run(*runtime, 'node', '--version', success=False) + run('just', 'stow') + first = run('just', 'init-pi-extensions') + require('Install Node 24 with fnm' in first, 'Fresh setup must install Node') + require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') + require(run(*runtime, 'node', '--version').strip().startswith('v24.'), 'Expected Node 24') + pi = repo / 'pi/.pi/agent/extensions' + run(*runtime, 'npm', 'ls', '--prefix', str(pi), '--depth=0') + run(*runtime, 'node', '--input-type=module', '-e', + 'const {createRequire} = await import("node:module"); ' + 'const require = createRequire(process.argv[1]); ' + 'await import(require.resolve("typebox")); console.log("Pi dependency import passed")', + str(pi / 'package.json')) + for source in (repo / 'fish/.config/fish/config.fish', pi / 'package.json', + pi / 'node_modules/typebox/package.json'): + package = source.relative_to(repo).parts[0] + target = home / source.relative_to(repo / package) + require(target.resolve() == source.resolve(), f'Incorrect Stow target: {target}') + + run('fish', '--no-config', '-c', + 'source "$HOME/.config/fish/conf.d/00-homebrew.fish"; ' + 'source "$HOME/.config/fish/conf.d/fnm.fish"; ' + 'command -q brew; or exit 1; command -q fnm; or exit 1; ' + 'node --version; npm --version') + account_shell = run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') + shells = Path('/etc/shells').read_bytes() + run('just', 'init-fish') + require(run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') == account_shell, + 'Noninteractive setup changed the account shell') + require(Path('/etc/shells').read_bytes() == shells, 'Noninteractive setup changed /etc/shells') + + before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} + second = run('just', 'stow') + run('just', 'init-pi-extensions') + require('→' not in second, 'Second run unexpectedly performed setup work') + require('Pi dependencies already installed' in second, 'Second run did not reuse dependencies') + for path, state in before.items(): + require(snapshot(Path(path)) == state, f'Rerun changed installed state: {path}') + + conflict_home = base / 'conflicting home' + config = conflict_home / '.config/fish/config.fish' + config.parent.mkdir(parents=True) + config.write_text('keep this existing config\n') + env['HOME'] = str(conflict_home) + output = run('just', 'stow', success=False) + require('move or back up' in output, 'Conflict lacked recovery instructions') + require(config.read_text() == 'keep this existing config\n', 'Existing config was overwritten') + require(not (conflict_home / '.pi').exists(), 'Conflict preflight partially linked configs') + print('Real setup integration passed: install, startup, rerun, and conflict preservation.', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/test_init.py b/tests/test_init.py deleted file mode 100644 index 2101bd73..00000000 --- a/tests/test_init.py +++ /dev/null @@ -1,260 +0,0 @@ -import json -import os -from pathlib import Path -import pty -import shutil -import subprocess -import sys -import tempfile -import unittest - -ROOT = Path(__file__).resolve().parents[1] -TOOLS = 'brew uname id dscl getent grep sudo chsh gh git stow fnm node npm defaults launchctl pgrep osascript open fish just curl'.split() - - -class SetupTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix='dots-test-') - self.addCleanup(self.temp.cleanup) - self.base = Path(self.temp.name) - self.repo = self.base / 'repo with spaces' - self.repo.mkdir() - for item in ('scripts', 'homebrew', 'pi'): - shutil.copytree(ROOT / item, self.repo / item, ignore=shutil.ignore_patterns('node_modules', '__pycache__')) - shutil.copy(ROOT / 'justfile', self.repo) - script = self.repo / 'scripts/init.sh' - content = script.read_text() - for index, prefix in enumerate(('/opt/homebrew', '/usr/local', '/home/linuxbrew/.linuxbrew')): - content = content.replace(prefix + '/bin/brew', str(self.base / f'prefix{index}/bin/brew')) - script.write_text(content) - self.home = self.base / 'home' - self.home.mkdir() - self.bin = self.base / 'bin' - self.bin.mkdir() - self.state_file = self.base / 'state.json' - self.state_file.write_text('{}') - driver = self.base / 'driver' - driver.write_text(f'#!{sys.executable}\n' + (ROOT / 'tests/fake_command.py').read_text()) - driver.chmod(0o755) - for tool in TOOLS: - (self.bin / tool).symlink_to(driver) - for tool in ('bash', 'dirname', 'mktemp', 'rm', 'cat', 'awk', 'cut', 'cksum', 'mkdir', 'tar', 'install', 'ln'): - (self.bin / tool).symlink_to(shutil.which(tool)) - self.env = {'PATH': str(self.bin), 'HOME': str(self.home), 'TEST_STATE': str(self.base), 'NO_COLOR': '1', 'LC_ALL': 'C', 'TMPDIR': str(self.base)} - - def state(self, **updates): - state = json.loads(self.state_file.read_text()) - state.update(updates) - self.state_file.write_text(json.dumps(state)) - return state - - def calls(self): - path = self.base / 'calls.jsonl' - return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] - - def run_setup(self, task='init', tty=False, success=True): - command = ['/bin/bash', str(self.repo / 'scripts/init.sh'), task] - if tty: - master, slave = pty.openpty() - try: - result = subprocess.run(command, env=self.env, stdin=slave, capture_output=True, text=True, timeout=30) - finally: - os.close(master) - os.close(slave) - else: - result = subprocess.run(command, env=self.env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) - if success: - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - else: - self.assertNotEqual(result.returncode, 0) - return result.stdout + result.stderr - - def test_fresh_init_then_rerun_has_no_mutations(self): - first = self.run_setup(tty=True) - self.assertIn('Login shell changed', first) - before = len(self.calls()) - second = self.run_setup(tty=True) - self.assertIn('already', second) - self.assertNotIn('→', second) - self.assertNotIn('\x1b', second) - forbidden = {'sudo', 'chsh', 'open', 'osascript'} - for call in self.calls()[before:]: - self.assertNotIn(call[0], forbidden, call) - self.assertFalse(call[:2] in [['npm', 'ci'], ['fnm', 'install'], ['brew', 'install'], ['defaults', 'write'], ['launchctl', 'bootstrap']], call) - self.assertNotEqual(call[:3], ['gh', 'extension', 'install']) - if call[0] == 'stow': - self.assertIn('--simulate', call) - self.assertTrue(any(c[:3] == ['brew', 'bundle', 'install'] and '--no-upgrade' in c for c in self.calls())) - - def test_fish_installs_missing_dependency_and_only_changes_once(self): - (self.bin / 'fish').unlink() - self.run_setup('fish', tty=True) - self.assertIn(['brew', 'install', 'fish'], self.calls()) - self.assertEqual(self.state()['shell'], str(self.bin / 'fish')) - before = len(self.calls()) - self.run_setup('fish', tty=True) - self.assertFalse(any(c[0] in ('sudo', 'chsh') for c in self.calls()[before:])) - - def test_fish_uses_account_shell_not_stale_environment(self): - self.env['SHELL'] = str(self.bin / 'fish') - self.run_setup('fish', tty=True) - self.assertTrue(any(c[0] == 'chsh' for c in self.calls())) - - def test_unauthed_headless_init_continues_with_instructions(self): - self.state(auth=False, gui=False) - output = self.run_setup() - for text in ('gh auth login', 'just init-fish', 'no GUI session', '3 skipped'): - self.assertIn(text, output) - self.assertTrue(self.state()['npm']) - self.assertFalse(any(c[0] in ('sudo', 'chsh', 'open') for c in self.calls())) - - def test_fnm_runtime_reused_despite_old_ambient_node(self): - self.state(old_node=True, fnm_node=True) - self.run_setup('pi') - self.assertNotIn(['fnm', 'install', '24'], self.calls()) - self.assertTrue(self.state()['npm']) - self.assertFalse(any(c[:2] in (['fnm', 'use'], ['fnm', 'default']) for c in self.calls())) - - def test_missing_runtime_and_fnm_installed_for_standalone_pi(self): - for tool in ('node', 'npm', 'fnm'): - (self.bin / tool).unlink() - self.run_setup('pi') - self.assertIn(['brew', 'install', 'fnm'], self.calls()) - self.assertIn(['fnm', 'install', '24'], self.calls()) - self.assertNotIn(['brew', 'install', 'node'], self.calls()) - self.assertTrue(any(c[:5] == ['fnm', 'exec', '--using', '24', 'npm'] for c in self.calls())) - - def test_fnm_install_failure_can_be_retried(self): - self.state(fail_fnm_install=True) - output = self.run_setup('pi', success=False) - self.assertIn('simulated fnm download failure', output) - self.assertIn('fnm install 24', output) - self.assertFalse(any(c[:2] == ['npm', 'ci'] for c in self.calls())) - self.state(fail_fnm_install=False) - self.run_setup('pi') - self.assertTrue(self.state()['npm']) - - def test_pi_manifest_change_and_missing_dependencies_reinstall(self): - self.run_setup('pi') - manifest = self.repo / 'pi/.pi/agent/extensions/package.json' - manifest.write_text(manifest.read_text() + '\n') - self.run_setup('pi') - self.state(npm=False) - self.run_setup('pi') - self.assertEqual(sum(c[:2] == ['npm', 'ci'] for c in self.calls()), 3) - - def test_failure_reports_diagnostics_and_can_retry(self): - self.state(fail_npm_ci=True) - output = self.run_setup('pi', success=False) - self.assertIn('simulated dependency failure', output) - self.assertFalse((self.repo / 'pi/.pi/agent/extensions/node_modules/.dots-install').exists()) - self.state(fail_npm_ci=False) - self.run_setup('pi') - - def test_conflict_preserves_files_and_stops(self): - self.state(conflict=True) - output = self.run_setup('stow', success=False) - self.assertIn('move or back up', output) - self.assertTrue(all('--simulate' in c for c in self.calls() if c[0] == 'stow')) - - def test_missing_sudo_has_actionable_error(self): - (self.bin / 'sudo').unlink() - output = self.run_setup('fish', tty=True, success=False) - self.assertIn('Register the Fish path in /etc/shells', output) - - def test_explicit_noninteractive_does_not_change_shell(self): - self.env['DOTS_NONINTERACTIVE'] = '1' - self.assertIn('skipped', self.run_setup('fish', tty=True)) - self.assertFalse(any(c[0] in ('sudo', 'chsh') for c in self.calls())) - - def test_linux_skips_desktop_recipes(self): - self.state(os='Linux') - self.assertIn('macOS only', self.run_setup('insomnia')) - self.assertIn('macOS only', self.run_setup('tailscale')) - - def test_bootstrap_without_homebrew_or_just(self): - (self.bin / 'brew').unlink() - (self.bin / 'just').unlink() - self.state(install_homebrew=True) - self.run_setup('bootstrap') - self.assertIn(['brew', 'install', 'just'], self.calls()) - self.assertTrue(any(c[0] == 'curl' for c in self.calls())) - self.assertTrue(any(c[0] == 'just' for c in self.calls())) - - def test_bootstrap_download_failure_explains_recovery(self): - (self.bin / 'brew').unlink() - self.state(fail='curl') - self.assertIn('Check your network connection and rerun', self.run_setup('bootstrap', success=False)) - self.assertFalse(any(c[0] == 'just' for c in self.calls())) - - def test_bootstrap_hands_off_to_just(self): - self.run_setup('bootstrap') - self.assertIn(['just', '--justfile', str(self.repo / 'justfile'), 'init'], self.calls()) - - def test_obscura_matching_version_does_not_download(self): - target = self.home / '.local/bin' - target.mkdir(parents=True) - for tool in ('obscura', 'obscura-worker'): - binary = target / tool - binary.write_text('#!/bin/bash\nprintf "obscura 0.1.8\\n"\n') - binary.chmod(0o755) - self.assertIn('already installed', self.run_setup('obscura')) - self.assertFalse(any(c[0] == 'curl' for c in self.calls())) - - @unittest.skipUnless(shutil.which('fnm'), 'Install fnm to test real runtime selection') - def test_real_fnm_exec_with_disposable_runtime(self): - runtime_dir = self.base / 'fnm' - runtime_bin = runtime_dir / 'node-versions/v24.0.0/installation/bin' - runtime_bin.mkdir(parents=True) - for tool in ('node', 'npm'): - (runtime_bin / tool).symlink_to(self.base / 'driver') - (self.bin / tool).unlink() - self.env['FNM_DIR'] = str(runtime_dir) - real_fnm = shutil.which('fnm') - (self.bin / 'fnm').unlink() - (self.bin / 'fnm').write_text( - f'#!{sys.executable}\nimport os, sys\n' - 'if sys.argv[1:4] != ["exec", "--using", "24"]: sys.exit(97)\n' - f'os.execv({real_fnm!r}, [{real_fnm!r}, *sys.argv[1:]])\n') - (self.bin / 'fnm').chmod(0o755) - self.run_setup('pi') - self.assertIn('already installed', self.run_setup('pi')) - self.assertEqual(sum(c[:2] == ['npm', 'ci'] for c in self.calls()), 1) - - @unittest.skipUnless(shutil.which('fish'), 'Install Fish to test startup prerequisites') - def test_fish_startup_with_missing_optional_tools(self): - (self.bin / 'fnm').unlink() - config_dir = ROOT / 'fish/.config/fish/conf.d' - result = subprocess.run( - [shutil.which('fish'), '--no-config', '-c', - 'source "$argv[1]/00-homebrew.fish"; source "$argv[1]/fnm.fish"', str(config_dir)], - env=self.env, cwd=self.home, capture_output=True, text=True, timeout=30) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stderr, '') - self.assertIn(['brew', 'shellenv', 'fish'], self.calls()) - - @unittest.skipUnless(shutil.which('stow'), 'Install GNU Stow to run real symlink integration') - def test_real_stow_rerun_and_conflict(self): - for package in ('fish', 'gh-dash', 'nvim', 'wezterm', 'yazi'): - shutil.copytree(ROOT / package, self.repo / package) - (self.bin / 'stow').unlink() - (self.bin / 'stow').symlink_to(shutil.which('stow')) - self.run_setup('stow') - config = self.home / '.config/fish/config.fish' - self.assertTrue(config.exists()) - self.assertEqual(config.resolve(), (self.repo / 'fish/.config/fish/config.fish').resolve()) - self.assertIn('already linked', self.run_setup('stow')) - fish_dir = self.home / '.config/fish' - if (self.home / '.config').is_symlink(): - target = (self.home / '.config').resolve() - (self.home / '.config').unlink() - shutil.copytree(target, self.home / '.config', symlinks=True) - if fish_dir.is_symlink(): - target = fish_dir.resolve() - fish_dir.unlink() - shutil.copytree(target, fish_dir, symlinks=True) - if config.is_symlink(): - config.unlink() - config.write_text('keep my config\n') - self.run_setup('stow', success=False) - self.assertEqual(config.read_text(), 'keep my config\n') From 62c619fe424343cb6e70cc4f68bc077b052499cd Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:56:03 -0700 Subject: [PATCH 12/20] Let bootstrap provision integration test tools --- .github/workflows/test-init.yml | 6 +++--- scripts/init.sh | 2 +- tests/integration.py | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index f5f02e6b..46ca736a 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -11,8 +11,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v7 - - name: Install test dependencies - run: brew install just stow shellcheck fish fnm + - name: Install shell linter + run: brew install shellcheck - run: bash -n scripts/init.sh && shellcheck scripts/init.sh - name: Test real setup and reruns in a disposable home - run: just test + run: python3 tests/integration.py diff --git a/scripts/init.sh b/scripts/init.sh index 5223ff10..dd52c01a 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -250,7 +250,7 @@ init_tailscale() { fi } case "${1:-init}" in - bootstrap) ensure_brew; require_tool just just; exec just --justfile "$ROOT/justfile" init ;; + bootstrap) ensure_brew; require_tool just just; exec just --justfile "$ROOT/justfile" "${2:-init}" ;; init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; diff --git a/tests/integration.py b/tests/integration.py index 9836d982..5746d454 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -34,8 +34,6 @@ def main(): and os.environ.get('RUNNER_ENVIRONMENT') == 'github-hosted' ): raise SystemExit('Real installation tests run only on disposable GitHub-hosted macOS runners.') - for tool in ('just', 'stow', 'fish', 'fnm'): - require(shutil.which(tool), f'The runner needs {tool}.') with tempfile.TemporaryDirectory(prefix='dots-integration-') as temporary: base = Path(temporary).resolve() @@ -77,8 +75,9 @@ def run(*args, success=True): runtime = ('fnm', 'exec', '--using', '24') require(not Path(env['FNM_DIR']).exists(), 'fnm must start empty') - run(*runtime, 'node', '--version', success=False) - run('just', 'stow') + if shutil.which('fnm'): + run(*runtime, 'node', '--version', success=False) + run('bash', 'scripts/init.sh', 'bootstrap', 'stow') first = run('just', 'init-pi-extensions') require('Install Node 24 with fnm' in first, 'Fresh setup must install Node') require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') @@ -96,11 +95,6 @@ def run(*args, success=True): target = home / source.relative_to(repo / package) require(target.resolve() == source.resolve(), f'Incorrect Stow target: {target}') - run('fish', '--no-config', '-c', - 'source "$HOME/.config/fish/conf.d/00-homebrew.fish"; ' - 'source "$HOME/.config/fish/conf.d/fnm.fish"; ' - 'command -q brew; or exit 1; command -q fnm; or exit 1; ' - 'node --version; npm --version') account_shell = run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') shells = Path('/etc/shells').read_bytes() run('just', 'init-fish') @@ -108,8 +102,14 @@ def run(*args, success=True): 'Noninteractive setup changed the account shell') require(Path('/etc/shells').read_bytes() == shells, 'Noninteractive setup changed /etc/shells') + run('fish', '--no-config', '-c', + 'source "$HOME/.config/fish/conf.d/00-homebrew.fish"; ' + 'source "$HOME/.config/fish/conf.d/fnm.fish"; ' + 'command -q brew; or exit 1; command -q fnm; or exit 1; ' + 'node --version; npm --version') + before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} - second = run('just', 'stow') + run('just', 'init-pi-extensions') + second = run('bash', 'scripts/init.sh', 'bootstrap', 'stow') + run('just', 'init-pi-extensions') require('→' not in second, 'Second run unexpectedly performed setup work') require('Pi dependencies already installed' in second, 'Second run did not reuse dependencies') for path, state in before.items(): From 2a4780f5f5576472c2358842d8de456d32d6777e Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 12:56:30 -0700 Subject: [PATCH 13/20] Clearly report expected integration test failures --- tests/integration.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integration.py b/tests/integration.py index 5746d454..ccf50c02 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -68,9 +68,13 @@ def run(*args, success=True): result = subprocess.run(args, cwd=repo, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=600) - print(result.stdout, flush=True) - require((result.returncode == 0) == success, - f'Unexpected exit {result.returncode}: {args}') + if (result.returncode == 0) != success: + print(result.stdout, flush=True) + raise AssertionError(f'Unexpected exit {result.returncode}: {args}') + if success: + print(result.stdout, flush=True) + else: + print('✓ Command refused as expected; checking the resulting state.', flush=True) return result.stdout runtime = ('fnm', 'exec', '--using', '24') @@ -124,6 +128,7 @@ def run(*args, success=True): require('move or back up' in output, 'Conflict lacked recovery instructions') require(config.read_text() == 'keep this existing config\n', 'Existing config was overwritten') require(not (conflict_home / '.pi').exists(), 'Conflict preflight partially linked configs') + print('✓ Existing dotfile preserved; no partial links created.', flush=True) print('Real setup integration passed: install, startup, rerun, and conflict preservation.', flush=True) From a6c706ffc8ed45cfbadbca7eed6481b09567a86e Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:02:15 -0700 Subject: [PATCH 14/20] Provide a dedicated bootstrap entry point --- .github/workflows/test-init.yml | 2 +- README.md | 2 +- scripts/bootstrap.sh | 7 +++++++ scripts/init.sh | 5 ++++- tests/integration.py | 4 ++-- 5 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 scripts/bootstrap.sh diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index 46ca736a..453edcb6 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -13,6 +13,6 @@ jobs: - uses: actions/checkout@v7 - name: Install shell linter run: brew install shellcheck - - run: bash -n scripts/init.sh && shellcheck scripts/init.sh + - run: bash -n scripts/bootstrap.sh && bash -n scripts/init.sh && shellcheck -x scripts/*.sh - name: Test real setup and reruns in a disposable home run: python3 tests/integration.py diff --git a/README.md b/README.md index 61cb27f8..72ef1222 100644 --- a/README.md +++ b/README.md @@ -20,5 +20,5 @@ This is where I tweak config files till 4am like a goblin. It's pretty cozy in h ## Setup ```bash -bash scripts/init.sh bootstrap +bash scripts/bootstrap.sh ``` diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 00000000..cf0f8a9c --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/init.sh +ensure_brew +require_tool just just +exec just --justfile "$ROOT/justfile" "${@:-init}" diff --git a/scripts/init.sh b/scripts/init.sh index dd52c01a..4b7b9615 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -249,8 +249,11 @@ init_tailscale() { run 'Add Tailscale app directory to Fish PATH' fish --no-config -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' fi } +if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return +fi + case "${1:-init}" in - bootstrap) ensure_brew; require_tool just just; exec just --justfile "$ROOT/justfile" "${2:-init}" ;; init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; diff --git a/tests/integration.py b/tests/integration.py index ccf50c02..94e81343 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -81,7 +81,7 @@ def run(*args, success=True): require(not Path(env['FNM_DIR']).exists(), 'fnm must start empty') if shutil.which('fnm'): run(*runtime, 'node', '--version', success=False) - run('bash', 'scripts/init.sh', 'bootstrap', 'stow') + run('bash', 'scripts/bootstrap.sh', 'stow') first = run('just', 'init-pi-extensions') require('Install Node 24 with fnm' in first, 'Fresh setup must install Node') require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') @@ -113,7 +113,7 @@ def run(*args, success=True): 'node --version; npm --version') before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} - second = run('bash', 'scripts/init.sh', 'bootstrap', 'stow') + run('just', 'init-pi-extensions') + second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'init-pi-extensions') require('→' not in second, 'Second run unexpectedly performed setup work') require('Pi dependencies already installed' in second, 'Second run did not reuse dependencies') for path, state in before.items(): From 166cf3a731666a46a5cd216af18da1b3f8b1c8cc Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:03:55 -0700 Subject: [PATCH 15/20] Consolidate setup in bootstrap.sh --- .github/workflows/test-init.yml | 2 +- justfile | 18 +-- scripts/bootstrap.sh | 266 ++++++++++++++++++++++++++++++- scripts/init.sh | 267 -------------------------------- 4 files changed, 272 insertions(+), 281 deletions(-) delete mode 100644 scripts/init.sh diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml index 453edcb6..5a055b8f 100644 --- a/.github/workflows/test-init.yml +++ b/.github/workflows/test-init.yml @@ -13,6 +13,6 @@ jobs: - uses: actions/checkout@v7 - name: Install shell linter run: brew install shellcheck - - run: bash -n scripts/bootstrap.sh && bash -n scripts/init.sh && shellcheck -x scripts/*.sh + - run: bash -n scripts/bootstrap.sh && shellcheck scripts/bootstrap.sh - name: Test real setup and reruns in a disposable home run: python3 tests/integration.py diff --git a/justfile b/justfile index 9bf26c21..ef680b7b 100644 --- a/justfile +++ b/justfile @@ -1,29 +1,29 @@ brew: - @bash scripts/init.sh brew + @bash scripts/bootstrap.sh brew init: - @bash scripts/init.sh init + @bash scripts/bootstrap.sh init init-gh-extensions: - @bash scripts/init.sh gh + @bash scripts/bootstrap.sh gh init-insomnia: - @bash scripts/init.sh insomnia + @bash scripts/bootstrap.sh insomnia init-fish: - @bash scripts/init.sh fish + @bash scripts/bootstrap.sh fish init-obscura: - @bash scripts/init.sh obscura + @bash scripts/bootstrap.sh obscura init-pi-extensions: - @bash scripts/init.sh pi + @bash scripts/bootstrap.sh pi init-tailscale-cli: - @bash scripts/init.sh tailscale + @bash scripts/bootstrap.sh tailscale stow: - @bash scripts/init.sh stow + @bash scripts/bootstrap.sh stow test: @python3 tests/integration.py diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index cf0f8a9c..10000c1a 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,7 +1,265 @@ #!/usr/bin/env bash set -Eeuo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." -source scripts/init.sh -ensure_brew +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" +skipped=0 +green='' yellow='' blue='' reset='' +if [[ -t 1 && -z "${NO_COLOR+x}" && "${TERM:-dumb}" != dumb ]]; then + green=$'\033[32m' yellow=$'\033[33m' blue=$'\033[36m' reset=$'\033[0m' +fi +ok() { printf ' %s✓%s %s\n' "$green" "$reset" "$*"; } +warn() { printf ' %s!%s %s\n' "$yellow" "$reset" "$*"; skipped=$((skipped + 1)); } +section() { printf '\n%s%s%s\n' "$blue" "$*" "$reset"; } +fail() { printf ' ✗ %s\n' "$*" >&2; exit 1; } +trap 'printf " ✗ Setup stopped. Fix the error above, then rerun the same command.\n" >&2' ERR + +run() { + local label=$1 log + shift + printf ' → %s\n' "$label" + log=$(mktemp) + if "$@" >"$log" 2>&1; then + rm -f "$log" + ok "$label" + else + cat "$log" >&2 + rm -f "$log" + fail "$label failed. Fix the error above and rerun." + fi +} +need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required. $2"; } + +ensure_brew() { + local candidate installer shellenv + if ! command -v brew >/dev/null 2>&1; then + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi + done + fi + if ! command -v brew >/dev/null 2>&1; then + section 'Homebrew' + need curl 'Install curl, then rerun setup.' + installer=$(mktemp) + if ! curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$installer"; then + rm -f "$installer" + fail 'Could not download Homebrew. Check your network connection and rerun.' + fi + if ! /bin/bash "$installer"; then + rm -f "$installer" + fail 'Homebrew installation failed. Complete the prerequisites printed above and rerun.' + fi + rm -f "$installer" + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi + done + need brew 'Follow the Homebrew shellenv instructions, then rerun.' + fi + shellenv=$(brew shellenv bash) + eval "$shellenv" + export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 +} +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + ensure_brew + run "Install $2 (provides $1)" brew install "$2" + need "$1" "Homebrew installed $2 but $1 is not on PATH; check brew shellenv." + fi +} +init_brew() { + section 'Packages' + ensure_brew + if brew bundle check --no-upgrade --file="$ROOT/homebrew/Brewfile" >/dev/null 2>&1; then + ok 'All Brewfile packages already installed' + else + printf ' → Install missing Brewfile packages\n' + brew bundle install --no-upgrade --file="$ROOT/homebrew/Brewfile" + ok 'Brewfile packages installed' + fi +} +link_configs() { + require_tool stow stow + local preview + preview=$(mktemp) + if ! stow --dir="$ROOT" --target="$HOME" --simulate --verbose "$@" >"$preview" 2>&1; then + cat "$preview" >&2 + rm -f "$preview" + fail 'Dotfile conflicts: move or back up the listed files, then rerun. Existing files were preserved.' + fi + if grep -qE '(LINK|UNLINK|MKDIR|RMDIR):' "$preview"; then + rm -f "$preview" + run "Link configs ($*)" stow --dir="$ROOT" --target="$HOME" "$@" + else + rm -f "$preview" + ok "Configs already linked ($*)" + fi +} +init_stow() { section 'Dotfiles'; link_configs fish gh-dash nvim pi wezterm yazi; } +init_gh() { + section 'GitHub extensions' + require_tool gh gh + require_tool git git + link_configs gh-dash + if ! gh auth status --hostname github.com >/dev/null 2>&1; then + warn 'GitHub extensions skipped: run gh auth login, then just init-gh-extensions' + return + fi + local extensions extension + extensions=$(gh extension list) + for extension in dlvhdr/gh-dash github/gh-stack; do + if printf '%s\n' "$extensions" | grep -qE "(^|[[:space:]])${extension}([[:space:]]|$)"; then + ok "$extension already installed" + else + run "Install $extension" gh extension install "$extension" + fi + done +} +init_pi() { + section 'Pi extensions' + require_tool fnm fnm + local dir fingerprint stamp + local runtime=(fnm exec --using 24) + if "${runtime[@]}" node --version >/dev/null 2>&1; then + ok 'Node 24 already available through fnm' + else + run 'Install Node 24 with fnm (retry with fnm install 24 if needed)' fnm install 24 + fi + if ! "${runtime[@]}" npm --version >/dev/null 2>&1; then + fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just init-pi-extensions.' + fi + dir="$ROOT/pi/.pi/agent/extensions" + stamp="$dir/node_modules/.dots-install" + fingerprint=$(cat "$dir/package.json" "$dir/package-lock.json" | cksum) + fingerprint="$fingerprint $("${runtime[@]}" node --version) $("${runtime[@]}" npm --version) $(uname -sm)" + if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && "${runtime[@]}" npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then + ok 'Pi dependencies already installed (manifests and runtime unchanged)' + else + run 'Install Pi dependencies' "${runtime[@]}" npm ci --prefix "$dir" --include=dev --no-audit --no-fund + printf '%s\n' "$fingerprint" > "$stamp" + fi + link_configs pi +} +init_fish() { + section 'Login shell' + require_tool fish fish + local fish_path current_shell username + fish_path=$(command -v fish) + username=$(id -un) + case "$(uname -s)" in + Darwin) + need dscl 'Use System Settings to change your login shell.' + current_shell=$(dscl . -read "/Users/$username" UserShell | awk '{print $2}') ;; + Linux) + need getent 'Install getent or change your login shell manually.' + current_shell=$(getent passwd "$username" | cut -d: -f7) ;; + *) warn 'Automatic login shell setup is unsupported on this OS'; return ;; + esac + [[ -n "$current_shell" ]] || fail 'Could not determine the account login shell.' + if [[ "$current_shell" == "$fish_path" ]] && grep -qxF "$fish_path" /etc/shells; then + ok 'Fish is already the registered login shell' + return + fi + if [[ ! -t 0 || "${DOTS_NONINTERACTIVE:-0}" == 1 ]]; then + warn 'Login shell change skipped: run just init-fish in an interactive terminal' + return + fi + need chsh 'Install chsh or change your login shell through system settings.' + if ! grep -qxF "$fish_path" /etc/shells; then + need sudo 'Register the Fish path in /etc/shells as an administrator, then rerun.' + printf '%s\n' "$fish_path" | sudo tee -a /etc/shells >/dev/null + ok 'Fish registered in /etc/shells' + fi + if [[ "$current_shell" != "$fish_path" ]]; then + chsh -s "$fish_path" + ok 'Login shell changed to Fish (takes effect on next login)' + fi +} +init_insomnia() { + section 'SwiftBar / Insomnia' + if [[ "$(uname -s)" != Darwin ]]; then warn 'SwiftBar skipped: macOS only'; return; fi + ensure_brew + if ! brew list --cask swiftbar >/dev/null 2>&1; then + run 'Install SwiftBar' brew install --cask swiftbar + else + ok 'SwiftBar already installed' + fi + link_configs swiftbar + local plugins domain plist reload=0 + plugins="$HOME/.config/swiftbar/plugins" + domain="gui/$(id -u)" + plist="$HOME/Library/LaunchAgents/com.wu-json.swiftbar-login.plist" + need defaults 'Run this recipe on macOS.' + need launchctl 'Run this recipe in a macOS desktop session.' + if [[ "$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null || true)" != "$plugins" ]]; then + run 'Set SwiftBar plugin directory' defaults write com.ameba.SwiftBar PluginDirectory -string "$plugins" + reload=1 + else + ok 'SwiftBar plugin directory already configured' + fi + if ! launchctl print "$domain" >/dev/null 2>&1; then + warn 'SwiftBar launch skipped: no GUI session; rerun just init-insomnia from the desktop' + return + fi + if ! launchctl print "$domain/com.wu-json.swiftbar-login" >/dev/null 2>&1; then + run 'Register SwiftBar login agent' launchctl bootstrap "$domain" "$plist" + else + ok 'SwiftBar login agent already registered' + fi + if [[ "$reload" == 1 ]] && pgrep -x SwiftBar >/dev/null 2>&1; then + run 'Quit SwiftBar to apply its plugin directory' osascript -e 'tell application "SwiftBar" to quit' + fi + if ! pgrep -x SwiftBar >/dev/null 2>&1; then + run 'Start SwiftBar' open -a SwiftBar + else + ok 'SwiftBar already running' + fi +} +init_obscura() { + section 'Obscura' + local version=0.1.8 os arch asset tmp + if [[ -x "$HOME/.local/bin/obscura" && -x "$HOME/.local/bin/obscura-worker" ]] && + [[ "$("$HOME/.local/bin/obscura" --version)" == "obscura $version" ]]; then + ok "Obscura $version already installed" + return + fi + need curl 'Install curl, then rerun just init-obscura.' + need tar 'Install tar, then rerun just init-obscura.' + case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) fail 'Unsupported OS' ;; esac + case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64) arch=x86_64 ;; *) fail 'Unsupported architecture' ;; esac + asset="obscura-${arch}-${os}.tar.gz" + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + run "Download Obscura $version" curl -fsSL "https://github.com/h4ckf0r0day/obscura/releases/download/v${version}/${asset}" -o "$tmp/obscura.tar.gz" + tar xzf "$tmp/obscura.tar.gz" -C "$tmp" obscura obscura-worker + mkdir -p "$HOME/.local/bin" + run 'Install Obscura binaries' install -m 755 "$tmp/obscura" "$tmp/obscura-worker" "$HOME/.local/bin/" + rm -rf "$tmp" + trap - EXIT +} +init_tailscale() { + section 'Tailscale CLI' + if [[ "$(uname -s)" != Darwin ]]; then warn 'Tailscale app integration skipped: macOS only'; return; fi + if [[ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then + warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just init-tailscale-cli' + return + fi + require_tool fish fish + if fish --no-config -c "contains -- /Applications/Tailscale.app/Contents/MacOS \$fish_user_paths"; then + ok 'Tailscale app directory already on Fish PATH' + else + run 'Add Tailscale app directory to Fish PATH' fish --no-config -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' + fi +} require_tool just just -exec just --justfile "$ROOT/justfile" "${@:-init}" + +case "${1:-init}" in + init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; + brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; + fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; + *) fail "Unknown setup task: $1" ;; +esac +printf '\n' +if [[ "$skipped" -gt 0 ]]; then + ok "Setup finished with $skipped skipped step(s); follow the instructions above." +else + ok 'Setup complete.' +fi diff --git a/scripts/init.sh b/scripts/init.sh deleted file mode 100644 index 4b7b9615..00000000 --- a/scripts/init.sh +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -cd "$ROOT" -skipped=0 -green='' yellow='' blue='' reset='' -if [[ -t 1 && -z "${NO_COLOR+x}" && "${TERM:-dumb}" != dumb ]]; then - green=$'\033[32m' yellow=$'\033[33m' blue=$'\033[36m' reset=$'\033[0m' -fi -ok() { printf ' %s✓%s %s\n' "$green" "$reset" "$*"; } -warn() { printf ' %s!%s %s\n' "$yellow" "$reset" "$*"; skipped=$((skipped + 1)); } -section() { printf '\n%s%s%s\n' "$blue" "$*" "$reset"; } -fail() { printf ' ✗ %s\n' "$*" >&2; exit 1; } -trap 'printf " ✗ Setup stopped. Fix the error above, then rerun the same command.\n" >&2' ERR - -run() { - local label=$1 log - shift - printf ' → %s\n' "$label" - log=$(mktemp) - if "$@" >"$log" 2>&1; then - rm -f "$log" - ok "$label" - else - cat "$log" >&2 - rm -f "$log" - fail "$label failed. Fix the error above and rerun." - fi -} -need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required. $2"; } - -ensure_brew() { - local candidate installer shellenv - if ! command -v brew >/dev/null 2>&1; then - for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do - if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi - done - fi - if ! command -v brew >/dev/null 2>&1; then - section 'Homebrew' - need curl 'Install curl, then rerun setup.' - installer=$(mktemp) - if ! curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$installer"; then - rm -f "$installer" - fail 'Could not download Homebrew. Check your network connection and rerun.' - fi - if ! /bin/bash "$installer"; then - rm -f "$installer" - fail 'Homebrew installation failed. Complete the prerequisites printed above and rerun.' - fi - rm -f "$installer" - for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do - if [[ -x "$candidate" ]]; then shellenv=$("$candidate" shellenv bash); eval "$shellenv"; break; fi - done - need brew 'Follow the Homebrew shellenv instructions, then rerun.' - fi - shellenv=$(brew shellenv bash) - eval "$shellenv" - export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 -} -require_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - ensure_brew - run "Install $2 (provides $1)" brew install "$2" - need "$1" "Homebrew installed $2 but $1 is not on PATH; check brew shellenv." - fi -} -init_brew() { - section 'Packages' - ensure_brew - if brew bundle check --no-upgrade --file="$ROOT/homebrew/Brewfile" >/dev/null 2>&1; then - ok 'All Brewfile packages already installed' - else - printf ' → Install missing Brewfile packages\n' - brew bundle install --no-upgrade --file="$ROOT/homebrew/Brewfile" - ok 'Brewfile packages installed' - fi -} -link_configs() { - require_tool stow stow - local preview - preview=$(mktemp) - if ! stow --dir="$ROOT" --target="$HOME" --simulate --verbose "$@" >"$preview" 2>&1; then - cat "$preview" >&2 - rm -f "$preview" - fail 'Dotfile conflicts: move or back up the listed files, then rerun. Existing files were preserved.' - fi - if grep -qE '(LINK|UNLINK|MKDIR|RMDIR):' "$preview"; then - rm -f "$preview" - run "Link configs ($*)" stow --dir="$ROOT" --target="$HOME" "$@" - else - rm -f "$preview" - ok "Configs already linked ($*)" - fi -} -init_stow() { section 'Dotfiles'; link_configs fish gh-dash nvim pi wezterm yazi; } -init_gh() { - section 'GitHub extensions' - require_tool gh gh - require_tool git git - link_configs gh-dash - if ! gh auth status --hostname github.com >/dev/null 2>&1; then - warn 'GitHub extensions skipped: run gh auth login, then just init-gh-extensions' - return - fi - local extensions extension - extensions=$(gh extension list) - for extension in dlvhdr/gh-dash github/gh-stack; do - if printf '%s\n' "$extensions" | grep -qE "(^|[[:space:]])${extension}([[:space:]]|$)"; then - ok "$extension already installed" - else - run "Install $extension" gh extension install "$extension" - fi - done -} -init_pi() { - section 'Pi extensions' - require_tool fnm fnm - local dir fingerprint stamp - local runtime=(fnm exec --using 24) - if "${runtime[@]}" node --version >/dev/null 2>&1; then - ok 'Node 24 already available through fnm' - else - run 'Install Node 24 with fnm (retry with fnm install 24 if needed)' fnm install 24 - fi - if ! "${runtime[@]}" npm --version >/dev/null 2>&1; then - fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just init-pi-extensions.' - fi - dir="$ROOT/pi/.pi/agent/extensions" - stamp="$dir/node_modules/.dots-install" - fingerprint=$(cat "$dir/package.json" "$dir/package-lock.json" | cksum) - fingerprint="$fingerprint $("${runtime[@]}" node --version) $("${runtime[@]}" npm --version) $(uname -sm)" - if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && "${runtime[@]}" npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then - ok 'Pi dependencies already installed (manifests and runtime unchanged)' - else - run 'Install Pi dependencies' "${runtime[@]}" npm ci --prefix "$dir" --include=dev --no-audit --no-fund - printf '%s\n' "$fingerprint" > "$stamp" - fi - link_configs pi -} -init_fish() { - section 'Login shell' - require_tool fish fish - local fish_path current_shell username - fish_path=$(command -v fish) - username=$(id -un) - case "$(uname -s)" in - Darwin) - need dscl 'Use System Settings to change your login shell.' - current_shell=$(dscl . -read "/Users/$username" UserShell | awk '{print $2}') ;; - Linux) - need getent 'Install getent or change your login shell manually.' - current_shell=$(getent passwd "$username" | cut -d: -f7) ;; - *) warn 'Automatic login shell setup is unsupported on this OS'; return ;; - esac - [[ -n "$current_shell" ]] || fail 'Could not determine the account login shell.' - if [[ "$current_shell" == "$fish_path" ]] && grep -qxF "$fish_path" /etc/shells; then - ok 'Fish is already the registered login shell' - return - fi - if [[ ! -t 0 || "${DOTS_NONINTERACTIVE:-0}" == 1 ]]; then - warn 'Login shell change skipped: run just init-fish in an interactive terminal' - return - fi - need chsh 'Install chsh or change your login shell through system settings.' - if ! grep -qxF "$fish_path" /etc/shells; then - need sudo 'Register the Fish path in /etc/shells as an administrator, then rerun.' - printf '%s\n' "$fish_path" | sudo tee -a /etc/shells >/dev/null - ok 'Fish registered in /etc/shells' - fi - if [[ "$current_shell" != "$fish_path" ]]; then - chsh -s "$fish_path" - ok 'Login shell changed to Fish (takes effect on next login)' - fi -} -init_insomnia() { - section 'SwiftBar / Insomnia' - if [[ "$(uname -s)" != Darwin ]]; then warn 'SwiftBar skipped: macOS only'; return; fi - ensure_brew - if ! brew list --cask swiftbar >/dev/null 2>&1; then - run 'Install SwiftBar' brew install --cask swiftbar - else - ok 'SwiftBar already installed' - fi - link_configs swiftbar - local plugins domain plist reload=0 - plugins="$HOME/.config/swiftbar/plugins" - domain="gui/$(id -u)" - plist="$HOME/Library/LaunchAgents/com.wu-json.swiftbar-login.plist" - need defaults 'Run this recipe on macOS.' - need launchctl 'Run this recipe in a macOS desktop session.' - if [[ "$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null || true)" != "$plugins" ]]; then - run 'Set SwiftBar plugin directory' defaults write com.ameba.SwiftBar PluginDirectory -string "$plugins" - reload=1 - else - ok 'SwiftBar plugin directory already configured' - fi - if ! launchctl print "$domain" >/dev/null 2>&1; then - warn 'SwiftBar launch skipped: no GUI session; rerun just init-insomnia from the desktop' - return - fi - if ! launchctl print "$domain/com.wu-json.swiftbar-login" >/dev/null 2>&1; then - run 'Register SwiftBar login agent' launchctl bootstrap "$domain" "$plist" - else - ok 'SwiftBar login agent already registered' - fi - if [[ "$reload" == 1 ]] && pgrep -x SwiftBar >/dev/null 2>&1; then - run 'Quit SwiftBar to apply its plugin directory' osascript -e 'tell application "SwiftBar" to quit' - fi - if ! pgrep -x SwiftBar >/dev/null 2>&1; then - run 'Start SwiftBar' open -a SwiftBar - else - ok 'SwiftBar already running' - fi -} -init_obscura() { - section 'Obscura' - local version=0.1.8 os arch asset tmp - if [[ -x "$HOME/.local/bin/obscura" && -x "$HOME/.local/bin/obscura-worker" ]] && - [[ "$("$HOME/.local/bin/obscura" --version)" == "obscura $version" ]]; then - ok "Obscura $version already installed" - return - fi - need curl 'Install curl, then rerun just init-obscura.' - need tar 'Install tar, then rerun just init-obscura.' - case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) fail 'Unsupported OS' ;; esac - case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64) arch=x86_64 ;; *) fail 'Unsupported architecture' ;; esac - asset="obscura-${arch}-${os}.tar.gz" - tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' EXIT - run "Download Obscura $version" curl -fsSL "https://github.com/h4ckf0r0day/obscura/releases/download/v${version}/${asset}" -o "$tmp/obscura.tar.gz" - tar xzf "$tmp/obscura.tar.gz" -C "$tmp" obscura obscura-worker - mkdir -p "$HOME/.local/bin" - run 'Install Obscura binaries' install -m 755 "$tmp/obscura" "$tmp/obscura-worker" "$HOME/.local/bin/" - rm -rf "$tmp" - trap - EXIT -} -init_tailscale() { - section 'Tailscale CLI' - if [[ "$(uname -s)" != Darwin ]]; then warn 'Tailscale app integration skipped: macOS only'; return; fi - if [[ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then - warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just init-tailscale-cli' - return - fi - require_tool fish fish - if fish --no-config -c "contains -- /Applications/Tailscale.app/Contents/MacOS \$fish_user_paths"; then - ok 'Tailscale app directory already on Fish PATH' - else - run 'Add Tailscale app directory to Fish PATH' fish --no-config -c 'fish_add_path -U /Applications/Tailscale.app/Contents/MacOS' - fi -} -if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then - return -fi - -case "${1:-init}" in - init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; - brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; - fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; - *) fail "Unknown setup task: $1" ;; -esac -printf '\n' -if [[ "$skipped" -gt 0 ]]; then - ok "Setup finished with $skipped skipped step(s); follow the instructions above." -else - ok 'Setup complete.' -fi From 2e45723edc90440a45cb3c50feacd67270ac8886 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:05:05 -0700 Subject: [PATCH 16/20] Rename init recipes to bootstrap --- justfile | 16 ++++++++-------- scripts/bootstrap.sh | 38 +++++++++++++++++++------------------- tests/integration.py | 6 +++--- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/justfile b/justfile index ef680b7b..7dfa2f98 100644 --- a/justfile +++ b/justfile @@ -1,25 +1,25 @@ brew: @bash scripts/bootstrap.sh brew -init: - @bash scripts/bootstrap.sh init +bootstrap: + @bash scripts/bootstrap.sh bootstrap -init-gh-extensions: +bootstrap-gh-extensions: @bash scripts/bootstrap.sh gh -init-insomnia: +bootstrap-insomnia: @bash scripts/bootstrap.sh insomnia -init-fish: +bootstrap-fish: @bash scripts/bootstrap.sh fish -init-obscura: +bootstrap-obscura: @bash scripts/bootstrap.sh obscura -init-pi-extensions: +bootstrap-pi-extensions: @bash scripts/bootstrap.sh pi -init-tailscale-cli: +bootstrap-tailscale-cli: @bash scripts/bootstrap.sh tailscale stow: diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 10000c1a..60f2336a 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -65,7 +65,7 @@ require_tool() { need "$1" "Homebrew installed $2 but $1 is not on PATH; check brew shellenv." fi } -init_brew() { +bootstrap_brew() { section 'Packages' ensure_brew if brew bundle check --no-upgrade --file="$ROOT/homebrew/Brewfile" >/dev/null 2>&1; then @@ -93,14 +93,14 @@ link_configs() { ok "Configs already linked ($*)" fi } -init_stow() { section 'Dotfiles'; link_configs fish gh-dash nvim pi wezterm yazi; } -init_gh() { +bootstrap_stow() { section 'Dotfiles'; link_configs fish gh-dash nvim pi wezterm yazi; } +bootstrap_gh() { section 'GitHub extensions' require_tool gh gh require_tool git git link_configs gh-dash if ! gh auth status --hostname github.com >/dev/null 2>&1; then - warn 'GitHub extensions skipped: run gh auth login, then just init-gh-extensions' + warn 'GitHub extensions skipped: run gh auth login, then just bootstrap-gh-extensions' return fi local extensions extension @@ -113,7 +113,7 @@ init_gh() { fi done } -init_pi() { +bootstrap_pi() { section 'Pi extensions' require_tool fnm fnm local dir fingerprint stamp @@ -124,7 +124,7 @@ init_pi() { run 'Install Node 24 with fnm (retry with fnm install 24 if needed)' fnm install 24 fi if ! "${runtime[@]}" npm --version >/dev/null 2>&1; then - fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just init-pi-extensions.' + fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just bootstrap-pi-extensions.' fi dir="$ROOT/pi/.pi/agent/extensions" stamp="$dir/node_modules/.dots-install" @@ -138,7 +138,7 @@ init_pi() { fi link_configs pi } -init_fish() { +bootstrap_fish() { section 'Login shell' require_tool fish fish local fish_path current_shell username @@ -159,7 +159,7 @@ init_fish() { return fi if [[ ! -t 0 || "${DOTS_NONINTERACTIVE:-0}" == 1 ]]; then - warn 'Login shell change skipped: run just init-fish in an interactive terminal' + warn 'Login shell change skipped: run just bootstrap-fish in an interactive terminal' return fi need chsh 'Install chsh or change your login shell through system settings.' @@ -173,7 +173,7 @@ init_fish() { ok 'Login shell changed to Fish (takes effect on next login)' fi } -init_insomnia() { +bootstrap_insomnia() { section 'SwiftBar / Insomnia' if [[ "$(uname -s)" != Darwin ]]; then warn 'SwiftBar skipped: macOS only'; return; fi ensure_brew @@ -196,7 +196,7 @@ init_insomnia() { ok 'SwiftBar plugin directory already configured' fi if ! launchctl print "$domain" >/dev/null 2>&1; then - warn 'SwiftBar launch skipped: no GUI session; rerun just init-insomnia from the desktop' + warn 'SwiftBar launch skipped: no GUI session; rerun just bootstrap-insomnia from the desktop' return fi if ! launchctl print "$domain/com.wu-json.swiftbar-login" >/dev/null 2>&1; then @@ -213,7 +213,7 @@ init_insomnia() { ok 'SwiftBar already running' fi } -init_obscura() { +bootstrap_obscura() { section 'Obscura' local version=0.1.8 os arch asset tmp if [[ -x "$HOME/.local/bin/obscura" && -x "$HOME/.local/bin/obscura-worker" ]] && @@ -221,8 +221,8 @@ init_obscura() { ok "Obscura $version already installed" return fi - need curl 'Install curl, then rerun just init-obscura.' - need tar 'Install tar, then rerun just init-obscura.' + need curl 'Install curl, then rerun just bootstrap-obscura.' + need tar 'Install tar, then rerun just bootstrap-obscura.' case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) fail 'Unsupported OS' ;; esac case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64) arch=x86_64 ;; *) fail 'Unsupported architecture' ;; esac asset="obscura-${arch}-${os}.tar.gz" @@ -235,11 +235,11 @@ init_obscura() { rm -rf "$tmp" trap - EXIT } -init_tailscale() { +bootstrap_tailscale() { section 'Tailscale CLI' if [[ "$(uname -s)" != Darwin ]]; then warn 'Tailscale app integration skipped: macOS only'; return; fi if [[ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then - warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just init-tailscale-cli' + warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just bootstrap-tailscale-cli' return fi require_tool fish fish @@ -251,10 +251,10 @@ init_tailscale() { } require_tool just just -case "${1:-init}" in - init) init_brew; init_stow; init_gh; init_pi; init_fish; init_insomnia ;; - brew) init_brew ;; stow) init_stow ;; gh) init_gh ;; pi) init_pi ;; - fish) init_fish ;; insomnia) init_insomnia ;; obscura) init_obscura ;; tailscale) init_tailscale ;; +case "${1:-bootstrap}" in + bootstrap) bootstrap_brew; bootstrap_stow; bootstrap_gh; bootstrap_pi; bootstrap_fish; bootstrap_insomnia ;; + brew) bootstrap_brew ;; stow) bootstrap_stow ;; gh) bootstrap_gh ;; pi) bootstrap_pi ;; + fish) bootstrap_fish ;; insomnia) bootstrap_insomnia ;; obscura) bootstrap_obscura ;; tailscale) bootstrap_tailscale ;; *) fail "Unknown setup task: $1" ;; esac printf '\n' diff --git a/tests/integration.py b/tests/integration.py index 94e81343..39068bb9 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -82,7 +82,7 @@ def run(*args, success=True): if shutil.which('fnm'): run(*runtime, 'node', '--version', success=False) run('bash', 'scripts/bootstrap.sh', 'stow') - first = run('just', 'init-pi-extensions') + first = run('just', 'bootstrap-pi-extensions') require('Install Node 24 with fnm' in first, 'Fresh setup must install Node') require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') require(run(*runtime, 'node', '--version').strip().startswith('v24.'), 'Expected Node 24') @@ -101,7 +101,7 @@ def run(*args, success=True): account_shell = run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') shells = Path('/etc/shells').read_bytes() - run('just', 'init-fish') + run('just', 'bootstrap-fish') require(run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') == account_shell, 'Noninteractive setup changed the account shell') require(Path('/etc/shells').read_bytes() == shells, 'Noninteractive setup changed /etc/shells') @@ -113,7 +113,7 @@ def run(*args, success=True): 'node --version; npm --version') before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} - second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'init-pi-extensions') + second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'bootstrap-pi-extensions') require('→' not in second, 'Second run unexpectedly performed setup work') require('Pi dependencies already installed' in second, 'Second run did not reuse dependencies') for path, state in before.items(): From b26f7c200f249b1690723efcb4563563e0331ca2 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:05:40 -0700 Subject: [PATCH 17/20] Use one bootstrap recipe with an optional target --- justfile | 28 +++------------------------- scripts/bootstrap.sh | 20 ++++++++++---------- tests/integration.py | 8 ++++---- 3 files changed, 17 insertions(+), 39 deletions(-) diff --git a/justfile b/justfile index 7dfa2f98..c40a8c77 100644 --- a/justfile +++ b/justfile @@ -1,29 +1,7 @@ -brew: - @bash scripts/bootstrap.sh brew +set positional-arguments -bootstrap: - @bash scripts/bootstrap.sh bootstrap - -bootstrap-gh-extensions: - @bash scripts/bootstrap.sh gh - -bootstrap-insomnia: - @bash scripts/bootstrap.sh insomnia - -bootstrap-fish: - @bash scripts/bootstrap.sh fish - -bootstrap-obscura: - @bash scripts/bootstrap.sh obscura - -bootstrap-pi-extensions: - @bash scripts/bootstrap.sh pi - -bootstrap-tailscale-cli: - @bash scripts/bootstrap.sh tailscale - -stow: - @bash scripts/bootstrap.sh stow +bootstrap target="all": + @bash scripts/bootstrap.sh "$1" test: @python3 tests/integration.py diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 60f2336a..979067d7 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -100,7 +100,7 @@ bootstrap_gh() { require_tool git git link_configs gh-dash if ! gh auth status --hostname github.com >/dev/null 2>&1; then - warn 'GitHub extensions skipped: run gh auth login, then just bootstrap-gh-extensions' + warn 'GitHub extensions skipped: run gh auth login, then just bootstrap gh' return fi local extensions extension @@ -124,7 +124,7 @@ bootstrap_pi() { run 'Install Node 24 with fnm (retry with fnm install 24 if needed)' fnm install 24 fi if ! "${runtime[@]}" npm --version >/dev/null 2>&1; then - fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just bootstrap-pi-extensions.' + fail 'Node 24/npm is unavailable through fnm. Repair it with fnm install 24, then rerun just bootstrap pi.' fi dir="$ROOT/pi/.pi/agent/extensions" stamp="$dir/node_modules/.dots-install" @@ -159,7 +159,7 @@ bootstrap_fish() { return fi if [[ ! -t 0 || "${DOTS_NONINTERACTIVE:-0}" == 1 ]]; then - warn 'Login shell change skipped: run just bootstrap-fish in an interactive terminal' + warn 'Login shell change skipped: run just bootstrap fish in an interactive terminal' return fi need chsh 'Install chsh or change your login shell through system settings.' @@ -196,7 +196,7 @@ bootstrap_insomnia() { ok 'SwiftBar plugin directory already configured' fi if ! launchctl print "$domain" >/dev/null 2>&1; then - warn 'SwiftBar launch skipped: no GUI session; rerun just bootstrap-insomnia from the desktop' + warn 'SwiftBar launch skipped: no GUI session; rerun just bootstrap insomnia from the desktop' return fi if ! launchctl print "$domain/com.wu-json.swiftbar-login" >/dev/null 2>&1; then @@ -221,8 +221,8 @@ bootstrap_obscura() { ok "Obscura $version already installed" return fi - need curl 'Install curl, then rerun just bootstrap-obscura.' - need tar 'Install tar, then rerun just bootstrap-obscura.' + need curl 'Install curl, then rerun just bootstrap obscura.' + need tar 'Install tar, then rerun just bootstrap obscura.' case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) fail 'Unsupported OS' ;; esac case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64) arch=x86_64 ;; *) fail 'Unsupported architecture' ;; esac asset="obscura-${arch}-${os}.tar.gz" @@ -239,7 +239,7 @@ bootstrap_tailscale() { section 'Tailscale CLI' if [[ "$(uname -s)" != Darwin ]]; then warn 'Tailscale app integration skipped: macOS only'; return; fi if [[ ! -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then - warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just bootstrap-tailscale-cli' + warn 'Tailscale CLI skipped: install Tailscale.app, then rerun just bootstrap tailscale' return fi require_tool fish fish @@ -251,11 +251,11 @@ bootstrap_tailscale() { } require_tool just just -case "${1:-bootstrap}" in - bootstrap) bootstrap_brew; bootstrap_stow; bootstrap_gh; bootstrap_pi; bootstrap_fish; bootstrap_insomnia ;; +case "${1:-all}" in + all) bootstrap_brew; bootstrap_stow; bootstrap_gh; bootstrap_pi; bootstrap_fish; bootstrap_insomnia ;; brew) bootstrap_brew ;; stow) bootstrap_stow ;; gh) bootstrap_gh ;; pi) bootstrap_pi ;; fish) bootstrap_fish ;; insomnia) bootstrap_insomnia ;; obscura) bootstrap_obscura ;; tailscale) bootstrap_tailscale ;; - *) fail "Unknown setup task: $1" ;; + *) fail "Unknown setup target: $1. Use all, brew, stow, gh, pi, fish, insomnia, obscura, or tailscale." ;; esac printf '\n' if [[ "$skipped" -gt 0 ]]; then diff --git a/tests/integration.py b/tests/integration.py index 39068bb9..693add78 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -82,7 +82,7 @@ def run(*args, success=True): if shutil.which('fnm'): run(*runtime, 'node', '--version', success=False) run('bash', 'scripts/bootstrap.sh', 'stow') - first = run('just', 'bootstrap-pi-extensions') + first = run('just', 'bootstrap', 'pi') require('Install Node 24 with fnm' in first, 'Fresh setup must install Node') require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') require(run(*runtime, 'node', '--version').strip().startswith('v24.'), 'Expected Node 24') @@ -101,7 +101,7 @@ def run(*args, success=True): account_shell = run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') shells = Path('/etc/shells').read_bytes() - run('just', 'bootstrap-fish') + run('just', 'bootstrap', 'fish') require(run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') == account_shell, 'Noninteractive setup changed the account shell') require(Path('/etc/shells').read_bytes() == shells, 'Noninteractive setup changed /etc/shells') @@ -113,7 +113,7 @@ def run(*args, success=True): 'node --version; npm --version') before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} - second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'bootstrap-pi-extensions') + second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'bootstrap', 'pi') require('→' not in second, 'Second run unexpectedly performed setup work') require('Pi dependencies already installed' in second, 'Second run did not reuse dependencies') for path, state in before.items(): @@ -124,7 +124,7 @@ def run(*args, success=True): config.parent.mkdir(parents=True) config.write_text('keep this existing config\n') env['HOME'] = str(conflict_home) - output = run('just', 'stow', success=False) + output = run('just', 'bootstrap', 'stow', success=False) require('move or back up' in output, 'Conflict lacked recovery instructions') require(config.read_text() == 'keep this existing config\n', 'Existing config was overwritten') require(not (conflict_home / '.pi').exists(), 'Conflict preflight partially linked configs') From 08b21f687c549faec139dce9b6a65ccfa8640f01 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:09:01 -0700 Subject: [PATCH 18/20] Declare Codex as a cask and validate Brewfile formulas in CI --- homebrew/Brewfile | 2 +- tests/integration.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homebrew/Brewfile b/homebrew/Brewfile index 00bcc2cf..950ac6d3 100644 --- a/homebrew/Brewfile +++ b/homebrew/Brewfile @@ -2,7 +2,6 @@ brew "aqua" brew "oven-sh/bun/bun" brew "cloudflared" brew "cmake" -brew "codex" brew "d2" brew "eza" brew "fd" @@ -38,6 +37,7 @@ brew "zoxide" if OS.mac? cask "audacity" cask "claude-code@latest" + cask "codex" cask "handy" cask "orbstack" cask "raycast" diff --git a/tests/integration.py b/tests/integration.py index 693add78..a7fa4736 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -63,7 +63,7 @@ def main(): 'HOMEBREW_NO_INSTALL_CLEANUP': '1', } - def run(*args, success=True): + def run(*args, success=True, show_output=True): print('→ ' + ' '.join(args), flush=True) result = subprocess.run(args, cwd=repo, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -72,11 +72,19 @@ def run(*args, success=True): print(result.stdout, flush=True) raise AssertionError(f'Unexpected exit {result.returncode}: {args}') if success: - print(result.stdout, flush=True) + if show_output: + print(result.stdout, flush=True) else: print('✓ Command refused as expected; checking the resulting state.', flush=True) return result.stdout + formulae = run('brew', 'bundle', 'list', '--formula', + '--file=homebrew/Brewfile', show_output=False).splitlines() + for tap in sorted({name.rsplit('/', 1)[0] for name in formulae if '/' in name}): + run('brew', 'tap', tap) + run('brew', 'info', '--formula', '--json=v2', *formulae, show_output=False) + print('✓ Brewfile formula names resolve in Homebrew.', flush=True) + runtime = ('fnm', 'exec', '--using', '24') require(not Path(env['FNM_DIR']).exists(), 'fnm must start empty') if shutil.which('fnm'): From d99fd63a841c1de600dd527ed104ce925a57ab8c Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:10:38 -0700 Subject: [PATCH 19/20] Keep Homebrew diagnostics out of parsed package names --- tests/integration.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration.py b/tests/integration.py index a7fa4736..8fee3f75 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -66,17 +66,18 @@ def main(): def run(*args, success=True, show_output=True): print('→ ' + ' '.join(args), flush=True) result = subprocess.run(args, cwd=repo, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=600) + output = result.stdout + result.stderr if (result.returncode == 0) != success: - print(result.stdout, flush=True) + print(output, flush=True) raise AssertionError(f'Unexpected exit {result.returncode}: {args}') if success: if show_output: - print(result.stdout, flush=True) + print(output, flush=True) else: print('✓ Command refused as expected; checking the resulting state.', flush=True) - return result.stdout + return result.stdout if success else output formulae = run('brew', 'bundle', 'list', '--formula', '--file=homebrew/Brewfile', show_output=False).splitlines() From 587fe14d895e60f97aab804a26c765f54e60b3c8 Mon Sep 17 00:00:00 2001 From: wu-json Date: Sat, 26 Sep 2026 13:14:41 -0700 Subject: [PATCH 20/20] Check Pi dev dependencies even in production mode --- scripts/bootstrap.sh | 2 +- tests/integration.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 979067d7..8bb1873c 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -130,7 +130,7 @@ bootstrap_pi() { stamp="$dir/node_modules/.dots-install" fingerprint=$(cat "$dir/package.json" "$dir/package-lock.json" | cksum) fingerprint="$fingerprint $("${runtime[@]}" node --version) $("${runtime[@]}" npm --version) $(uname -sm)" - if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && "${runtime[@]}" npm ls --prefix "$dir" --depth=0 >/dev/null 2>&1; then + if [[ -f "$stamp" && "$(cat "$stamp")" == "$fingerprint" ]] && "${runtime[@]}" npm ls --prefix "$dir" --depth=0 --include=dev >/dev/null 2>&1; then ok 'Pi dependencies already installed (manifests and runtime unchanged)' else run 'Install Pi dependencies' "${runtime[@]}" npm ci --prefix "$dir" --include=dev --no-audit --no-fund diff --git a/tests/integration.py b/tests/integration.py index 8fee3f75..6fe63d35 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -96,7 +96,7 @@ def run(*args, success=True, show_output=True): require('Install Pi dependencies' in first, 'Fresh setup must install dependencies') require(run(*runtime, 'node', '--version').strip().startswith('v24.'), 'Expected Node 24') pi = repo / 'pi/.pi/agent/extensions' - run(*runtime, 'npm', 'ls', '--prefix', str(pi), '--depth=0') + run(*runtime, 'npm', 'ls', '--prefix', str(pi), '--depth=0', '--include=dev') run(*runtime, 'node', '--input-type=module', '-e', 'const {createRequire} = await import("node:module"); ' 'const require = createRequire(process.argv[1]); ' @@ -121,6 +121,7 @@ def run(*args, success=True, show_output=True): 'command -q brew; or exit 1; command -q fnm; or exit 1; ' 'node --version; npm --version') + env['NODE_ENV'] = 'production' before = {str(path): snapshot(path) for path in (home, pi / 'node_modules')} second = run('bash', 'scripts/bootstrap.sh', 'stow') + run('just', 'bootstrap', 'pi') require('→' not in second, 'Second run unexpectedly performed setup work') @@ -128,6 +129,21 @@ def run(*args, success=True, show_output=True): for path, state in before.items(): require(snapshot(Path(path)) == state, f'Rerun changed installed state: {path}') + missing_dependency = pi / 'node_modules/typebox' + stamp = pi / 'node_modules/.dots-install' + original_stamp = stamp.read_bytes() + shutil.rmtree(missing_dependency) + require(stamp.read_bytes() == original_stamp, 'Regression needs an unchanged install stamp') + run(*runtime, 'npm', 'ls', '--prefix', str(pi), '--depth=0', '--include=dev', success=False) + repaired = run('just', 'bootstrap', 'pi') + require('Install Pi dependencies' in repaired, 'Production mode hid a missing dev dependency') + require((missing_dependency / 'package.json').is_file(), 'Missing dependency was not restored') + run(*runtime, 'npm', 'ls', '--prefix', str(pi), '--depth=0', '--include=dev') + repaired_state = snapshot(pi / 'node_modules') + require('→' not in run('just', 'bootstrap', 'pi'), 'Repaired dependencies were reinstalled') + require(snapshot(pi / 'node_modules') == repaired_state, 'Repaired dependencies changed on rerun') + print('✓ Missing dev dependency repaired under NODE_ENV=production; rerun unchanged.', flush=True) + conflict_home = base / 'conflicting home' config = conflict_home / '.config/fish/config.fish' config.parent.mkdir(parents=True)