FormalOS

Builtins

Every function a spec.star, dev.star or secrets.star can call, with its arguments and defaults.

A spec is Starlark: Python syntax with def, if, for, comprehensions and lambda, and no access to files, the network or the clock. On top of the language, FormalSpec adds the builtins on this page.

Every builtin checks its arguments when it runs, so a misspelled or unknown field fails on the line that wrote it. An argument passed as None counts as unset. Positional arguments are listed in the signature; everything else is a keyword.

Besides these, spec.star and dev.star have Starlark's struct, json.encode, json.decode and print.

Files and their globals

FileMust defineHas
spec.starhosts = [host(...), ...]every builtin on this page except dev, lib and bin; load("//...") and load("gh:...")
dev.starone call to dev(...)dev, lib, bin, pkg, pypi, the source builtins, service (the project form), a platform global; load("//...")
secrets.starsecrets = {...}struct and print only, no load

load("//modules/cli.star", "cli") resolves from the directory that holds spec.star. Relative loads (load("cli.star")) are an error. Hosts and modules covers gh: loads.

host

host(name, platform, system = None, users = [], modules = [], ssh = name,
     protect = [], persist = [], release_age = None, builders = [], sync = [])
ArgumentTypeDefaultMeaning
namestringrequiredThe host's name. A host is this machine when the name equals the system host name, lowercased, up to the first dot
platformstringrequiredx86_64-linux, aarch64-linux, x86_64-darwin or aarch64-darwin
systemimage(...) or a distro builtinnoneThe system layer. Required on Linux, not allowed on macOS
userslist of user(...)[]Accounts this host manages
modulesresource, list, function or None[]Everything else the host has. Lists nest; a function is called with the host's facts
sshstringthe nameThe ssh destination fspec apply reaches the host by
protectlist of paths[]Paths FormalSpec refuses to act under, such as /home. The applying user's own home stays usable
persistlist of paths[]Paths fspec switch --reset-var keeps
release_agestringnoneResolve packages to the newest release at least this old: 3d, 12h, 2w (units m, h, d, w)
builderslist of builder(...)[]Machines that build git(...) packages for this host
synclist of sync(...)[]App data this host syncs with its peers

A function in modules receives a struct with:

FieldExample
name"laptop"
platform.name"aarch64-darwin"
platform.os"linux" or "darwin"
platform.arch"x86_64" or "aarch64"
platform.is_linux, platform.is_darwinTrue or False
distro"fedora", "bazzite", "arch", "debian", "ubuntu", "void", "aerynos", "opensuse-tumbleweed", "macos"
init"systemd", "launchd", "runit", "openrc", "dinit", "s6"

modules takes pkg, file, service, timer, user, secret, defaults, setting, repo and sync. A sync in modules joins the host's sync list. image(...) and the distro builtins go in system, never in modules.

System layer

image

image(base, packages = None, copr = [], repos = [], stages = [], steps = [],
      kernel_args = [], distro = None)

A bootc image, built by CI and switched to with bootc switch. See The system layer.

ArgumentTypeDefaultMeaning
basestringrequiredBase image, such as ghcr.io/ublue-os/bazzite-nvidia:stable
packagesdnf(...)nonePackages layered into the image. The base must be a dnf distro
coprlist of strings[]COPR projects (owner/project) enabled before the packages install
reposlist of URLs[].repo files added before the packages install
stageslist of strings[]Containerfile stages placed before the image's FROM; each starts with FROM
stepslist of strings[]Containerfile instructions run after the packages
kernel_argslist of strings[]Kernel arguments, written to /usr/lib/bootc/kargs.d/fspec.toml
distrostringfrom the base nameThe distro the image is; bazzite-nvidia gives bazzite

An image host runs systemd.

fedora, arch, debian, ubuntu, void, aerynos, tumbleweed

fedora(packages = [], init = "systemd")
tumbleweed(packages = [], kernel_args = [])

A classic distro. All seven take keywords only.

