Add Nix-based Mac provisioning with nix-darwin and home-manager - #9
Add Nix-based Mac provisioning with nix-darwin and home-manager#9tubone24 wants to merge 10 commits into
Conversation
Replace Ansible-based provisioning with a declarative Nix configuration: - flake.nix: Main entry point with nix-darwin + home-manager integration - nix/darwin.nix: System-level Nix settings, fonts, and Touch ID sudo - nix/macos.nix: macOS defaults (Dock, Finder, trackpad, screenshots) - nix/homebrew.nix: Homebrew casks for GUI apps and Mac App Store apps - nix/packages.nix: CLI tools (awscli, jq, htop, nmap, ffmpeg, etc.) - nix/languages.nix: Programming languages (Node/fnm, Python/pyenv, Ruby/rbenv, Go, Rust, Deno, Nim, Lua) - nix/git.nix: Git config with SSH key generation - nix/shell.nix: Zsh with autosuggestions, syntax highlighting, tmux, direnv - nix/editors.nix: Neovim with AstroNvim, dotfiles setup - nix/home.nix: Home-manager entry point - setup.sh: Bootstrap script for fresh Mac setup - Makefile: Added nix-setup, nix-switch, nix-update targets https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
- Replace macOS-11 (retired) with macOS-14 and macOS-15 images - Replace Ansible+Serverspec CI with Nix package verification - Add ci/verify-nix-packages.sh: checks all nixpkgs packages resolve - Add ci/verify-homebrew.sh: checks all brew formulas/casks exist - Add checks output to flake.nix for nix flake check support - Pipeline steps: install Nix, evaluate flake, build system closure, verify individual nix packages, verify homebrew definitions https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
Each Azure Pipelines script step runs in a new shell, so Nix profile sourcing from the install step is not preserved. Add ci/nix-env.sh as a shared script that adds Nix binary paths to PATH and sources the nix-daemon profile. All CI steps now source this before running Nix. https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
Determinate Systems installer dropped x86_64-darwin support, causing 404 errors on Azure Pipelines macOS images (Intel-based). Switch to the official nixos.org installer with --no-daemon mode. - azure-pipelines.yml: use official installer, enable flakes via nix.conf - ci/nix-env.sh: prioritize ~/.nix-profile path for official installer - setup.sh: use official installer for user-facing setup too https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
--no-daemon installs are no longer supported on Darwin/macOS as of Nix 2.34.x. Switch to --daemon mode with --yes for non-interactive CI. - azure-pipelines.yml: use --daemon --yes for multi-user install - ci/nix-env.sh: prioritize /nix/var/nix/profiles/default for daemon mode - setup.sh: use --daemon mode for user-facing setup https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
There was a problem hiding this comment.
Pull request overview
This PR adds a Nix flake-based macOS provisioning workflow built on nix-darwin + home-manager, plus CI verification scripts and pipeline updates to validate the Nix/Homebrew definitions.
Changes:
- Introduces
flake.nixwith nix-darwin + home-manager configurations for Apple Silicon and x86_64 macOS. - Adds nix-darwin modules for macOS defaults, Homebrew casks/formulas, and user-level home-manager setup (packages, languages, shell, git, editors).
- Adds bootstrap (
setup.sh), CI verification scripts, and updates Azure Pipelines + Makefile targets for Nix workflows.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| setup.sh | Bootstraps Nix + Homebrew and applies the selected nix-darwin flake configuration. |
| flake.nix | Defines nixpkgs/nix-darwin/home-manager inputs and darwinConfigurations outputs (aarch64 + x86_64). |
| nix/darwin.nix | Core nix-darwin system config (Nix settings, system packages, hostname, user, fonts, TouchID sudo). |
| nix/macos.nix | Declarative macOS defaults (Dock/Finder/Trackpad/Screenshot/Menu bar/NSGlobalDomain). |
| nix/homebrew.nix | nix-darwin Homebrew integration: taps, brews, casks, and MAS apps. |
| nix/home.nix | Home-manager entrypoint importing user-level modules and setting HM state/version. |
| nix/packages.nix | Home-manager CLI/system tool package set. |
| nix/languages.nix | Home-manager language runtimes/version managers package set. |
| nix/git.nix | Home-manager Git configuration and activation-time SSH key generation. |
| nix/shell.nix | Home-manager zsh/tmux/direnv configuration and shell init integrations. |
| nix/editors.nix | Home-manager Neovim setup plus activation-time cloning of editor config and dotfiles. |
| ci/nix-env.sh | Helper to put Nix on PATH for CI scripts. |
| ci/verify-nix-packages.sh | CI script to verify referenced nixpkgs attributes resolve/build (dry-run). |
| ci/verify-homebrew.sh | CI script to verify referenced Homebrew taps/formulas/casks exist. |
| azure-pipelines.yml | Replaces Ansible/Serverspec job with Nix flake evaluation/build + verification scripts on macOS runners. |
| Makefile | Adds Nix-oriented targets (nix-setup, nix-switch, nix-update, etc.). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| home.activation.generateSshKey = lib.hm.dag.entryAfter [ "writeBoundary" ] '' | ||
| if [ ! -f "$HOME/.ssh/id_ed25519" ]; then | ||
| mkdir -p "$HOME/.ssh" | ||
| ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519" -N "" -C "tubo.yyyuuu@gmail.com" |
There was a problem hiding this comment.
This generates an SSH private key with an empty passphrase (-N "") during activation. That creates an unencrypted key by default, which is a security risk on developer machines and can be surprising behavior for a provisioning run. Consider requiring an explicit opt-in (flag/env var), prompting the user, or generating keys with a passphrase (or skipping key generation entirely).
| ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519" -N "" -C "tubo.yyyuuu@gmail.com" | |
| if [ -n "${SSH_KEY_PASSPHRASE-}" ]; then | |
| # Generate key protected with the provided passphrase | |
| ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519" -N "$SSH_KEY_PASSPHRASE" -C "tubo.yyyuuu@gmail.com" | |
| elif [ "${GENERATE_UNENCRYPTED_SSH_KEY:-}" = "1" ]; then | |
| # Explicitly opted-in to generating an unencrypted key | |
| ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519" -N "" -C "tubo.yyyuuu@gmail.com" | |
| else | |
| echo "Skipping SSH key generation: set SSH_KEY_PASSPHRASE for an encrypted key or GENERATE_UNENCRYPTED_SSH_KEY=1 to allow an unencrypted key." >&2 | |
| fi |
| # This script bootstraps Nix, nix-darwin, and applies the configuration. | ||
|
|
||
| HOSTNAME="${1:-my-mac}" | ||
|
|
There was a problem hiding this comment.
HOSTNAME is accepted from argv and used as the flake output selector, but this repo only defines specific darwinConfigurations (e.g., my-mac and my-mac-x86). When a user passes an unknown hostname, the error will be a Nix evaluation failure. Consider validating the argument (allowed set) and printing a clearer error message listing valid hostnames.
| # Validate HOSTNAME against the allowed set of darwinConfigurations. | |
| VALID_HOSTNAMES=("my-mac" "my-mac-x86") | |
| if [[ ! " ${VALID_HOSTNAMES[*]} " =~ " ${HOSTNAME} " ]]; then | |
| echo "Error: Unknown hostname '${HOSTNAME}'." | |
| echo "Valid hostnames are:" | |
| for h in "${VALID_HOSTNAMES[@]}"; do | |
| echo " - ${h}" | |
| done | |
| exit 1 | |
| fi |
| export PYENV_ROOT="$HOME/.pyenv" | ||
| export PATH="$PYENV_ROOT/bin:$PATH" | ||
| eval "$(pyenv init -)" | ||
| eval "$(pyenv virtualenv-init -)" 2>/dev/null |
There was a problem hiding this comment.
The 2>/dev/null redirection here only applies to eval, not to the pyenv virtualenv-init - command inside the $(...) substitution. If pyenv-virtualenv isn’t installed, the error output will still appear every shell startup. Consider redirecting stderr inside the command substitution and/or guarding the call based on plugin availability.
| eval "$(pyenv virtualenv-init -)" 2>/dev/null | |
| eval "$(pyenv virtualenv-init - 2>/dev/null)" |
| yarn | ||
|
|
||
| # Python - version manager | ||
| pyenv |
There was a problem hiding this comment.
shell.nix runs pyenv virtualenv-init -, but this Nix config only installs pyenv (not pyenv-virtualenv). Unless the Nix pyenv package bundles the virtualenv plugin, this will cause errors on shell startup and missing virtualenv integration. Consider adding pyenv-virtualenv to home.packages (or removing the virtualenv-init line if not needed).
| pyenv | |
| pyenv | |
| pyenv-virtualenv |
| # CI checks - verify the configurations evaluate correctly | ||
| checks = let | ||
| forAllSystems = nixpkgs.lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ]; | ||
| in forAllSystems (system: | ||
| let | ||
| pkgs = nixpkgs.legacyPackages.${system}; | ||
| in { | ||
| # Verify the flake configuration evaluates without errors | ||
| eval = pkgs.runCommand "eval-check" {} '' | ||
| echo "Flake evaluation succeeded for ${system}" | ||
| touch $out | ||
| ''; | ||
| } | ||
| ); |
There was a problem hiding this comment.
checks.eval currently doesn’t actually evaluate or build any flake outputs—it just echoes a message and touches $out. That means nix flake check will succeed even if the darwin configurations are broken. Consider making this check run a real evaluation/build (e.g., evaluating darwinConfigurations.<host>.config.system.build.toplevel or building the system derivation) for each system.
| # CI checks - verify the configurations evaluate correctly | |
| checks = let | |
| forAllSystems = nixpkgs.lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ]; | |
| in forAllSystems (system: | |
| let | |
| pkgs = nixpkgs.legacyPackages.${system}; | |
| in { | |
| # Verify the flake configuration evaluates without errors | |
| eval = pkgs.runCommand "eval-check" {} '' | |
| echo "Flake evaluation succeeded for ${system}" | |
| touch $out | |
| ''; | |
| } | |
| ); | |
| # CI checks - verify the configurations evaluate correctly by building | |
| # the darwin system derivations for each supported architecture. | |
| checks = { | |
| aarch64-darwin = { | |
| "${hostname}" = self.darwinConfigurations.${hostname}.system; | |
| }; | |
| x86_64-darwin = { | |
| "${hostname}-x86" = self.darwinConfigurations."${hostname}-x86".system; | |
| }; | |
| }; |
|
|
||
| . ci/nix-env.sh | ||
| echo ">>> Building nix-darwin system closure (this downloads/builds all Nix packages)..." | ||
| nix build .#darwinConfigurations.my-mac.system --no-link --print-build-logs 2>&1 | tail -50 |
There was a problem hiding this comment.
In this step, piping nix build output to tail masks the exit status of nix build (the pipeline exit code becomes tail's). As a result, the CI step can pass even when the build fails. Avoid piping, or enable set -o pipefail and capture logs in a way that preserves the build command’s exit code (e.g., tee to a file and tail afterwards).
| nix build .#darwinConfigurations.my-mac.system --no-link --print-build-logs 2>&1 | tail -50 | |
| nix build .#darwinConfigurations.my-mac.system --no-link --print-build-logs > nix-build.log 2>&1 | |
| build_status=$? | |
| tail -50 nix-build.log | |
| if [ "$build_status" -ne 0 ]; then | |
| echo ">>> System closure build failed with exit code $build_status" | |
| exit "$build_status" | |
| fi |
| # Also support x86_64 Macs | ||
| darwinConfigurations."${hostname}-x86" = nix-darwin.lib.darwinSystem { | ||
| system = "x86_64-darwin"; | ||
| specialArgs = { inherit inputs username hostname; }; | ||
| modules = [ |
There was a problem hiding this comment.
The x86_64 configuration is selected with the flake output name "${hostname}-x86", but it still passes hostname = "my-mac" into specialArgs. This makes networking.hostName (set from that arg in nix/darwin.nix) disagree with the selected configuration name (my-mac-x86). Consider passing a distinct hostname (e.g., "${hostname}-x86") or deriving the hostname from the configuration key so setup.sh my-mac-x86 results in a matching system hostname.
| eval "$(direnv hook zsh)" | ||
|
|
||
| # asdf (for Erlang/Elixir) | ||
| [ -f "$(brew --prefix asdf 2>/dev/null)/libexec/asdf.sh" ] && . "$(brew --prefix asdf 2>/dev/null)/libexec/asdf.sh" |
There was a problem hiding this comment.
This asdf initialization sources a Homebrew-installed asdf (brew --prefix asdf), but the Nix config installs asdf-vm via home.packages (and homebrew.nix doesn’t install an asdf brew). As written, asdf won’t be initialized. Prefer sourcing the Nix-provided asdf script (or using a home-manager asdf module) instead of relying on Homebrew here.
| [ -f "$(brew --prefix asdf 2>/dev/null)/libexec/asdf.sh" ] && . "$(brew --prefix asdf 2>/dev/null)/libexec/asdf.sh" | |
| [ -f "${pkgs.asdf-vm}/share/asdf-vm/asdf.sh" ] && . "${pkgs.asdf-vm}/share/asdf-vm/asdf.sh" |
| check_brew_formula() { | ||
| local formula="$1" | ||
| if brew info "$formula" >/dev/null 2>&1; then | ||
| echo " [PASS] brew: ${formula}" | ||
| PASSED=$((PASSED + 1)) | ||
| else | ||
| echo " [FAIL] brew: ${formula}" | ||
| FAILED=$((FAILED + 1)) | ||
| fi |
There was a problem hiding this comment.
This script assumes brew is available and will fail immediately on environments where Homebrew isn't installed (it calls brew info / brew tap without checking). Consider adding an explicit command -v brew guard with a clear error message (or installing Homebrew as part of the script) so CI/local runs fail with a helpful diagnostic.
| programs.git = { | ||
| enable = true; | ||
| userName = "tubone"; | ||
| userEmail = "tubo.yyyuuu@gmail.com"; | ||
|
|
There was a problem hiding this comment.
Git identity is hard-coded (userName/userEmail). If this provisioning is meant to be reusable across machines/users, this will cause incorrect commit attribution. Consider sourcing these from user-specific variables (passed via extraSpecialArgs, a separate secrets.nix ignored by git, or prompting during bootstrap) instead of committing fixed values.
- Remove packer from packages (no longer needed) - Replace terraform with opentofu (terraform removed from nixpkgs due to BSL) - Add Claude Code installation step in setup.sh via npm - Update CI verify script accordingly https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
- Switch from npm install to native installer (curl claude.ai/install.sh) which requires no Node.js dependency and auto-updates - Add tubone24/claude-code-settings plugin installation via its install.sh - Restore Node.js install to post-setup steps (fnm install 22) https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
- Remove mycli, ruby-build, git-flow (unavailable in nixpkgs) - Remove fnm, pyenv, rbenv, yarn from languages.nix - Consolidate Node.js/Python/Ruby/Erlang/Elixir management under asdf-vm - Update shell.nix: replace fnm/pyenv/rbenv init with asdf init - Update setup.sh post-setup steps with asdf plugin commands - Update CI verify script accordingly https://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms
Summary
This PR introduces a complete Nix-based provisioning system for macOS using nix-darwin and home-manager, replacing or complementing the existing Ansible-based setup. The configuration enables declarative management of system packages, Homebrew casks, macOS defaults, shell environments, and development tools.
Key Changes
Flake configuration (
flake.nix): Defines the Nix flake with inputs for nixpkgs, nix-darwin, and home-manager. Supports both aarch64 and x86_64 Darwin systems with a single configuration.System configuration (
nix/darwin.nix): Core nix-darwin setup including Nix settings, system packages, zsh shell, hostname configuration, and font management.macOS defaults (
nix/macos.nix): Declarative macOS system preferences (Dock, Finder, Trackpad, Screenshot, Menu bar) converted from the existing Ansible configuration.Homebrew integration (
nix/homebrew.nix): Manages Homebrew taps, formulae, casks (browsers, IDEs, development tools, utilities), and Mac App Store apps through nix-darwin.Home-manager modules:
nix/home.nix: Main home-manager configuration that imports all user-level modulesnix/packages.nix: System packages (AWS tools, infrastructure, utilities, databases, multimedia)nix/languages.nix: Language version managers (fnm, pyenv, rbenv, rustup, asdf) and runtimesnix/git.nix: Git configuration with SSH key generation and GitHub CLI toolsnix/shell.nix: Zsh and tmux configuration with shell integrations for version managersnix/editors.nix: Neovim setup with AstroNvim and dotfiles initializationBootstrap script (
setup.sh): Automated setup script that installs Nix, Homebrew, and applies the nix-darwin configuration with post-setup instructions.Makefile targets: Added
nix-setup,nix-setup-x86,nix-switch, andnix-updatetargets for easy management.Notable Implementation Details
home-manager.darwinModulesfor seamless integration with nix-darwinhttps://claude.ai/code/session_01GYSaXuAahUWTqeoUWVzhms