This post is extremely similar to nix flakes and direnv. I repeated my process there, but with a little more thought and a little less language model magic.

I setup my new computer to use nix, switching away from Homebrew, which I’ve used to manage and install dependencies on my system for about a decade. My goal was to unify my configuration management with my package management. Thus far, I’ve been quite satisfied. However, I’ve also relied on asdf to manage and switch between multiple versions of things like Python and Node. Lately, I’ve been jumping between projects that use different versions of node. While modifying my home.nix file and rebuilding would be pretty simple, I wanted to see if I could enable easy access to multiple versions of node at the same time. My first attempt was to add both of the following to my home.packages

nodejs
nodejs-18_x

Nix was able to rebuild this configuration, but I only was still able to use a single version of node (not 18, but the LTS version 20.x.x at the time of this writing). After a bit of searching and prompting, I learned that I could define two separate packages in the following way

(writeShellScriptBin "node" ''
    #!${zsh}/bin/zsh
    exec ${nodejs}/bin/node "$@"
'')
(writeShellScriptBin "node18" ''
    #!${zsh}/bin/zsh
    exec ${nodejs-18_x}/bin/node "$@"
'')

After rebuilding the changes, I could run the two different versions of node with the aliases I defined

$ node -v
v20.11.0
$ node18 -v
v18.19.0

This solution fit the initial need but Node projects don’t typically reference tool version in name (as Python does with python3). It would be easy enough to run a standalone script with node18, but a bit more confusing to use that convention for a project that requires Node 18. Typically, the interpreter is just referred to as node.

This setup is still a little unusual, so I was motivated to come up with a more consistent starting point to use nix for setting up project specific dependencies. Ironically, it ended up looking a lot like my process in this post. I’m going to include it again here because it includes the use of a flake template, something I haven’t been doing consistently.

To start, I ran

nix flake init -t github:numtide/flake-utils

This writes a flake with

{
  description = "Flake utils demo";

  inputs.flake-utils.url = "github:numtide/flake-utils";

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system}; in
      {
        packages = rec {
          hello = pkgs.hello;
          default = hello;
        };
        apps = rec {
          hello = flake-utils.lib.mkApp { drv = self.packages.${system}.hello; };
          default = hello;
        };
      }
    );
}

The aim is to setup flake that will make project-specific tools available when I cd into the project folder. Following this example, a few minor modifications make this possible.

{
  description = "Flake utils demo";

  inputs.flake-utils.url = "github:numtide/flake-utils";

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system}; in
      {
        devShell = pkgs.mkShell {
          buildInputs = with pkgs; [
            # packages go here
          ];
        };
      }
    );
}

Let’s say I want to install deno

To start, I validate I don’t have it installed

$ deno
zsh: command not found: deno

Now, I’ll add an .envrc with

use flake

and run

direnv allow

in the directory.

Finally, I’ll add deno to my packages list, save then run

cd .

which should output a bunch of stuff like

$ cd .
direnv: loading ~/dev/workshop/myproj/.envrc
direnv: using flake
direnv: nix-direnv: renewed cache
direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +LD_DYLD_PATH +MACOSX_DEPLOYMENT_TARGET +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_aarch64_apple_darwin +NIX_BUILD_CORES +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_aarch64_apple_darwin +NIX_CFLAGS_COMPILE +NIX_DONT_SET_RPATH +NIX_DONT_SET_RPATH_FOR_BUILD +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_IGNORE_LD_THROUGH_GCC +NIX_LDFLAGS +NIX_NO_SELF_RPATH +NIX_STORE +NM +PATH_LOCALE +RANLIB +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +ZERO_AR_DATE +__darwinAllowLocalNetworking +__impureHostDeps +__propagatedImpureHostDeps +__propagatedSandboxProfile +__sandboxProfile +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH

Now, when I run

$ deno
Deno 1.40.5
exit using ctrl+d, ctrl+c, or close()
REPL is running with all permissions allowed.
To specify permissions, run `deno repl` with allow flags.
> console.log("hey")
hey
undefined
>

it drops me into a shell. When I cd out of the project, the project’s dependencies are unloaded by direnv.

My takeaway generally is that while it can be nice to install tools at a system level, when managing multiple versions, it seems like it would be easier to create flakes per project rather than trying to manage multiple tool versions with different names/aliases using nix-darwin and home-manager. Setting up a flake with direnv is pretty fast and lightweight. Remember to run nix-store --gc occasionally to free up your disk space.