BuiltinDistroPackage managerDefault init
fedora()Fedora Workstation and spinsdnfsystemd
arch()Arch Linuxpacmansystemd
debian()Debianaptsystemd
ubuntu()Ubuntuaptsystemd
void()Void Linuxxbpsrunit
aerynos()AerynOSmosssystemd
tumbleweed()openSUSE Tumbleweedzyppersystemd
ArgumentTypeDefaultMeaning
packageslist of strings[]Base packages, named the way the distro names them. Removing a name removes the package. On Tumbleweed, "!name" removes a conflicting package in the same transaction
initstringper distrosystemd, runit, openrc, dinit or s6
kernel_argslist of strings[]Tumbleweed only: options kept in /etc/kernel/cmdline, one option each, no spaces

repo

repo(name, url, key = None, fingerprint = None)

A third-party zypper repository. Tumbleweed only.

ArgumentTypeDefaultMeaning
namestringrequiredThe repository alias
urlstringrequiredThe repository URL
keyURLnoneSigning key, imported with rpm --import
fingerprintstringnoneThe key's full 40-digit fingerprint. Required with key

pkg

pkg(name, source = aqua(name), version = None, mode = "pinned", channel = "stable", ...)

A package. See Packages.

ArgumentTypeDefaultMeaning
namestringrequiredThe package name, unique per host
sourcea source builtinaqua(name)Where it comes from. pkg("python") defaults to CPython from python-build-standalone
versionstringnoneLock this exact upstream version instead of the newest
modestring"pinned""pinned": the lock's version, always. "self_managed": install once, then leave updates to the app
channelstring"stable""bleeding" ignores the host's release_age
binslist of strings[]Only link these executables, by file name. Empty links every command the package ships
linksdict{}Extra command names for one of its executables: {"cargo": "rustup"}
sharedict{}Linux: link a path under /usr/local/share to a path inside the package ("." is its root). For fonts and themes
etcdict{}Linux: link a path under /etc to a path inside the package
appslist of strings[]macOS: .app bundles inside the package, copied to /Applications
partsdict{}Extra url(...) archives unpacked into the package, keyed by the path inside it
patcheslist of paths[]Patch files in the spec repository, applied in order
buildlist of strings[]For git(...): the build command, replacing recipe detection
build_flagslist of strings[]For git(...): appended to the recipe's configure or build command
fileslist of file(...)[]Files that belong to the package and go with it
serviceslist of service(...)[]Services that belong to the package and go with it
timerslist of timer(...)[]Timers that belong to the package and go with it
datalist of paths[]App data moved to the trash when the package is removed
keep_databoolFalseLeave app data in place on removal

pypi

pypi(name, ...)

A PyPI distribution, for dev.star. Same arguments as pkg except source.

Sources

Each returns a value for pkg(source = ...).

BuiltinPositionalKeywordsInstalls from
aqua(name)nameThe aqua registry, which maps tool names to their GitHub releases
github(repo)owner/nameasset: a glob over release asset namesGitHub releases, the asset matching the platform
npm(name)nameThe npm registry
cargo(name)nameA crate's prebuilt GitHub release assets. Never compiles
flatpak(id)idremote = "flathub"Flathub or another remote. Linux
brew(name)namecask = False, tap = "owner/repo"Homebrew formulae and casks. macOS
url(url)urlOne file or archive at a fixed URL
git(url, rev = "main")url, revdeps = [], image = NoneA source build at a commit. See Building from source
nix(attr)attrA nixpkgs attribute, on hosts that have Nix
dnf(names), pacman(names), apt(names), xbps(names), moss(names), zypper(names)a name or a listrepo (zypper only)The distro's repositories, on hosts of that distro

names defaults to the package's own name. zypper("nordvpn", repo = "nordvpn") ties the package to a repo(...), so it waits with that repository when the repository is unreachable.

file

file(path, src = None, content = None, target = None, mode = "managed",
     format = None, owns = {}, on_conflict = "ours", on_drift = "overwrite", perms = None)

A file. A path under ~/ is in the user scope; an absolute path is in the system scope. See Files.

