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.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
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
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"
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 > /.envrc
Technically, nothing is globally installed that way, only globally available.