# Hosts and modules

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

```starlark
hosts = [
    host(
        name = "desktop",
        platform = "x86_64-linux",
        system = fedora(packages = ["fish", "git"]),
        users = [user("alex", uid = 1000, shell = "fish")],
        modules = [pkg("ripgrep")],
    ),
]
```

| Argument | What it decides |
| --- | --- |
| `name` | Which machine this is. It matches the system host name, lowercased, up to the first dot |
| `platform` | The CPU and OS: `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`, `x86_64-darwin` |
| `system` | On Linux, the distro or bootc image. macOS hosts leave it out |
| `users` | The accounts FormalSpec manages |
| `modules` | Everything else |

The full list, including `ssh`, `protect`, `release_age`, `builders` and `sync`, is in the
[builtins reference](https://os.formalsnake.dev/docs/reference/builtins.md#host).

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](https://os.formalsnake.dev/docs/guides/remote-hosts.md)).

## Write a module

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

```starlark
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:

```starlark
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:

```starlark
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`:

```starlark
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:

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

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

The part after `@` picks the version:

| Form | Meaning |
| --- | --- |
| `gh:owner/repo//file.star` | The default branch, moved by each `fspec update` |
| `gh:owner/repo@branch//file.star` or `@tag` | That branch or tag, moved by each `fspec update` |
| `gh:owner/repo@<40-hex commit>//file.star` | That 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`:

```toml
[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:

```starlark
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](https://os.formalsnake.dev/docs/guides/source-builds.md) covers recipes.

## Constraints

- Host names are unique. So are package names, file paths and service names within a host;
  declaring one twice fails the plan.
- `image(...)` and the distro builtins go in `system`, never in `modules`.
- A file, service or timer declared inside `pkg(files = ..., services = ..., timers = ...)`
  belongs to that package and is removed with it. Declared on its own, it is removed only when
  its own line goes.