ArgumentTypeDefaultMeaning
pathstringrequiredAbsolute, or ~/...
srcpathnoneA file in the spec repository, copied as is. For link, a file or directory
contentstring or valuenoneA string is written as is. Any other value is serialized to format
targetpathnonelink only: a path outside the repository to point at
modestring"managed""managed", "link", "seed" or "patch"
formatstringfrom the extension"text", "json", "toml", "yaml", "ini", "plist" or "lua"
ownsdict{}patch only: dotted key paths and their values
on_conflictstring"ours"patch only: "ours", "theirs" or "fail" when a key you own changed on disk
on_driftstring"overwrite"managed only: "overwrite" or "fail" when the file changed on disk
permsstringthe src file's mode, else the existing file's, else "0644"Octal permission bits: "0600", "0440". No effect in link and patch mode

Each mode takes its content one way:

ModeTakes
managed, seedexactly one of src or content
linkexactly one of src or target
patchowns only, on a JSON, TOML or YAML file

The format comes from the path's extension (.json, .toml, .yaml, .yml, .ini, .plist, .lua) unless format sets it. A dict or list content needs a format. lua writes return { ... }.

list_union, list_replace, list_keyed

list_union(items)
list_replace(items)
list_keyed(field, items)

Strategies for a list under file(owns = ...):

BuiltinResult
list_union(items)Adds the items that are missing, keeps what the app added, removes items you added earlier and no longer list
list_replace(items)The list is exactly items
list_keyed(field, items)Merges dicts by the value of field; every item needs it

secret

secret(path, src, perms = "0400", owner = None, identities = [])

An age-encrypted file from the repository, decrypted at apply. See Secrets.

ArgumentTypeDefaultMeaning
pathstringrequiredWhere the plaintext goes: absolute or ~/...
srcpathrequiredThe .age file in the repository
permsstring"0400"Octal permission bits
ownerstringnoneuser or user:group
identitieslist of pathsthe defaultsPrivate keys to decrypt with, tried in order

Default identities: in the system scope /etc/ssh/ssh_host_ed25519_key, /etc/ssh/ssh_host_rsa_key, then ~/.config/fspec/age.key; in the user scope ~/.ssh/id_ed25519, ~/.ssh/id_rsa, then ~/.config/fspec/age.key.

service

service(name, exec = None, user = False, restart = "no", after = [], env = {},
        description = None, systemd = {}, launchd = {}, display_manager = False)

A service, rendered for the host's init system. See Services and timers.

ArgumentTypeDefaultMeaning
namestringrequiredLetters, digits and _ . - @
execstringnoneThe command. Without it, the service is a unit the distro ships, which fspec only enables
userboolFalseA user service instead of a system one
restartstring"no""no", "on_failure" or "always"
afterlist of strings[]"network", another service's name, or (systemd) a literal *.target
envdict{}Environment variables
descriptionstringnoneOne line
systemddict{}systemd directives; unit, service and install are dicts merged into those sections
launchddict{}Raw plist keys merged over the ones fspec writes
display_managerboolFalsesystemd: this unit is the login screen, so the host boots to graphical.target

A service without exec takes only name, user (systemd) and display_manager.

timer

timer(name, exec, schedule, user = False, env = {})

A scheduled job: a systemd timer, a cron entry or a launchd calendar interval.

ArgumentTypeDefaultMeaning
namestringrequiredNo . on cron inits
execstringrequiredThe command
schedulestringrequiredFive-field cron, or @hourly, @daily, @weekly, @monthly
userboolFalseRun as the user
envdict{}Environment variables

user

user(name, uid = None, shell = None, groups = [])
ArgumentTypeDefaultMeaning
namestringrequiredThe account name
uidintnonePin the UID so file ownership survives a reinstall
shellstringnoneA name ("fish"), resolved through /etc/shells, or a path
groupslist of strings[]Supplementary groups. Linux only. fspec adds missing groups and never removes one

On Linux a missing account is created with useradd, and an existing one gets the declared UID and shell with usermod. On macOS the account must exist; fspec sets its login shell and checks the UID.

defaults

defaults(domain, key, value, system = False)

