diff --git a/.github/workflows/test-init.yml b/.github/workflows/test-init.yml new file mode 100644 index 00000000..5a055b8f --- /dev/null +++ b/.github/workflows/test-init.yml @@ -0,0 +1,18 @@ +name: Test setup +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Install shell linter + run: brew install shellcheck + - 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/.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..72ef1222 100644 --- a/README.md +++ b/README.md @@ -18,27 +18,7 @@ This is where I tweak config files till 4am like a goblin. It's pretty cozy in h - **[yazi](https://yazi-rs.github.io/)** ## Setup + ```bash -brew install just -just init +bash scripts/bootstrap.sh ``` - -### GitHub dashboard - -`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. - -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`. - -### Insomnia - -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. 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..0fcb54a8 --- /dev/null +++ b/fish/.config/fish/conf.d/00-homebrew.fish @@ -0,0 +1,10 @@ +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..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" @@ -35,12 +34,15 @@ 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 "codex" + 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..c40a8c77 100644 --- a/justfile +++ b/justfile @@ -1,79 +1,7 @@ -brew_prefix := if os() == "macos" { "/opt/homebrew" } else { "/home/linuxbrew/.linuxbrew" } +set positional-arguments -brew: - brew bundle install --file=homebrew/Brewfile +bootstrap target="all": + @bash scripts/bootstrap.sh "$1" -init: brew init-gh-extensions init-pi-extensions init-fish init-insomnia - @echo "✓ Initialization complete!" - -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 - -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" - -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 - -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)" - -init-pi-extensions: stow - npm ci --prefix pi/.pi/agent/extensions - -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" - -stow: - stow -t ~ fish - stow -t ~ gh-dash - stow -t ~ nvim - stow -t ~ pi - stow -t ~ wezterm - stow -t ~ yazi +test: + @python3 tests/integration.py diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 00000000..8bb1873c --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,265 @@ +#!/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 +} +bootstrap_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 +} +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 bootstrap gh' + 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 +} +bootstrap_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 bootstrap pi.' + 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 --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 + printf '%s\n' "$fingerprint" > "$stamp" + fi + link_configs pi +} +bootstrap_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 bootstrap 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 +} +bootstrap_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 bootstrap 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 +} +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" ]] && + [[ "$("$HOME/.local/bin/obscura" --version)" == "obscura $version" ]]; then + 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.' + 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 +} +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' + 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 + +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 target: $1. Use all, brew, stow, gh, pi, fish, insomnia, obscura, or tailscale." ;; +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/integration.py b/tests/integration.py new file mode 100644 index 00000000..6fe63d35 --- /dev/null +++ b/tests/integration.py @@ -0,0 +1,161 @@ +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.') + + 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, show_output=True): + print('→ ' + ' '.join(args), flush=True) + result = subprocess.run(args, cwd=repo, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, timeout=600) + output = result.stdout + result.stderr + if (result.returncode == 0) != success: + print(output, flush=True) + raise AssertionError(f'Unexpected exit {result.returncode}: {args}') + if success: + if show_output: + print(output, flush=True) + else: + print('✓ Command refused as expected; checking the resulting state.', flush=True) + return result.stdout if success else output + + 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'): + run(*runtime, 'node', '--version', success=False) + run('bash', 'scripts/bootstrap.sh', 'stow') + 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') + pi = repo / 'pi/.pi/agent/extensions' + 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]); ' + '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}') + + account_shell = run('dscl', '.', '-read', '/Users/' + run('id', '-un').strip(), 'UserShell') + shells = Path('/etc/shells').read_bytes() + 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') + + 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') + + 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') + 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}') + + 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) + config.write_text('keep this existing config\n') + env['HOME'] = str(conflict_home) + 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') + print('✓ Existing dotfile preserved; no partial links created.', flush=True) + print('Real setup integration passed: install, startup, rerun, and conflict preservation.', flush=True) + + +if __name__ == '__main__': + main()