A demo cram repo
You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Reid 'arrdem' McKenzie e6d564111a
A demo state
5 months ago
hosts.d/demo A demo state 5 months ago
packages.d A demo state 5 months ago
profiles.d A demo state 5 months ago
.gitignore A demo state 5 months ago
README.md A demo state 5 months ago
cram A demo state 5 months ago


Cram demo

Hello and welcome to a workable demo repo of Cram; arrdem's personal dotfiles manager.

Cram (the ./cram file in this repo) is a tiny package manager. Cram doesn't know anything about $XDG_CONFIG_DIR or your OS's package manager or even dotfiles at all. What cram does know about is packages and profiles.

TO Cram, a package is a directory under packages.d (this is hardcoded). Such a directory may contain a pkg.toml file, which as with other package file formats may describe dependencies, preparation, installation and post-install steps. An example of such a file is as follows -

version = 1

# The package.require list names depended artifacts.
name = "packages.d/some-other-package"

# (optional) The package.build list enumerates either
# inline scripts or script files. These are run as a
# package is 'built' before it is installed.
run = "some-build-command"

# (optional) Hook script(s) which occur before installation.
run = "some-hook"

# (optional) Override installation scrpt(s).
# By default, everthing under the package directory
# (the `pkg.toml` excepted) treated is as a file to be
# installed and stow is emulated using symlinks.
run = "some-install-command"

# (optional) Hook script(s) which after installation.
run = "some-other-hook"

Managing files with cram

Cram is used to "apply" changes to a directory under management. The conventional incantation for this is ./cram apply ~/conf ~/, for managing a home directory or dotfiles.

Let's look at the usage -

$ ./cram apply --help
Usage: __main__.py apply [OPTIONS] CONFDIR DESTDIR

  The entry point of cram.

  --execute / --dry-run
  --force / --no-force
  --state-file PATH
  --optimize / --no-optimize
  --require TEXT
  --exec-idempotent / --exec-always
  --help                          Show this message and exit.

By default, Cram will "require" the following packages:

  • profiles.d/default
  • profiles.d/$HOSTNAME

But you can override this by passing --require. For the purposes of this demo, we will just install the zsh package. Don't worry, we aren't going to actually install anything here.

$ ./cram apply --dry-run --require packages.d/fake . ~/
2022-07-28 22:25:26,521 - __main__ - WARNING - No previous statefile .cram.log
- mkdir ~/.config
- chmod ~/.config 16877
- mkdir ~/.config/fake
- chmod ~/.config/fake 16877
- link ./packages.d/fake/.config/fake/b.conf ~/.config/fake/b.conf
- link ./packages.d/fake/.config/fake/a.conf ~/.config/fake/a.conf

--dry-run (which is also the default behavior) instructed cram to figure out what to do, but not to do anything. This is a changelog of commands which Cram is proposing to execute against your filesystem. All of these commands are generated by the default stow installer which the zsh package happens to use, and produce an installed state. Were you to use apply --execute, Cram would go ahead and make these changes.

That No previous statefile warning is the secret sauce of Cram. Cram works in terms not just of this log of what changes it will make, but in terms of a persisted log of what changes it has made. This allows Cram to optimize repeated executions to remove installation steps that haven't changed, while still retaining a precise log of how to get where you are now from an empty slate. This also allows Cram, unlike other dotfile managers, to clean up after itself.

Let's do a real demo of this.

$ ./cram apply --execute --require packages.d/fake . ~/
- mkdir ~/.config
- chmod ~/.config 16877
- mkdir ~/.config/fake
- chmod ~/.config/fake 16877
- link ./packages.d/fake/.config/fake/a.conf ~/.config/fake/a.conf
- link ./packages.d/fake/.config/fake/b.conf ~/.config/fake/b.conf

So now we've got two files and a couple directories on the filesystem we may or may not want. We can see the record of this state as follows -

$ ./cram state .
- mkdir ~/.config
- chmod ~/.config 16877
- mkdir ~/.config/fake
- chmod ~/.config/fake 16877
- link ./packages.d/fake/.config/fake/a.conf ~/.config/fake/a.conf
- link ./packages.d/fake/.config/fake/b.conf ~/.config/fake/b.conf

Were you to delete a file, say rm packages.d/fake/.config/a.conf and then inspect changes -

$ rm packages.d/fake/.config/a.conf
$ ./cram apply --require packages.d/fake . ~/
- unlink ~/.config/fake/a.conf

What cram did here was compute what the install steps for the current state would be, compare that with the PREVIOUSLY EXECUTED steps, identify a file that is no longer to be installed, and include removing that file in the new plan.

And if we apply --execute our changes, note that the state file DOES NOT include the unlink cleanup instruction. It only contains the steps required to produce the now-current state

$ ./cram apply --execute --require packages.d/fake . ~/
- unlink ~/.config/fake/a.conf
$ ./cram state .
- mkdir ~/.config
- chmod ~/.config 16877
- mkdir ~/.config/fake
- chmod ~/.config/fake 16877
- link ./packages.d/fake/.config/fake/b.conf ~/.config/fake/b.conf

Managing software with Cram

Okay so that gives you a good idea of how to manage files with Cram packages. Software is a bit trickier. Let's look at how homebrew would be installed -

$ ./cram list . packages.d/homebrew
packages.d/homebrew: (PackageV1)
  - exec /tmp ('/bin/sh', PosixPath('/tmp/stow/e5d3a54761ee43023832d565e11ec4661b84f4ec66629042674b6658993e8cb8.sh'))

Not super helpful - let's take a look at the pkg.toml

$ cat packages.d/homebrew/pkg.toml

version = 1

require = []

run = "[ ! -e /opt/homebrew/bin/brew ] && /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""

Managing larger configurations with cram

We've hinted a couple times at the package.require feature of Cram packages. But that doesn't tell you too much about how to organize larger configurations.

A profile is a directory under profiles.d or hosts.d which may but need not have a pkg.toml specifying requirements. Where a package is fundamentally a set of installation directives, a profile is a group of packages. For instance profiles.d/emacs/doom-emacs is a package of configurations specific to the Emacs package.

The tricky bit is that a profile IMPLICITLY requires all its subpackages. This is useful for profile and host specific packages - you don't have to have a bunch of macos-foo packages running around, they could live in profiles.d/macos/* and then a given MacOS host can depend on profiles.d/macos to grab all the relevant configuration.

The hosts.d/demo host provides an example of this pattern by depending on the macos and work profiles as meta-packages.

To see how the demo host would be installed, ./cram apply --require profiles.d/default --require hosts.d/demo . ~/ would do the trick.

Limitations of Cram

  • Cram does not do static analysis or sandboxing of your run = scripts and assumes they're idempotent.
  • Cram does not a templating engine.
  • Cram does not have an inventory or data system.
  • It can be difficult to
  • Cram does not have a conditional dependency system - such as detecting the platform OS and varying requirements accordingly.
  • Cram is not recursive and does not support nested transactions. It's a one-shot system.
  • Cram assumes that if you have files or directories which CONFLICT with an apply --execute and are not managed that you mean to delete them.
  • Cram can't tell that there are unmanaged files or directories alongside a directory you're removing from config.
  • Cram can't (easily) create symlinks that aren't pointers back to packaged files.
  • Cram has no concept of a package manager or providers; can't abstract over where you're going to get emacs from.
  • Cram has no pattern for depending on remote packages. Everything must be vendored into your repo.
  • Cram cannot be configured. You get packages.d, profiles.d and hosts.d. That's it.
  • Cram cannot be configured. There is no host.d/default, you get host.d/$HOSTNAME or an error.
  • Cram cannot be configured and uses /bin/sh for scripts.
  • Cram cannot be configured and uses a new /bin/sh for each script; this prevents export PATH= from functioning and may impede installing user-local package managers or leveraging user-local software.
  • Cram uses .cram.log as a state file, not ~/.local/cram/cram.log or some other harder to git clean -fdx state.
  • Cram maybe should know something about the XDG directory conventions and common dotfiles.
  • There is no cram package command for turning a directory tree into a cram package.
  • There is no cram check command for self-validating the state of your config repo.
  • There is no cram untracked command for identifying cram-unmanaged files inhabiting cram-managed directories.
  • The name cram is contended and a new one may be chosen.
  • The concept of a profile is somewhat backwards - a real metapackage or group may be introduced with profiles becoming configuration packages.
  • Cram will probably break while you use it, be prepared to unzip ./cram and edit the source.
  • The cram fmt directive produces pretty garbage output.


Copyright Reid 'arrdem' McKenzie, 28/07/2022.

Published under the terms of the Anticapitalist Software License (https://anticapitalist.software).

Unlimited commercial licensing is available at nominal pricing.