A macOS defaults key. value is a bool, int, float, string, list or dict. system = True writes the machine-wide domain in /Library/Preferences as root.

defaults("com.apple.dock", "autohide", True)
defaults("NSGlobalDomain", "KeyRepeat", 2)

setting

setting(tool, key, value)

A macOS setting behind a command instead of a defaults domain.

toolRunsvalue
"pmset"pmset -a <key> <value>a number, a string without spaces, or a bool written as 1/0
"systemsetup"systemsetup -set<key> on|offa bool
"firewall"socketfilterfw --set<key> on|offa bool
setting("pmset", "sleep", 0)
setting("firewall", "globalstate", True)

builder

builder(name, routes = [name])

A machine that builds git(...) packages for a host, reached over ssh. routes are ssh destinations for the same machine, tried in order.

sync, json_keys, keyring

sync(name, peers, paths = [], include = [], exclude = [], keys = None, keyring = [],
     mode = "live", conflict = "newest", after = [], on_receive = None, unless_running = [])
json_keys(path, keys)
keyring(schema = None, attrs = {}, label = None)

App data shared between your hosts over ssh. See Sync between hosts.

ArgumentTypeDefaultMeaning
namestringrequiredLetters, digits, - and _
peerslist of host namesrequiredEvery host that holds the item, this one included
pathslist of paths[]Files, or directories ending in /, under ~/
include, excludelist of globs[]Filter files inside a directory
keysjson_keys(...)noneSync only these top-level keys of a JSON file
keyringlist of keyring(...)[]Secret Service items
modestring"live""live": every peer writes. "seed": copied once to a host that has none
conflictstring"newest""newest" or "manual"
afterlist of item names[]Items that must sync first
on_receivestringnoneA shell command run after the item changed on this host; {key} is a key's new value
unless_runninglist of process names[]Skip the item while one of these runs on either host

An item needs at least one of paths, keys or keyring. keyring needs a schema or attrs; an attribute value ending in * matches a prefix.

Helpers

BuiltinReturns
glob(pattern)Sorted paths of the files under the spec directory that match, relative to it. No .., no absolute patterns
origin()In a file loaded with gh:, struct(url = "https://github.com/<owner>/<repo>", rev = "<locked commit>"). None in your own files

dev.star

dev

dev(packages = [], libs = [], bins = [], env = {}, services = [], shell_hook = None,
    container = None, fallback_container = None, platforms = [...])
ArgumentTypeDefaultMeaning
packageslist of pkg(...) or pypi(...)[]Tools on the project's PATH
libslist of lib(...)[]System libraries, by pkg-config module
binslist of bin(...)[]System executables
envdict{}Environment variables
serviceslist of service(...)[]Processes or containers that run while a shell is open
shell_hookstringnonesh code run after the environment is set; what it exports is kept
containerimagenoneRun the shell in this image, such as debian:13
fallback_containerimagenoneRun in this image only on machines that cannot provide a lib or bin
platformslistall fourThe platforms the project supports

dev.star has a global platform with name, os, arch, is_linux and is_darwin.

lib, bin

lib(name, apt = None, dnf = None, zypper = None, pacman = None, xbps = None, moss = None, brew = None)
bin(name, ...)

lib("gtk4") is the pkg-config module gtk4, or with a .so name a shared object. bin("cmake") is an executable. fspec update asks each package manager which package provides it. Name the package by hand where that lookup cannot: lib("x", apt = "libx-dev").

service (in dev.star)

service(name, exec = None, version = None, image = None, ports = [], env = {})

Without exec, the image <name>:<version> (or image), pinned by digest, run with each port published on 127.0.0.1. With exec, a command from the project's environment.

secrets.star

kyan = "age1..."
laptop = "ssh-ed25519 AAAA..."

secrets = {
    "secrets/openai.age": [kyan, laptop],
    "secrets/wifi.age": [kyan],
}

Maps each .age file, relative to the spec directory, to the public keys it is encrypted to: age recipients (age1...) or ssh public keys (ssh-ed25519 ..., ssh-rsa ...). fspec secret edit and fspec secret rekey read it.