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

13 KiB

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

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.

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).
  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).
  • 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.