Homemaker

golang homemaker mit license

Homemaker is a lightweight tool for straightforward and efficient management of *nix configuration files found in the user’s home directory, commonly known as dot-files. It can also be readily used for general purpose system bootstrapping, including installing packages, cloning repositories, etc. This tool is written in Go, requires no installation, has no dependencies and makes use of simple configuration file structure inspired by make to generate symlinks and execute system commands to aid in configuring a new system for use.

Table of Contents

Motivation

Ever since switching to using Linux as my daily driver operating system, I have been searching for a way to effectively manage settings between different computers (and system reinstalls on the same machine) while avoiding the accumulation of cruft that plagues our home directories.

Specifically, I required a solution that had the following characteristics:

It soon became apparent to me that utility which met all of my requirements for simply did not exist. After making do with a hastily hacked-together Python script for a couple of months, I decided that this problem deserved a clean, formal solution. I settled on building this new utility in Go because in addition to the language syntax being clear and easy to understand, executables built by the Go compiler are statically linked, making them highly portable. Just drop the binary on your system and you are ready! The result of my work is Homemaker; I hope that you find it suitable for your needs.

Configuration

Configuration files for Homemaker can be authored in your choice of TOML, JSON, or YAML markup languages. Being the easiest to read out of the three, TOML will be used for the example configuration files. Worry not if you are unfamiliar with this format; everything you need to know about it will be shown below.

Let’s start by looking at a basic example configuration file, example.toml. Notice that Homemaker determines which markdown language processor to use based on the extension of your c:nfiguration file. Use .toml/.tml for TOML, .yaml/.yml for YAML, and .json for JSON. Be aware that specifying an incorrect file extension will prevent your configuration file from being parsed correctly.

[tasks.default]
    links = [
        [".config/fish"],
        [".config/keepassx"],
        [".config/terminal"],
        [".config/vlc"],
        [".gitconfig"],
        [".xinputrc"],
    ]

We could have just as easily written this configuration in JSON (or YAML for that matter), but it’s subjectively uglier:

{
    "tasks": {
        "default": {
            "links": [
                [".config/fish"],
                [".config/keepassx"],
                [".config/terminal"],
                [".config/vlc"],
                [".gitconfig"],
                [".xinputrc"]
            ]
        }
    }
}

To create symlinks based on the contents of the TOML file from before, we invoke the homemaker utility as follows:

$ homemaker example.toml /mnt/data/config

To get a better idea of what /mnt/data/config is, let’s look at the in-program documentation:

Usage: homemaker [options] conf src
https://foosoft.net/projects/homemaker/

Parameters:
  -clobber
        delete files and directories at target
  -dest string
        target directory for tasks (default "/home/alex")
  -force
        create parent directories to target (default true)
  -nocmds
        don't execute commands
  -nolinks
        don't create links
  -task string
        name of task to execute (default "default")
  -unlink
        remove existing links instead of creating them
  -variant string
        execution variant for tasks and macros
  -verbose
        verbose output

For the purpose of our illustration, src is defined on the command line to be /mnt/data/config; namely the source directory where your dot-files live (this will be your Git repository, Dropbox folder, rsync root, etc.). The symlinks that Homemaker creates will point to the configuration files in this directory. You may have noticed that you can also provide a destination directory via the -dest command line argument; this is where the symlinks should be created and it defaults to your home directory.

Another useful parameter is task; it will be initialized to the value default unless you override it on the command line. In practice, this means that Homemaker will try to find a task called default and execute it. You can create as many unique tasks as necessary to correspond to your configuration requirements, and then choose which one will execute by specifying it on the command line in the format -task=taskname. Good candidates for tasks are computer names, as shown in the configuration file below:

[tasks.flatline]
    links = [
        [".config/syncthing", ".config/syncthing_flatline"],
        [".s3cfg"],
        [".sabnzbd"],
        [".ssh", ".ssh_flatline"],
    ]

