Files
adamandClaude Opus 5.5 3a3b4b8eb9 add AGENTS.md
Guidance for agents working on the repo: layout, build and checks, the
invariants (ownership, permissions, sandbox, data safety), code
conventions, a checklist for adding commands, testing without root,
docs style, the changelog, releases and git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:41:31 +00:00

247 lines
13 KiB
Markdown

# AGENTS.md
Guidance for AI agents (and people) working on this repository.
## What this is
`terraria-server-manager` runs any number of vanilla Terraria and tModLoader
dedicated servers on one Linux host, each as its own systemd service
(`terraria@<name>.service`), managed with the `terractl` command. It ships as a
Debian package. Server software is never bundled: official zips go in
`/var/lib/terraria/dist` and are unpacked into each instance on demand.
The user-facing documentation is `docs/guide.md` (installed as the package's
README). Read the sections relevant to a change before making it; the guide is
the specification of how things behave.
## Layout
| Path | What it is |
|---|---|
| `bin/terractl` | The main tool, in bash. Everything on the local host. |
| `lib/terractl-remote` | Python (standard library only, 3.9+): `--host`, `remote`, `daemon`, `copy`, `move`. Its daemon does everything by running `terractl` on its own host. |
| `completions/bash/terractl` | Bash completion. |
| `systemd/` | `terraria@.service`/`.socket` (one per instance), `terractld.service` (daemon for other hosts), `terraria-backup@.service`/`.timer` (scheduled backups). |
| `config/` | `default/terractl` (user overrides, a conffile), `tmpfiles.d`, `sysusers.d`, `polkit` rule, `journald@terraria.conf` (a conffile). |
| `packaging/deb/` | `control`, `conffiles`, maintainer scripts (`postinst`, `prerm`, `postrm`). |
| `packaging/gitea-release.py` | Publishes a release with that version's CHANGELOG section as notes. |
| `.gitea/workflows/release.yaml` | On a `v*` tag: `make deb`, publish the release and the apt repository. |
| `tools/windows/export-tmod-mods.ps1` | Helper for players to export their enabled tModLoader mods. |
| `docs/guide.md`, `README.md`, `CHANGELOG.md`, `VERSION` | Docs and release metadata. |
Placeholders `@VERSION@`, `@BINDIR@`, `@DOCDIR@` and `@PKGLIBDIR@` in
`bin/terractl` and the unit files are filled in by `make install`/`make deb`.
A copy run straight from the repo is a "development copy": it detects the
unfilled placeholders and uses `lib/terractl-remote` from the source tree.
## Build and check
```bash
make check # bash -n, sh -n on maintainer scripts, Python syntax, shellcheck, pyflakes
make deb # runs check, builds build/terraria-server-manager_<version>_all.deb
```
**`make check` must pass with shellcheck installed** (`apt install shellcheck
python3-pyflakes`). The release workflow runs it, so a shellcheck warning blocks
a release. Fix warnings rather than disabling them; a `# shellcheck disable=`
needs a comment saying why (see the existing ones).
There is no automated test suite. See [Testing](#testing).
## How the system fits together
These invariants run through the whole code base. Keep them.
- **Servers run as the unprivileged `terraria` user**, sandboxed by
`terraria@.service` (`ProtectSystem=strict`, `ProtectHome=yes`, writable only
`ReadWritePaths=/var/lib/terraria/instances`, `UMask=0027`).
- **Nothing in an instance may end up owned by root.** Commands run as root
`chown` what they create (`fix_owner`, `chown -R` after `new`/`import`).
`prepare` and `run` started as root switch to `terraria` with `runuser`
(re-running `$SELF`, which is resolved before the script's `cd /`).
- **Permission model:** root can do everything; members of the `terraria` group
can read instances, start/stop/restart servers (polkit rule), use the console
(`attach`, `cmd`, `watch`), read the servers' own journal (log namespace
`terraria`), add zips to `dist/` and read backups. Group members can read but
never change instance files. Every new command must fit this model and be
added to the permissions table in the guide.
- **`instance.conf` is sourced by bash.** Anything written into it (e.g. `ZIP=`)
must be validated first (`safe_zip_name`). Instance names are validated by
`valid_name` and can't start with `.`.
- **One server per instance at a time:** the lock on `<instance>/.lock` (fd 9)
is held for the server's lifetime. Commands that replace `server/` or delete
things under a running server must check `instance_running`.
- **`server/` is replaceable, `data/` is precious.** Unpacking happens in a temp
folder and only replaces `server/` once complete; `server/.installed` records
`<type> <zip>`. Nothing may delete or rewrite worlds without a backup.
- **Changes that can lose data back up first** and leave things as they were on
failure (see `cmd_upgrade`: validate everything, stop, back up, change, roll
back the pin and restart on failure).
- **Files outside `/var/lib/terraria`:** only the documented drop-ins in
`/etc/systemd/system` (`terraria-backup@<name>.{timer,service}.d/terractl.conf`
from `backup enable`, `<unit>.d/terractl-locations.conf` from `doctor --fix`).
`postrm` purge removes them. Never overwrite admin files or unit files.
- **The daemon (`terractld`) runs `terractl` as root** with `TERRACTL_VIA` set.
Only commands listed in `EXEC_COMMANDS` in `lib/terractl-remote` can be run
remotely. It runs with `ProtectSystem=full` and `ProtectHome=true`, plus write
access to `/etc/systemd/system` only.
- **Locations are configurable** (`TERRARIA_BASE`, `TERRARIA_DIST`,
`TERRARIA_BACKUPS` in `/etc/default/terractl`), but unit files can't follow
them; `location_warnings` and `doctor` detect mismatches. Never hard-code
`/var/lib/terraria/...` in `bin/terractl`: use `$BASE_DIR`, `$DIST_DIR`,
`$BACKUP_DIR`.
## Code conventions
### bash (`bin/terractl`)
- `set -euo pipefail`. Be careful with pipelines that can legitimately fail
(`grep` with no match, `grep -q` closing a pipe early): capture to a variable
first, or add `|| true`.
- Errors: `die "message"` (exits 1); notes: `log "message"` (stderr). Both
prefix `terractl:`. Normal output goes to stdout.
- **Messages say what to do next.** When suggesting a command, use
`hint root|group <command> [args]`, which adds `sudo` only when needed and
shows `terractl --host <remote> ...` when run through the daemon.
- Commands are `cmd_<name>` functions; sub-actions are `<command>_<action>`
(e.g. `backup_enable`). Helpers are lower-case with underscores. Sections are
separated by `# ---- name ----` banner comments.
- Every non-obvious function gets a short comment above it saying what it does
and why; match the existing density (comments explain intent and edge cases,
not syntax).
- Option parsing: a `while`/`case` loop, `die "unknown option '$1'"` for
anything unexpected, and a `usage: terractl ...` message for wrong arguments.
- Root checks: `[[ $EUID -eq 0 ]] || die "'<command>' needs root: sudo terractl ..."`.
- `local` for all function variables. Globals are upper-case.
- `EXTRACT_TMP` is the single temp-folder slot that `cleanup` (the EXIT trap)
removes. Code that must survive a `die` in a helper runs it in a subshell
with its own `trap cleanup EXIT` (see `make_backup`).
### Python (`lib/terractl-remote`)
- Standard library only, Python 3.9+. `make check` runs pyflakes on it.
- It never touches instances directly: it runs `terractl` (locally, or via the
daemon on the other host) so checks and ownership stay in one place.
### Shell in packaging
- Maintainer scripts are POSIX `sh`, idempotent, and must never fail an
install or removal because systemd isn't running (`[ -d /run/systemd/system ]`
guards, `|| true` on best-effort steps).
## Adding or changing a command: checklist
1. `bin/terractl`: the `cmd_*` function, the `usage` text, and the dispatch at
the bottom. Commands that don't take an instance name first (like `list`,
`doctor`, `backup`, `fetch`) are dispatched in the first `case`, before
`valid_name "$1"`.
2. `lib/terractl-remote`: add it to `EXEC_COMMANDS` if it should work with
`--host` (and a handler in `run_on_remote` if it moves files to or from the
caller's computer, like `export`, `import` and `mods add`).
3. `completions/bash/terractl`: the command list (`commands=`), and its
arguments/options. Local-only commands go in the `else` branch's list.
Instance names on a remote come from `_terractl_remote_instances`.
4. `docs/guide.md`:
- the section describing it (with examples using the `calamity` instance);
- the **Permissions** table;
- the **Tab completion** list, if completion changed;
- the **Command reference**;
- **Troubleshooting** and the `doctor` description, if relevant;
- **Components** and **Directory layout** for new files or folders.
5. `README.md` features list, for user-visible features.
6. `CHANGELOG.md`: an entry in the newest section (see [Changelog](#changelog)).
7. New installed files: `Makefile` `install` **and** `uninstall`, unit files
through `$(SUBST)` if they contain placeholders, `packaging/deb/postrm`
(purge) for anything created at runtime, and `conffiles` for files in
`/etc` that users edit.
8. If it affects what can go wrong on a host, consider a check in `doctor`
(`doc_problem warn|FAIL "<what>" "<fix command>" [<function that fixes it>]`;
only pass a fix function for fixes that are safe to apply unattended).
## Testing
Agents usually can't run as root, use systemd, or install the package. The
pattern used so far:
- Make a scratch folder (outside the repo) with `inst/`, `dist/`, `backups/`
and point `TERRARIA_BASE`, `TERRARIA_DIST`, `TERRARIA_BACKUPS` at them, and
`TERRARIA_USER` at your own user.
- Fake root in a **copy** of the script:
`sed -e 's/\$EUID/${FAKE_EUID:-$EUID}/g' bin/terractl > scratch/terractl`
with `FAKE_EUID=0` exported, and a `runuser` stub on `PATH` that drops to the
"service user" with `exec env -u FAKE_EUID "$@"` after the `--`. (Replacing
`$EUID` with a literal `0` makes `prepare` re-run itself forever.)
- Stub `systemctl` on `PATH` that keeps unit state in files (`is-active`,
`is-enabled`, `is-failed`, `start`/`stop`/`enable`/`disable`, and `show -p`
for the properties used). Point `SYSTEMD_ETC` at a scratch folder in the copy.
- Build test zips with Python's `zipfile` (`zip` may not be installed): vanilla
needs `<ver>/Linux/TerrariaServer.bin.x86_64`, tModLoader needs
`start-tModLoaderServer.sh`.
- `fetch` can be tested against the real sites; downloads are 40-60 MB.
- Don't `pkill -f` a pattern that appears in your own command line.
- Always run `make check`, and `make deb` when packaging changed. For unit
files, run `systemd-analyze verify` on the **built** copies under
`build/deb-root` (the sources contain placeholders).
Say clearly what was tested with stubs and what wasn't (real root, real
systemd, real servers); the maintainer tests on a real host before releasing.
## Documentation style
- Plain, direct English; short sentences; British spelling where it differs.
- Explain what happens and why, then show the command. Examples use the
instance `calamity` and the remote `nas`.
- Bold lead-in phrases for key points (`**Downgrades need --downgrade.**`).
- Command reference and usage text are aligned two-column blocks; keep the
columns aligned when adding lines.
- Documented behaviour must match the code; update both in the same change.
## Changelog
`CHANGELOG.md` is for users, and each version's section is published as-is as
its release notes on the Gitea release page (`gitea-release.py`). Write it so it
reads well there.
- **Headings:** `## <version> - unreleased` while in progress,
`## <version> - <YYYY-MM-DD>` once released. `gitea-release.py` takes the
first word after `## ` as the version, so keep exactly that shape.
- **Where entries go:** in the newest section if it's unreleased. If the newest
section is already released (dated), start `## <next version> - unreleased`
at the top **and** bump `VERSION` to match, in the same commit.
- **Released sections are history:** don't edit them, except to fix a typo.
- **Unreleased changes are edited, not appended to.** If something in the open
section changes before release (renamed, moved, fixed), update its existing
entry so the section describes the final state, rather than adding a
"fixed X" line about something users never had.
- **What an entry says:** the command and options as the user types them
(`` `sudo terractl backup enable <name> [--every <when>]` ``), what it does
and what they'll see, defaults, and anything they must do themselves
(restart servers, run a command after upgrading, a new dependency). Full
sentences; say "now" for changed behaviour and "before, ..." when the old
behaviour could have caught people out.
- **Order:** new features first, then changed behaviour, then fixes; the most
important first within each. A large feature gets one bullet with
sub-bullets for its parts (see `upgrade` and `fetch` in 0.0.12).
- **Leave out** refactors, test-only changes, code style and doc rewording,
unless the docs gained something users should know about (e.g. a new guide
section on a setup that didn't work before).
- Every user-visible change gets an entry in the same commit as the change.
## Releases
- `VERSION` holds the version (`0.0.N`) and must match the newest changelog
section (see [Changelog](#changelog)).
- To release: set the date, commit, then tag **`v<version>`** (annotated,
message `terraria-server-manager <version>`). The workflow refuses a tag that
doesn't match `VERSION`. Pushing the tag builds and publishes the release and
the apt repository, so only push when asked.
## Git
- Commit subjects are short, lower-case and imperative-ish
(`add upgrade command; reinstall keeps the same version`), with a body
listing the changes for anything non-trivial.
- Everything is LF (`.gitattributes`); `build/`, `*.deb` and `__pycache__/` are
ignored.
- Commit, tag or push only when asked.