Here's how to use Home Manager without home-manager.
@proofconstruction is my witness.
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;
};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).
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; }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.05Build 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.nixThe new environment will have a deploy executable in its $PATH.
To change the confiration, edit home.nix and run:
deploy ./home.nixWhen 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-unstabledeploy ./home.nix -I nixpkgs=./nixpkgs.nixSince 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"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.
Put everything into /default.nix and run
sudo echo use_nix > /.envrcTechnically, nothing is globally installed that way, only globally available.