[tasks.wintermute]
    links = [
        [".config/syncthing", ".config/syncthing_wintermute"],
        [".ssh", ".ssh_wintermute"],
    ]

Here we see two tasks, named after the computers that will be using them, flatline and wintermute. Certain configuration data like key pairs and other per-machine settings should only be linked on the computer that is using them. That is to say if flatline and wintermute both try to manage the .ssh directory, a conflict will occur at both the source and destination directories. We can easily resolve the source directory conflict by giving the .ssh directories unique names, such as .ssh_flatline and .ssh_wintermute. The conflict at the destination directory can be fixed as shown above; we will create per-machine tasks that will symlink only the needed directory.

You may have noticed that each entry in the links collection is an array, which up until now has contained only one item. A second item can be added if the source file or directory name is different from that in the destination. If the paths provided are relative they will be assumed to be relative to the destination and source directories respectively.

Now that we have machine specific tasks defined in our configuration file, it would be nice to still be able to share configuration settings that are common to the two computers. We can do this by adding a dep array to our tasks as shown below:

[tasks.common]
    links = [
        [".config/fish"],
        [".config/keepassx"],
        [".config/terminal"],
        [".config/vlc"],
        [".gitconfig"],
        [".xinputrc"],
    ]

[tasks.flatline]
    deps = ["common"]
    links = [
        [".config/syncthing", ".config/syncthing_flatline"],
        [".s3cfg"],
        [".sabnzbd"],
        [".ssh", ".ssh_flatline"],
    ]

[tasks.wintermute]
    deps = ["common"]
    links = [
        [".config/syncthing", ".config/syncthing_wintermute"],
        [".ssh", ".ssh_wintermute"],
    ]

Homemaker will process the dependency tasks before processing the task itself.

Sometimes, just linking a config file is not enough, because the content of the configuration file needs to be adapted to the target and we do not want to maintain several different versions of the same file. For such use cases, Homemaker supports templates. The configuration syntax for templates is the same as for links.

[tasks.template]
    templates = [
        [".gitconfig"]
    ]

In the template file, the go templating syntax is used for the customization of the configuration file. With the .Env prefix, all environment variables are available. Template example:

[user]
name = "John Doe"
{{if eq .Env.USER "john"}}
    email = "john@doe.me"
{{else}}
    email = "john.doe@work.com"
{{end}}

In addition to creating links and processing templates, Homemaker is capable of executing commands on a per-task basis. Commands should be defined in an array called cmds, split into an item per each command line argument. All of the commands are executed with dest as the working directory (as mentioned previously, this defaults to your home directory). If any command returns a nonzero exit code, Homemaker will display an error message and prompt the user to determine if it should abort, retry, or cancel. Additionally, if you must have explicit control of whether commands execute before or after the linking phase, you can use the cmdspre and cmdspost arrays which have similar behavior.

The example task below will clone and install configuration files for Vim into the ~/.config directory, and create links to it from the home directory. You may notice that this task references an environment variable (set by Homemaker itself) in the links block; you can read more about how to use environment variables in the following section.

[tasks.vim]
    cmds = [
        ["rm", "-rf", ".config/vim"],
        ["git", "clone", "https://github.com/FooSoft/dotvim", ".config/vim"],
    ]
    links = [
        [".vimrc", "${HM_DEST}/.config/vim/.vimrc"],
        [".vim", "${HM_DEST}/.config/vim/.vim"],
    ]

Environment Variables

Homemaker supports the expansion of environment variables for both command and link blocks as well as for dependencies. This is a good way of avoiding having to hard code absolute paths into your configuration file. To reference an environment variable simply use ${ENVVAR} or $ENVVAR, where ENVVAR is the variable name (notice the similarity to normal shell variable expansion). In addition to being able to reference all of the environment variables defined on your system, Homemaker defines a couple of extra ones for ease of use:

Environment variables can also be set within tasks block by assigning them to the envs variable. The example below demonstrates the setting and clearing of environment variables:

[tasks.default]
    envs = [
        ["MYENV1", "foo"],        # set MYENV1 to foo
        ["MYENV2", "foo", "bar"], # set MYENV2 to foo,bar
        ["MYENV3"],               # clear MYENV3
    ]

It should be pointed out that it is possible to reference other environment variables using the syntax shown in the first part of this section. This makes it possible to expand variables like PATH without overwriting their existing value.

Command Macros

It is often convenient to execute certain commands repeatedly within task blocks to install packages, clone git repositories, etc. Homemaker provides macro blocks for this purpose; you can specify a command prefix and suffix that is used to wrap the parameters you provide. For example, you can declare a macro for apt-get install and with the declaration shown below (much like tasks, macro declarations are global).

[macros.install]
    prefix = ["sudo", "apt-get", "install", "-y"]

Macros can be referenced from commands by prefixing the macro name with the @ symbol (it must be the first character of the first item of a command). For example, the task below installs several python packages using the macro above:

[tasks.python]
    cmds = [
        ["@install", "python-dev", "python-pip", "python3-pip"]
    ]

Macros can have dependencies just like tasks. The git clone macro below makes sure that git is installed before attempting to clone a repository with it.

[macros.clone]
    deps = ["git"]
    prefix = ["git", "clone"]

[tasks.git]
    cmds = [
        ["@install", "git"]
    ]

Macros help reduce the clutter that comes from the repeated commands which must be executed to bootstrap a new system. When executed with the verbose option, Homemaker will echo the expanded macro commands before executing them.

Task and Macro Variants

If you wish to use this tool in a truly cross-platform and cross-distribution manner without authoring multiple configuration files, you will have to provide information to Homemaker about the environment it is running in. Different operating systems and distributions use different package managers and package names; we solve this problem with task and macro variants.

For example, if you want to write a generic macro for installing packages that works on both Ubuntu and Arch Linux, you can define the following variants (Ubuntu uses the apt package manager and Arch Linux uses pacman).

[macros.install__ubuntu]
    prefix = ["sudo", "apt-get", "install"]

[macros.install__arch]
    prefix = ["sudo", "pacman", "-S"]

The double underscore characters signify that the following identifier is a variant decorator. In most cases, you only have to think about variants when you are writing task and macro definitions, not when using them. For example, to see how to use the install macro that we just created, examine the configuration below:

[tasks.tmux]
    cmds = [["@install", "tmux"]]

Notice that the package manager is conveniently abstracted by the install macro. Be aware that for this example to work properly, you must specify a variant on the command line as shown below. Failing to specify a variant will cause Homemaker try to look for an undecorated install macro (which doesn’t exist), leading to failure.

$ homemaker --variant=ubuntu example.toml /mnt/data/config

Tasks can be be decorated much like commands:

[tasks.vim__server]
    cmds = [["@install", "vim-nox"]]

[tasks.vim]
    cmds = [["@install", "gvim"]]

In the above example, we avoid installing gvim on the server variant, where the X windowing system is not installed or needed. Homemaker only executes the best task or macro candidate; if the provided variant does not match any tasks or macros, the base undecorated version will be used instead if it is available.

The command below will execute the vim__server task:

$ homemaker --variant=server example.toml /mnt/data/config

Both of the commands below will execute the vim task:

$ homemaker --variant=foobar example.toml /mnt/data/config
$ homemaker example.toml /mnt/data/config

If for some reason you wish to explicitly reference the base task from the decorated task, you can add a dependency that contains a variant override as shown in the somewhat contrived examples below:

[tasks.foo]
[tasks.foo__specific]
    deps = ["foo__"]         # executes foo and foo_specific

[tasks.bar__specific]
[tasks.bar]
    deps = ["bar__specific"] # executes bar_specific and bar

