diff --git a/.gitignore b/.gitignore index 09edf7a7..14c56aab 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,16 @@ microwaveprop-*.tar # Git worktrees /.worktrees/ +# Nix dev shell runtime state (local to each machine) +.nix-postgres/ +.nix-mix/ +.nix-hex/ +.nix-services-started +.direnv/ + +# Generated by pre-commit-hooks.nix (configuration lives in nix/shell.nix) +.pre-commit-config.yaml + # OS files .DS_Store Thumbs.db diff --git a/.tool-versions b/.tool-versions index 088edd21..5e93d4ea 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 29.0.2 +erlang 29.0.3 elixir 1.20.2-otp-29 diff --git a/Dockerfile b/Dockerfile index c9c55e28..412a0553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,8 @@ # - codeberg.org/gmcintire/prop-base - prebuilt runtime base (see Dockerfile.base) # - Ex: docker.io/hexpm/elixir:1.19.5-erlang-28.4.1-debian-trixie-20260316-slim # -ARG ELIXIR_VERSION=1.20.1 -ARG OTP_VERSION=29.0.2 +ARG ELIXIR_VERSION=1.20.2 +ARG OTP_VERSION=29.0.3 ARG DEBIAN_VERSION=trixie-20260518-slim ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..7b0eb01f --- /dev/null +++ b/flake.lock @@ -0,0 +1,124 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784007870, + "narHash": "sha256-djcLt/JJphyNt4eDY9XTly+/WbCK5lqWq9lSgCmJkkQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18b9261cb3294b6d2a06d03f96872827b8fe2698", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769939035, + "narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a8ca480175326551d6c4121498316261cbb5b260", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..586536cb --- /dev/null +++ b/flake.nix @@ -0,0 +1,69 @@ +{ + description = "Prop — Microwave propagation prediction for NTMS"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + + systems.url = "github:nix-systems/default"; + + pre-commit-hooks = { + url = "github:cachix/pre-commit-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + inputs@{ + self, + nixpkgs, + flake-parts, + systems, + pre-commit-hooks, + }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = builtins.filter (s: s != "x86_64-darwin") (import systems); + + perSystem = + { + config, + self', + inputs', + pkgs, + system, + ... + }: + { + _module.args.pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + }; + overlays = [ + # Pin Erlang 29.0.3 and Elixir 1.20.2 (matches .tool-versions) + (final: prev: { + erlang = prev.beam.interpreters.erlang_29; + elixir = prev.beam.packages.erlang_29.elixir_1_20; + beamPackages = prev.beam.packages.erlang_29.extend ( + self: super: { + elixir = super.elixir_1_20; + } + ); + }) + ]; + }; + + # Development shell with PostgreSQL, Rust, Elixir LSP + devShells.default = pkgs.callPackage ./nix/shell.nix { + pre-commit-hooks = inputs.pre-commit-hooks.lib.${system}; + }; + + # Nix formatter for flake files + formatter = pkgs.nixfmt; + }; + }; +} diff --git a/nix.md b/nix.md new file mode 100644 index 00000000..6cbf93ab --- /dev/null +++ b/nix.md @@ -0,0 +1,155 @@ +# Nix + direnv Setup + +This project uses [Nix flakes](https://nixos.wiki/wiki/Flakes) to provide a reproducible dev environment that activates automatically when you `cd` into the project directory. No more "works on my machine" — every dev gets the same Elixir, Erlang, Rust, and PostgreSQL. + +## How It Works + +1. **Nix flakes** (`flake.nix`) declare every tool and library the project needs, pinned via `flake.lock`. +2. **direnv** watches for `.envrc` and loads/unloads the environment as you enter/leave the directory. +3. **nix-direnv** bridges the two, so `use flake` in `.envrc` triggers `nix develop`. + +The result: `cd` into the repo and you're ready to run `mix phx.server`. `cd` out and your shell is clean. No global installs, no version conflicts, no manual setup. + +## File Layout + +| File | Purpose | +|---|---| +| `flake.nix` | Top-level flake: pins nixpkgs, Erlang/Elixir versions, defines dev shell | +| `flake.lock` | Locked hashes for every dependency — reproducible across machines | +| `nix/shell.nix` | Dev shell definition: packages, env vars, PostgreSQL service scripts, pre-commit hooks | +| `shell.nix` | Flake-compat shim so `nix-shell` works without `--extra-experimental-features flakes` | +| `.envrc` | Contains `use flake` (gitignored — create it yourself on each machine) | + +## One-Time Setup + +### 1. Install Nix (multi-user) + +```bash +sh <(curl -L https://nixos.org/nix/install) +``` + +Follow the prompts. The multi-user install (`--daemon`) is recommended on macOS. When it finishes, open a new terminal so the Nix daemon is running. + +Enable flakes. Add this to `~/.config/nix/nix.conf` (create the file if needed): +``` +experimental-features = nix-command flakes +``` + +Verify: +```bash +nix --version +nix flake --help +``` + +### 2. Install direnv + +```bash +brew install direnv +``` + +Add the direnv hook to your shell's rc file. Running `brew info direnv` will print the exact line — pick the one for your shell: + +**fish** (`~/.config/fish/config.fish`): +```fish +direnv hook fish | source +``` + +**zsh** (`~/.zshrc`): +```zsh +eval "$(direnv hook zsh)" +``` + +### 3. Install nix-direnv + +```bash +brew install nix-direnv +``` + +Then configure direnv to load it. Create (or append to) `~/.config/direnv/direnvrc`: +```bash +mkdir -p ~/.config/direnv +cat > ~/.config/direnv/direnvrc << 'EOF' +source "$(brew --prefix nix-direnv)/share/nix-direnv/direnvrc" +EOF +``` + +If you installed nix-direnv via `nix profile` instead of Homebrew, the source line is: +``` +source $HOME/.nix-profile/share/nix-direnv/direnvrc +``` + +### 4. Create `.envrc` + +```bash +echo 'use flake' > .envrc +``` + +This is gitignored — each dev creates their own. You can add personal overrides (e.g., extra env vars) here. + +### 5. Restart your shell + +Open a new terminal tab or `exec $SHELL` so the direnv hook takes effect. + +### 6. First time entering the project directory + +```bash +cd ~/dev/prop +direnv allow # one-time approval — thereafter it loads automatically +``` + +The first build may take several minutes while Nix downloads and compiles packages (Erlang, Elixir, Rust, PostgreSQL, etc.). Subsequent entries are instant — direnv caches the shell. + +## Daily Use + +Once set up, just `cd` into the repo. The shell activates automatically: + +- **PostgreSQL** starts on port 5432 with `prop_dev` and `prop_test` databases created +- **`start-services`** / **`stop-services`** commands are available to manually control PostgreSQL +- **Mix/Hex** are sandboxed to `.nix-mix` / `.nix-hex` (doesn't pollute `~/.mix`) +- **Pre-commit hooks** (mix format, credo, nixfmt, cargo fmt, cargo clippy) install automatically +- **Services stop** automatically when you `cd` out of the directory + +## Troubleshooting + +**`direnv: error .envrc is blocked`** +Run `direnv allow` in the project directory. + +**`nix develop` fails with "experimental Nix feature 'nix-command' is disabled"** +Add `experimental-features = nix-command flakes` to `~/.config/nix/nix.conf`. + +**Empty shell, no Elixir available** +Run `nix develop --command elixir --version` to see build output. Check for syntax errors in `flake.nix` or `nix/shell.nix`: +```bash +nix flake check +``` + +**Flake can't see new or modified files** +Nix flakes only see files tracked by git. If you added a new nix file, stage it first: +```bash +git add -N +``` + +**Nix build fails with a hash mismatch in `shell.nix` (flake-compat)** +The `flake-compat` tarball hash in `shell.nix` is stale. Update it to the actual hash shown in the error message. + +**direnv not triggering on `cd`** +Check your shell's rc file has the hook line from step 2. Run `direnv status` in the project directory. + +**I added a new package to `nix/shell.nix` — how do I reload?** +direnv watches `flake.nix` and `flake.lock` for changes and rebuilds automatically. If it doesn't pick up the change, run `direnv reload`. + +## Updating Dependencies + +The flake pins everything via `flake.lock`. To update all inputs to their latest versions: + +```bash +nix flake update +``` + +To update just nixpkgs (e.g., to get a newer Erlang/Elixir): + +```bash +nix flake lock --update-input nixpkgs +``` + +Commit the updated `flake.lock` so everyone gets the same versions. diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 00000000..e3e8a9bf --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,280 @@ +{ + lib, + stdenv, + mkShell, + writeShellScriptBin, + # Elixir/Erlang + elixir, + # Database + postgresql_16, + # Rust toolchain + rustup, + rust-analyzer, + # Development tools + git-lfs, + inotify-tools, + # LSPs and formatters + elixir-ls, + nixfmt, + shellcheck, + # Pre-commit hooks + pre-commit-hooks, +}: + +let + # PostgreSQL data directory (local to project) + pgDataDir = ".nix-postgres"; + pgPort = "5432"; + pgHost = "localhost"; + + # Service management flag + servicesFlag = ".nix-services-started"; + + # Script to start PostgreSQL + startServices = writeShellScriptBin "start-services" '' + set -e + + echo "Starting development services..." + + # Check if already started + if [ -f "${servicesFlag}" ]; then + echo "Services already running (remove ${servicesFlag} to force restart)" + exit 0 + fi + + # Initialize PostgreSQL if needed + if [ ! -d "${pgDataDir}" ]; then + echo "Initializing PostgreSQL database..." + ${postgresql_16}/bin/initdb -D ${pgDataDir} -U $USER --encoding=UTF8 --locale=en_US.UTF-8 + + # Configure PostgreSQL for development (fast but unsafe for production) + cat >> ${pgDataDir}/postgresql.conf << EOF + # Development configuration + max_connections = 100 + shared_buffers = 128MB + fsync = off + synchronous_commit = off + full_page_writes = off + EOF + fi + + # Check if PostgreSQL is already running on this port + if ${postgresql_16}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then + echo "PostgreSQL already running on port ${pgPort}" + else + # Start PostgreSQL + echo "Starting PostgreSQL on port ${pgPort}..." + ${postgresql_16}/bin/pg_ctl -D ${pgDataDir} -l ${pgDataDir}/logfile -o "-p ${pgPort}" start + + # Wait for PostgreSQL to be ready + for i in {1..30}; do + if ${postgresql_16}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then + break + fi + if [ $i -eq 30 ]; then + echo "PostgreSQL failed to start within 30 seconds" + echo "Check ${pgDataDir}/logfile for details" + exit 1 + fi + sleep 1 + done + echo "PostgreSQL started successfully" + fi + + # Create postgres role if it doesn't exist (needed by some tools) + ${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \ + "SELECT 1 FROM pg_roles WHERE rolname='postgres'" | grep -q 1 || \ + ${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -c \ + "CREATE ROLE postgres WITH SUPERUSER LOGIN;" + + # Create development and test databases + for db in prop_dev prop_test; do + ${postgresql_16}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \ + "SELECT 1 FROM pg_database WHERE datname='$db'" | grep -q 1 || \ + ${postgresql_16}/bin/createdb -h ${pgHost} -p ${pgPort} -U $USER $db + done + + # Mark services as started + touch ${servicesFlag} + + echo "" + echo "Development environment ready!" + echo "" + echo "PostgreSQL: ${pgHost}:${pgPort} (databases: prop_dev, prop_test)" + echo "" + ''; + + # Script to stop PostgreSQL + stopServices = writeShellScriptBin "stop-services" '' + set -e + + echo "Stopping development services..." + + # Stop PostgreSQL + if [ -d "${pgDataDir}" ]; then + ${postgresql_16}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true + echo "PostgreSQL stopped" + fi + + # Remove services flag + rm -f ${servicesFlag} + + echo "Services stopped successfully" + ''; + + # Pre-commit hooks configuration + pre-commit-check = pre-commit-hooks.run { + src = ../.; + hooks = { + # Elixir formatting + mix-format = { + enable = true; + name = "mix format"; + entry = "mix format --check-formatted"; + files = "\\.(ex|exs)$"; + pass_filenames = false; + }; + + # Credo linting + credo = { + enable = true; + name = "credo"; + entry = "mix credo --strict"; + files = "\\.(ex|exs)$"; + pass_filenames = false; + }; + + # Rust formatting and linting + cargo-fmt = { + enable = true; + name = "cargo fmt"; + entry = "cargo fmt --all --check"; + files = "\\.rs$"; + pass_filenames = false; + }; + + cargo-clippy = { + enable = true; + name = "cargo clippy"; + entry = "cargo clippy --all-targets -- -D warnings"; + files = "\\.rs$"; + pass_filenames = false; + }; + + # Nix formatting + nixfmt = { + enable = true; + entry = "${nixfmt}/bin/nixfmt"; + }; + + # Shellcheck for shell scripts + shellcheck.enable = true; + }; + }; + +in +mkShell { + name = "prop-dev"; + + # Development tools + buildInputs = [ + # Elixir/Erlang + elixir + + # Database + postgresql_16 + + # Rust toolchain + rustup + rust-analyzer + + # Development tools + git-lfs + + # LSPs and formatters + elixir-ls + nixfmt + shellcheck + + # Service management scripts + startServices + stopServices + ] + ++ lib.optionals stdenv.isLinux [ + # Linux-only tools + inotify-tools # For Mix file watching + ]; + + # Environment variables + shellHook = '' + # Set Mix and Hex home to local directories (avoid polluting $HOME) + export MIX_HOME="$PWD/.nix-mix" + export HEX_HOME="$PWD/.nix-hex" + mkdir -p "$MIX_HOME" "$HEX_HOME" + + # PostgreSQL connection + export PGHOST="${pgHost}" + export PGPORT="${pgPort}" + export PGUSER="$USER" + export DATABASE_URL="ecto://$USER@${pgHost}:${pgPort}/prop_dev" + + # Development secrets (DO NOT use in production) + export SECRET_KEY_BASE="dev_secret_key_base_at_least_64_bytes_long_for_phoenix_to_accept_it_safely" + export RELEASE_COOKIE="dev_release_cookie_for_distributed_erlang" + + # Development environment + export MIX_ENV=dev + export PHX_HOST=localhost + export PORT=4000 + + # Ensure Erlang can find NIF libraries + export ERL_AFLAGS="-kernel shell_history enabled" + + # Rust: set up stable toolchain if not already installed + if ! rustup show active-toolchain 2>/dev/null | grep -q stable; then + rustup default stable 2>/dev/null || true + fi + + # Install Hex and Rebar3 if not already installed + mix local.hex --force --if-missing > /dev/null 2>&1 + mix local.rebar --force --if-missing > /dev/null 2>&1 + + # Auto-start services on first shell entry + if [ ! -f "${servicesFlag}" ]; then + ${startServices}/bin/start-services + + # Run migrations if databases exist and Mix deps are available + if [ -d "deps" ] && mix ecto.migrate 2>/dev/null; then + echo "Database migrations applied" + fi + fi + + # Install pre-commit hooks + ${pre-commit-check.shellHook} + + # Welcome message + echo "" + echo "╔═══════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ Prop — Microwave Propagation Prediction ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════╝" + echo "" + echo "Available commands:" + echo " mix phx.server - Start Phoenix development server" + echo " mix test - Run test suite" + echo " mix precommit - Run pre-commit checks (format, credo, test)" + echo " start-services - Start PostgreSQL" + echo " stop-services - Stop PostgreSQL" + echo " mix ecto.reset - Reset database (drop, create, migrate, seed)" + echo " cargo build - Build Rust crates (cd rust/prop_grid_rs)" + echo " cargo test - Run Rust tests" + echo "" + echo "Services:" + echo " PostgreSQL: ${pgHost}:${pgPort}" + echo "" + echo "Documentation:" + echo " See CLAUDE.md and nix.md for more information" + echo "" + ''; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..821da66d --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +# Compatibility shim for developers using `nix-shell` instead of `nix develop` +# This file uses flake-compat to provide the development shell from flake.nix + +(import (fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/35bb57c0c8d8b62bbfd284272c928ceb64ddbde9.tar.gz"; + sha256 = "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8="; +}) { src = ./.; }).shellNix.default