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>
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
terrariauser, sandboxed byterraria@.service(ProtectSystem=strict,ProtectHome=yes, writable onlyReadWritePaths=/var/lib/terraria/instances,UMask=0027). - Nothing in an instance may end up owned by root. Commands run as root
chownwhat they create (fix_owner,chown -Rafternew/import).prepareandrunstarted as root switch toterrariawithrunuser(re-running$SELF, which is resolved before the script'scd /). - Permission model: root can do everything; members of the
terrariagroup can read instances, start/stop/restart servers (polkit rule), use the console (attach,cmd,watch), read the servers' own journal (log namespaceterraria), add zips todist/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.confis sourced by bash. Anything written into it (e.g.ZIP=) must be validated first (safe_zip_name). Instance names are validated byvalid_nameand 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 replaceserver/or delete things under a running server must checkinstance_running. server/is replaceable,data/is precious. Unpacking happens in a temp folder and only replacesserver/once complete;server/.installedrecords<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.conffrombackup enable,<unit>.d/terractl-locations.conffromdoctor --fix).postrmpurge removes them. Never overwrite admin files or unit files. - The daemon (
terractld) runsterractlas root withTERRACTL_VIAset. Only commands listed inEXEC_COMMANDSinlib/terractl-remotecan be run remotely. It runs withProtectSystem=fullandProtectHome=true, plus write access to/etc/systemd/systemonly. - Locations are configurable (
TERRARIA_BASE,TERRARIA_DIST,TERRARIA_BACKUPSin/etc/default/terractl), but unit files can't follow them;location_warningsanddoctordetect mismatches. Never hard-code/var/lib/terraria/...inbin/terractl: use$BASE_DIR,$DIST_DIR,$BACKUP_DIR.
Code conventions
bash (bin/terractl)
set -euo pipefail. Be careful with pipelines that can legitimately fail (grepwith no match,grep -qclosing a pipe early): capture to a variable first, or add|| true.- Errors:
die "message"(exits 1); notes:log "message"(stderr). Both prefixterractl:. Normal output goes to stdout. - Messages say what to do next. When suggesting a command, use
hint root|group <command> [args], which addssudoonly when needed and showsterractl --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/caseloop,die "unknown option '$1'"for anything unexpected, and ausage: terractl ...message for wrong arguments. - Root checks:
[[ $EUID -eq 0 ]] || die "'<command>' needs root: sudo terractl ...". localfor all function variables. Globals are upper-case.EXTRACT_TMPis the single temp-folder slot thatcleanup(the EXIT trap) removes. Code that must survive adiein a helper runs it in a subshell with its owntrap cleanup EXIT(seemake_backup).
Python (lib/terractl-remote)
- Standard library only, Python 3.9+.
make checkruns 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,|| trueon best-effort steps).
Adding or changing a command: checklist
bin/terractl: thecmd_*function, theusagetext, and the dispatch at the bottom. Commands that don't take an instance name first (likelist,doctor,backup,fetch) are dispatched in the firstcase, beforevalid_name "$1".lib/terractl-remote: add it toEXEC_COMMANDSif it should work with--host(and a handler inrun_on_remoteif it moves files to or from the caller's computer, likeexport,importandmods add).completions/bash/terractl: the command list (commands=), and its arguments/options. Local-only commands go in theelsebranch's list. Instance names on a remote come from_terractl_remote_instances.docs/guide.md:- the section describing it (with examples using the
calamityinstance); - the Permissions table;
- the Tab completion list, if completion changed;
- the Command reference;
- Troubleshooting and the
doctordescription, if relevant; - Components and Directory layout for new files or folders.
- the section describing it (with examples using the
README.mdfeatures list, for user-visible features.CHANGELOG.md: an entry in the newest section (see Changelog).- New installed files:
Makefileinstallanduninstall, unit files through$(SUBST)if they contain placeholders,packaging/deb/postrm(purge) for anything created at runtime, andconffilesfor files in/etcthat users edit. - 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 pointTERRARIA_BASE,TERRARIA_DIST,TERRARIA_BACKUPSat them, andTERRARIA_USERat your own user. - Fake root in a copy of the script:
sed -e 's/\$EUID/${FAKE_EUID:-$EUID}/g' bin/terractl > scratch/terractlwithFAKE_EUID=0exported, and arunuserstub onPATHthat drops to the "service user" withexec env -u FAKE_EUID "$@"after the--. (Replacing$EUIDwith a literal0makespreparere-run itself forever.) - Stub
systemctlonPATHthat keeps unit state in files (is-active,is-enabled,is-failed,start/stop/enable/disable, andshow -pfor the properties used). PointSYSTEMD_ETCat a scratch folder in the copy. - Build test zips with Python's
zipfile(zipmay not be installed): vanilla needs<ver>/Linux/TerrariaServer.bin.x86_64, tModLoader needsstart-tModLoaderServer.sh. fetchcan be tested against the real sites; downloads are 40-60 MB.- Don't
pkill -fa pattern that appears in your own command line. - Always run
make check, andmake debwhen packaging changed. For unit files, runsystemd-analyze verifyon the built copies underbuild/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
calamityand the remotenas. - 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> - unreleasedwhile in progress,## <version> - <YYYY-MM-DD>once released.gitea-release.pytakes 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> - unreleasedat the top and bumpVERSIONto 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
upgradeandfetchin 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
VERSIONholds 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, messageterraria-server-manager <version>). The workflow refuses a tag that doesn't matchVERSION. 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/,*.deband__pycache__/are ignored. - Commit, tag or push only when asked.