Although variants are somewhat of an advanced topic as far as Homemaker features are concerned, they can be used to provide some basic conditional functionality to your configuration file without significantly increasing complexity for the user.

Conditional Execution

Homemaker provides a facility for determining whether or not a given task should execute at runtime; this is accomplished with the accepts and rejects task variables. Both follow the same syntax as the cmds variable and support macro and environment variable expansion.

The intent of this feature is to allow tasks to “early out” when the work they carry out has already been completed. In the example below, we use the which command to see if fish shell is already installed before trying to install it. This is possible because which returns a non-zero value when it encounters strings which do not correspond to applications installed on the current system.

[tasks.fish]
    rejects = [["which", "fish"]]
    cmds = [["@install", "fish"], ["chsh", "-s", "/usr/bin/fish"]]
    links = [[".config/fish/config.fish"]]

The accepts variable is the logical opposite of rejects and can be used to conditionally execute tasks only when all of the specified commands exit out with a return code of zero.

Usage

Executing Homemaker with the -help command line argument will trigger online help to be displayed. The list below provides a more detailed description of what the parameters do.

Sample

Below is a sample configuration file which should help to illustrate how Homemaker can be used in practice.

#
# macros
#

[macros.clone]
    deps = ["git"]
    prefix = ["git", "clone"]

[macros.install]
    prefix = ["sudo", "dnf", "install", "-y"]

#
# development
#

[tasks.dev]
    deps = ["git", "vim", "node", "python", "golang"]
    cmds = [[
        "@install",
        "make",
        "automake",
        "gcc",
        "gcc-c++",
        "cmake",
        "the_silver_searcher",
        "meld",
        "ncurses-compat-libs",
    ]]

[tasks.git]
    cmds = [["@install", "git"]]
    links = [[".gitconfig"]]

[tasks.golang]
    envs = [["GOPATH", "${HM_DEST}/projects/go"]]
    cmds = [["mkdir", "-p", "$GOPATH"], ["@install", "golang"]]

[tasks.node]
    cmds = [["@install", "nodejs", "npm"]]

[tasks.python]
    cmds = [["@install", "python-devel", "python-pip"]]

[tasks.vim]
    deps = ["vimrc"]
    cmds = [["@install", "vim-X11", "vim-enhanced"]]

[tasks.vimrc]
    rejects = [["test", "-d", ".config/vim"]]
    cmds = [["@clone", "https://github.com/FooSoft/dotvim.git", ".config/vim"]]
    links = [
        [".vim", "$HM_DEST/.config/vim/.vim"],
        [".vimrc", "$HM_DEST/.config/vim/.vimrc"],
        [".eslintrc.json"],
    ]

#
# general
#

[tasks.fusion]
    cmds = [["/home/alex/projects/dotfiles/bin/fusion.sh"]]

[tasks.virtualbox]
    cmds = [["@install", "VirtualBox"]]

[tasks.nvidia]
    deps = ["fusion"]
    cmds = [["@install", "akmod-nvidia"]]

[tasks.vlc]
    deps = ["fusion"]
    cmds = [["@install", "vlc"]]

[tasks.dropbox]
    deps = ["fusion"]
    cmds = [["@install", "dropbox"]]

[tasks.fish]
    rejects = [["which", "fish"]]
    cmds = [["@install", "fish"], ["chsh", "-s", "/usr/bin/fish"]]
    links = [[".config/fish/config.fish"]]

[tasks.common_term]
    cmds = [["@install", "openssh-server", "fzf", "htop", "p7zip", "unrar", "tmux", "whois", "rsync"]]
    links = [["bin"]]

[tasks.ibus]
    cmds = [["@install", "ibus", "ibus-anthy", "ibus-qt"]]

[tasks.default]
    deps = ["fusion", "common_term", "ibus", "vlc", "dropbox", "dev"]
    cmds = [["@install", "gimp", "keepassxc", "speedcrunch"]]
    links = [[".profile"]]