Skip to content

Instantly share code, notes, and snippets.

@fricklerhandwerk
Last active January 30, 2024 21:08
Show Gist options
  • Save fricklerhandwerk/fbf0b212bbbf51b79a08fdac8659481d to your computer and use it in GitHub Desktop.
Save fricklerhandwerk/fbf0b212bbbf51b79a08fdac8659481d to your computer and use it in GitHub Desktop.
Home Manager without `home-manager`

Here's how to use Home Manager without home-manager. @proofconstruction is my witness.

Getting the right version of Home Manager

First of all we have to make sure that the version of Home Manager matches the Nixpkgs release we want to use for our user environment configuration. Otherwise we will almost certainly get into trouble with mismatching interfaces.

We start out with a function that takes Nixpkgs as pkgs and fetch the appropriate Home Manager release. We get the given Nixpkgs version string from pkgs.lib.version and split it into the <major>.<minor> format with lib.versions.majorMinor.

pkgs:
let
  version = with pkgs; lib.versions.majorMinor lib.version;
in
builtins.fetchGit {
  name = "home-manager-${version}";
  url = https://github.com/nix-community/home-manager;
  ref = "release-${version}";
}

The juicy bit is figuring out which file in the Home Manager source is responsible for evaluating a configuration. It can be imported and called as a function on an attribute set containing pkgs and the configuration file's path as confPath. Here's a function that does the job, assuming home-manager is as above:

{ pkgs, home-manager, config }:
import "${home-manager}/home-manager/home-manager.nix" {
  inherit pkgs;
  confPath = config;
};

Configuration evaluator

Everything combined into a package in the file home-manager.nix, it would look like this:

# home-manager.nix
{ pkgs, config }:
let
  version = with pkgs; lib.versions.majorMinor lib.version;
  home-manager = builtins.fetchGit {
    name = "home-manager-${version}";
    url = https://github.com/nix-community/home-manager;
    ref = "release-${version}";
  };
in
import "${home-manager}/home-manager/home-manager.nix" {
  inherit pkgs;
  confPath = config;
};

Calling this function with a revision of Nixpkgs and a path to your configuration file, and realising the resulting derivation will produce a store path that contains an executable activate. Running that will wire up the system to make the contents of that build result serve as your user environment. Specifically, it sets $PATH, and also adds a new Home-Manager-specific profile generation such that you can roll back to it later.

Now, to have that as a convenient shell script, which we call deploy for the sake of simplicity, we wrap this into pkgs.writeShellApplication (which is an unfortunate misnomer, because it's clearly a build helper for Bash scripts).

Configuration builder

The script takes as argument the path to the configuration file, and passes the remaining arguments to Nix. This is what home-manager switch amounts to:

# deploy.nix
{ writeShellApplication, nix, pkgs }:
writeShellApplication {
  name = "deploy";
  runtimeInputs = [ nix ];
  text = ''
    config="$1"
    shift

    nix-build --expr \
      "(import <nixpkgs> {}).callPackage ${./home-manager.nix} { config = $(realpath $config); }" \
      "$@" \
      -I ${pkgs.path}
    && "./result/activate"
  '';
}

The expression passed to Nix is subtle in many ways. First we import some version of Nixpkgs:

(import <nixpkgs> {})

By default this is the source of pkgs that was passed to the outer function, which we access by string-interpolating pkgs.path. It can be overridden when calling the resulting script with -I nixpkgs= set to a different Nixpkgs revision, since search paths passed to nix-build are looked up in the given order. This will come in handy when you want to upgrade the package set your configuration is to be based on.

Given that Nixpkgs attribute set we just imported, we use callPackage to evaluate our matching release of Home Manager defined in home-manager.nix previously. The subtlety here is that callPackage passes the pkgs argument implicitly, and the additional argument config is the Bash variable $config containing path passed as the script's first argument:

callPackage ${./home-manager} { config = $config; }

Initial configuration

For example, our user environment could be defined in home.nix, featuring that very same deploy script:

# home.nix
{ pkgs, ... }:
let
  deploy = pkgs.callPackage ./deploy.nix {}:
in
{
  environment.homePackages = [ deploy ];
}

(In a real Home Manager configuration you will have to specify home.username and home.homeDirectory.)

To bootstrap a user environment, call nix-build on the expression that builds the script defined in deploy.nix with a Nixpkgs revision of your choice. Specifying the Nixpkgs version in a separate file allows using it from multiple locations and committing it to version control. It could look like this:

# nixpkgs.nix
import fetchTarball channel:nixos-23.05

Build the script defined in deploy.nix:

nix-build --expr 'with import ./nixpkgs.nix {}; callPackage ./deploy.nix {}'

Then run the script and pass the configuration that shall be activated as an argument:

./result/bin/deploy ./home.nix

The new environment will have a deploy executable in its $PATH.

To change the confiration, edit home.nix and run:

deploy ./home.nix

Upgrading Nixpkgs

When you want to upgrade your Nixpkgs version, edit the contents of nixpkgs.nix and call deploy with -I nixpkgs= set appropriately:

 # nixpkgs.nix
-import fetchTarball channel:nixos-23.05
+import fetchTarball channel:nixpkgs-unstable
deploy ./home.nix -I nixpkgs=./nixpkgs.nix

Goodies

Bootstrapping helper

Since we don't want to remember multiple commands to get going, we can make use of a helper. It does not require anything but a working Nix installation:

# default.nix
let
  pkgs = import ./nixpkgs.nix {};
  deploy = pkgs.callPackage ./deploy.nix {};
in pkgs.mkShell {
  buildInputs = [ deploy ];
}

Bootstrapping then reduces to calling:

nix-shell --run "deploy ./home.nix"

Distributed builds

You don't have to evaluate, build, and activate your configuration on the same machine. Splitting the build into multiple steps that can be performed on different machines allows for distributed builds and remote deployments. This is essentially what nixos-rebuild does, given appropriate SSH setup on each machine involved:

# deploy.nix
{ writeShellApplication, nix, pkgs }:
writeShellApplication {
  name = "deploy";
  runtimeInputs = [ nix ];
  text = ''
    config="$1"; shift
    args=
    while [ "$#" -gt 0 ]; do
      i="$1"; shift 1
      case "$i" in
        --build-host)
          buildHost="$1"
          shift 1
          ;;
        --target-host)
          targetHost="$1"
          shift 1
          ;;
        *)
          args+="$i";
          ;;
      esac
    done

    drv=$(nix-instantiate --expr "(import <nixpkgs> {}).callPackage ${./home-manager.nix} { config = $(realpath $config); }" \
      "$args" \
      -I ${pkgs.path})

    if [ -n "$buildHost" ]; then
      nix-copy-closure "$drv" "$buildHost"
      # Home Manager's derivation will produce two outputs, the second one being "news"
      out=$(ssh "$buildHost" 'nix-store --realise '"$drv" | head -1)
    else
      out=$(nix-store --realise "$drv" | head -1)
    fi

    if [ -n "$targetHost" ]; then
        # the target host must have its substituters configured appropriately
        # to fetch the output path from where it was built
        ssh "$targetHost" 'nix-build '"$out"' && ./result/activate'
    else
        "$out/activate"
    fi
  '';
}

The road to dependency hell is paved with angle brackets. None of this has been run.

Alternatives

Put everything into /default.nix and run

sudo echo use_nix > /.envrc

Technically, nothing is globally installed that way, only globally available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment