nobe4 / Posts / Nix ln, the wrong ways _

  |   Tech Nix

TL;DR: don’t do this, everyone suggests using home-manager, or a non-Nix solution.

In the exploration of porting my configuration to Nix, the next step was to manage all the links. This article shows a couple of wrong ways to do it, they are still good learning paths, hence this writing.

Unless otherwise specified, all code examples are simplified for clarity.

Current configuration 🔗

Whenever I need to link a new file, I add a line in:

# install.sh
ln -sfv "$DOTFILE_FOLDER/.zshrc" "$HOME/.zshrc"
ln -sfv "$DOTFILE_FOLDER/kitty/" "$HOME/.config/kitty"
# ...

This works well enough for a single system, but is not easily configurable. Using Nix’s options seemed like a good solution for this.

I heard of home-manager, but didn’t want to use it until absolutely necessary.

Using mkDerivation 🔗

# ln.nix
{ stdenv, lib }:
{
  ln =
    {
      name,
      links, # list of [ src, dst ] tuples
    }:
    stdenv.mkDerivation {
      installPhase = ''
        ${lib.strings.concatMapStringsSep "\n" (
          link:
          let
            src = builtins.elemAt link 0;
            dst = builtins.elemAt link 1;
          in ''ln -s ${dst} ${src}''
        ) links}
      '';
    };
}

# configuration.nix
{ pkgs, lib, ... }:
let
  ln = (import ./ln.nix { inherit (pkgs) stdenv lib; }).ln;
in
{
  environment.systemPackages = [
    (ln {
      links = [
        [ "$DOTFILE_FOLDER/.zshrc" "$HOME/.zshrc" ]
        [ "$DOTFILE_FOLDER/kitty" "$HOME/.config/kitty" ]
        # ...
      ];
    })
  ];
  # ...
}

Why this doesn’t work:

Using runCommand 🔗

# ln.nix
{ pkgs }:
{
  ln =
    { links }:
    pkgs.runCommand "ln" { } ''
      ${lib.concatMapStringsSep "\n" (
        link:
        let
          src = builtins.elemAt link 0;
          dst = builtins.elemAt link 1;
        in ''ln -s ${dst} ${src}''
      ) links}
    '';
}

# configuration.nix
{ pkgs, ... }:
let
  ln = (import ./ln.nix { inherit pkgs; }).ln;
in
{
  # ... similar as before
}

Why this doesn’t work:

Caveat on runCommand and etc 🔗

It’s technically possible to use the result of runCommand if the file is expected to be in /etc:

{ pkgs, ... }:
let
  dotfiles = pkgs.runCommand "ln" { } ''ln -s $DOTFILE_FOLDER $out'';
in
{
  enviroment.etc = {
    "kitty".source = "${dotfiles}/kitty";
    # ...
  };
  # ...
}

This doesn’t work if the file lives in $HOME/, or $XDG_CONFIG/.

Using userActivationScripts 🔗

# ln.nix
{ config, lib, ... }:
{
  options.ln = lib.mkOption {
    type = with lib.types; listOf (listOf str);
    default = [ ];
  };
  config.system.userActivationScripts.ln.text = lib.concatMapStringsSep "\n" (
    tuple:
    let
      src = builtins.elemAt tuple 0;
      dst = builtins.elemAt tuple 1;
    in
    ''ln -vfsT ${src} ${dst}''
  ) config.ln;
}


# configuration.nix
{ ... }:
{
  imports = [
    ./utils/ln.nix
    # ...
  ];
  ln = [
    [ "$DOTFILE_FOLDER/.zshrc" "$HOME/.zshrc" ]
    [ "$DOTFILE_FOLDER/kitty" "$HOME/.config/kitty" ]
  ];
  # ...
}

This works well for user-owned files and folders, but not for root-owned.

For root-owned files, one can use activationScripts, which is fundamentally similar except that root runs it.

E.g.

# ln.nix
{
  # ... adding to the previous
  options.ln-root = lib.mkOption {
    type = with lib.types; listOf (listOf str);
    default = [ ];
  };
  config.system.activationScripts.ln-root.text = lib.concatMapStringsSep "\n" (
    tuple:
    let
      src = builtins.elemAt tuple 0;
      dst = builtins.elemAt tuple 1;
    in
    ''ln -vfsT ${src} ${dst} ''
  ) config.ln-root;
  #...
}

# configuration.nix
{ pkgs, ... }:
{
  # ...
  ln-root = [
    [ "${pkgs.gojq}/bin/gojq" "/usr/bin/jq" ]
  ];
  # ...
}

Why it’s not ideal:

Moving forward 🔗

While it’s technically possible to build a link farm with *activationScripts, it’s not recommended, nor the paved path.

I shall explore home-manager next and see how it solves it.

References: