FormalOS

Hosts and modules

Declare several machines in one spec, share configuration between them with modules, and split the spec into files.

A spec declares one or more hosts. Each host lists its modules: the packages, files, services and other resources it has. A module is ordinary Starlark, usually a function, so sharing configuration between machines is the same as calling a function twice.

Declare a host

hosts = [
    host(
        name = "desktop",
        platform = "x86_64-linux",
        system = fedora(packages = ["fish", "git"]),
        users = [user("alex", uid = 1000, shell = "fish")],
        modules = [pkg("ripgrep")],
    ),
]
ArgumentWhat it decides
nameWhich machine this is. It matches the system host name, lowercased, up to the first dot
platformThe CPU and OS: x86_64-linux, aarch64-linux, aarch64-darwin, x86_64-darwin
systemOn Linux, the distro or bootc image. macOS hosts leave it out
usersThe accounts FormalSpec manages
modulesEverything else

The full list, including ssh, protect, release_age, builders and sync, is in the builtins reference.

When you run a command without --host, FormalSpec picks the host whose name matches this machine. A host with any other name is remote, and apply reaches it over ssh (Remote hosts).

Write a module

A module is a function that returns a list of resources:

def cli():
    return [
        pkg("ripgrep"),
        pkg("fd", source = github("sharkdp/fd")),
        pkg("jq"),
    ]

modules accepts a resource, a list of resources (nested lists are flattened), or a function. A function in modules is called with the host's facts, so one module can adapt to each host:

def cli(host):
    tools = [pkg("ripgrep"), pkg("fd", source = github("sharkdp/fd"))]
    if host.platform.is_darwin:
        tools.append(pkg("aerospace", source = brew(cask = True)))
    if host.distro == "fedora":
        tools.append(pkg("dnf-plugins-core", source = dnf()))
    return tools

hosts = [
    host(name = "laptop", platform = "aarch64-darwin", modules = [cli]),
    host(name = "desktop", platform = "x86_64-linux", system = fedora(), modules = [cli]),
]

Pass the function itself (cli) to get the facts. Calling it yourself (cli()) works too, for a module that takes no facts or takes your own arguments.

The facts struct has name, platform (name, os, arch, is_linux, is_darwin), distro and init. They come from the declaration, so evaluation never depends on which machine it runs on.

A plain if replaces Nix's mkIf, and a function argument replaces an option. There is no module system to learn: no option types, no priorities, no merging.

Build hosts from a function

Hosts that differ in a few details are one function:

load("//modules/cli.star", "cli")
load("//modules/desktop.star", "desktop")

def laptop(name, extra = []):
    return host(
        name = name,
        platform = "x86_64-linux",
        system = image(base = "ghcr.io/ublue-os/bazzite:stable"),
        users = [user("alex", uid = 1000, shell = "fish")],
        modules = [cli, desktop] + extra,
    )

hosts = [
    laptop("work"),
    laptop("home", extra = [pkg("steam", source = flatpak("com.valvesoftware.Steam"), mode = "self_managed")]),
]

Split the spec into files

load imports names from another file. Paths start with //, meaning the directory that holds spec.star:

load("//modules/cli.star", "cli")
load("//modules/desktop.star", "desktop", "FONTS")

Relative paths without // are an error. A loaded file can load others the same way. Names starting with _ stay private to their file.

The usual layout:

spec.star          entry point, sets hosts
modules/           one concern per file
files/             config files that file(src = ...) copies
patches/           .patch files referenced by packages
secrets/           .age files
secrets.star       who each secret is encrypted to
fspec.lock         generated by fspec update, committed

Paths in file(src = ...), patches and glob(...) are relative to the spec directory, from whichever file names them.

Load a module from another repository

A project can ship its own recipe, and a spec can load it straight from GitHub:

load("gh:FormalStudio/formalmusic@main//fspec.star", "formalmusic")

hosts = [host(name = "laptop", platform = "aarch64-darwin", modules = [formalmusic()])]

The part after @ picks the version:

FormMeaning
gh:owner/repo//file.starThe default branch, moved by each fspec update
gh:owner/repo@branch//file.star or @tagThat branch or tag, moved by each fspec update
gh:owner/repo@<40-hex commit>//file.starThat commit, never moved

fspec update resolves the ref to a commit, fetches the file and every // file it loads from the same repository, and records the commit and each file's hash in fspec.lock:

[loads."gh:FormalStudio/formalmusic@main"]
rev = "0123456789abcdef0123456789abcdef01234567"
files = { "fspec.star" = "sha256-...", "lib.star" = "sha256-..." }

Evaluation never resolves anything. It reads each file at the locked commit from a local cache, or from GitHub with your token, and checks the hash. A load missing from the lock fails with run fspec update. fspec update <pkg> updates packages and leaves every load at its commit.

Inside a loaded file, origin() returns the repository URL and the locked commit, so the recipe can build the project at the same commit it was read from:

def formalmusic():
    o = origin()
    return pkg("formalmusic", source = git(o.url, rev = o.rev))

gh: loads work only in spec.star and its own loads, not in dev.star. A loaded file names paths (glob, src, patches) relative to your spec, not to its own repository. Building from source covers recipes.

Constraints