orrery: latest hub/mod/web tags and unreleased commits in the status line; warns before a Mod release while the hub has unreleased commits

A hub for everything you run or ship, game servers first. Today it connects GT: New Horizons servers to Discord: two-way chat, console commands, status, and start/stop/crash alerts.
[GTNH server] ─ orrery mod ─┐
[GTNH server] ─ orrery mod ─┼─ TCP 127.0.0.1:25580 ─> orrery hub (Node) ─> Discord
┘ └─ SQLite uptime log
| Directory | What | Toolchain |
|---|---|---|
mod/ | Server-side Forge 1.7.10 mod. Relays game events, runs commands. | JDK 25, Gradle wrapper |
hub/ | The bot. Owns server state and uptime, talks to Discord. | Node 24+ |
deploy/ | systemd units for running both on one server. | systemd |
docs/ | Wire protocol, roadmap, decisions (adr/), and archived v1 specs. | — |
bot and applications.commands; permissions View Channels, Send Messages, Read Message History, Attach Files, Manage Webhooks (player-skin chat) and Manage Channels (live status in the channel topic). Open the URL and add the bot to your Discord server. Without the last two, chat posts as the bot and topics stay unchanged; everything else works. Already added the bot? Give its role those permissions under Server Settings → Roles instead./cmd./cmd and /restart are hidden from everyone by default. Show them to the admin role under Server Settings → Integrations → your bot → each command. The admin-role check still applies either way.cd hub
npm ci
cp config.example.json config.json
openssl rand -hex 24 # one token per game server
$EDITOR config.json # servers[]: id, name; integrations.minecraft.tokens and
# integrations.discord (guildId, adminRoleId, channels), keyed by server id
echo 'DISCORD_TOKEN=<bot token>' > .env # only needed with integrations.discord
echo 'DISCORD_CLIENT_SECRET=<OAuth secret>' >> .env # only needed with integrations.web
set -a; . ./.env; set +a; npm start
config.json and .env are git-ignored. Each section under integrations is optional: leave out discord to run without the bot (and without a token), or minecraft to open no mod port. web serves the dashboard's API on 127.0.0.1 (Discord login for admins, no bot needed; it uses its own guildId and adminRoleId, else those under discord, and the OAuth app's redirect set to <publicUrl>/api/callback). A server needs a token when minecraft is on, but a Discord channel is optional: one left out of channels just isn't bridged to Discord. checks lists URLs the hub requests on a schedule (id, url, intervalSeconds ≥ 30); up means HTTP 2xx within 10 s, and going down or back up is a notice in the dashboard and, if discord.alertsChannel is set, in that channel. host (id; optional mounts, default ["/"], memoryMaxPercent 90, memoryMinutes 5, diskMinFreeGB 10) samples this machine's CPU, load, memory and disks every minute (kept 90 days), warning on memory that stays high and on a low mount. systemd lists the units the hub may see (id, unit); no other unit exists to it. It watches their state (a failure is a notice) and reads their logs, so the hub's user needs journal access (e.g. the systemd-journal group); a check may name one as its service, and a server the service it runs as. The dashboard can start, stop and restart them once deploy/orrery-hub.rules is installed with the same units (keep the two in sync); stopping a server's service with players online counts down in game first, then stops it for good. npm run check-config checks the file offline. A config from before orrery 2.0 (with listenPort, guildId and each server's token/channelId at the old places) is rejected, and check-config lists each key and where it moves. To run it permanently, see Running on a server.
cd mod
./gradlew build
Copy build/libs/orrery-<version>.jar (not -dev or -sources) into the server's mods/ folder. Clients don't need it. Start the server once to create config/orrery.cfg, then set the server id and its token from the hub's config.json (integrations.minecraft.tokens):
general {
S:hubHost=127.0.0.1
I:hubPort=25580
S:serverId=gtnh
S:token=<integrations.minecraft.tokens.gtnh from hub config.json>
}
Restart the server. The hub logs the connection, and the channel gets "✅ Server started".
deploy/ has systemd units for running the hub and the GTNH server together on one Linux machine. Edit User=/SocketUser= and the paths in the files first.
sudo install -m 600 -o root -g root hub/.env /etc/orrery.env # bot token, root-only
sudo cp deploy/orrery-hub.service deploy/gtnh.service deploy/gtnh.socket /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now orrery-hub gtnh
These work with SELinux enforcing (the default on RHEL/Oracle Linux): the token lives in /etc rather than /home, and the server runs without tmux, which SELinux doesn't let services start. If gtnh.socket fails with "Permission denied" (SELinux blocking systemd from its own console FIFO), install the small policy module in deploy/gtnh-fifo.te, which allows exactly that and nothing else:
checkmodule -M -m -o /tmp/gtnh-fifo.mod deploy/gtnh-fifo.te
semodule_package -o /tmp/gtnh-fifo.pp -m /tmp/gtnh-fifo.mod
sudo semodule -i /tmp/gtnh-fifo.pp
journalctl -u orrery-hub -fjournalctl -u gtnh -f (add yourself to the systemd-journal group to read it without sudo)echo "say hello" > /run/gtnh.stdin, or /cmd from Discordsudo systemctl stop gtnh stops it for maintenance. An in-game /stop, or /cmd stop from Discord, restarts it. So does a crash.Ports. Only Minecraft needs to be reachable from outside:
127.0.0.1:25580. Don't open 25580: that connection is only protected by its token.0.0.0.0/0, TCP 25565.sudo firewall-cmd --permanent --add-port=25565/tcp && sudo firewall-cmd --reload[Discord] <name> message. In-game chat posts with each player's name and skin head; joins, leaves, deaths and achievements post as the bot./status shows online state, TPS, player count and 24 h / 7 d uptime. Alerts, status, stats and backups post as embeds; chat stays plain text./list shows online players./playtime <player> (or user:@someone who has linked) shows total playtime, the last 7 days and when they were last seen; /top [day|week|all] ranks players by playtime./tps shows TPS now, the last hour and day, a trend line and the slowest dimensions. The channel gets 🐢 Lag when TPS stays below 15 for 2 minutes (per server: "lagTps", "lagMinutes", "lagAlerts": false) and ✅ TPS back to normal after."quests" per server: batched (default), main, all or off./link gives you a code; type /discord link <code> in game within 10 minutes. Your Discord messages then show in game under your Minecraft name. /unlink (Discord) or /discord unlink (game) removes it./cmd output that arrives later (e.g. spark profiler results) is posted as a follow-up, for up to 15 minutes./cmd <command> runs a console command and shows its output. Admin role only. Every command is logged by the hub. The reply takes about 1.5 s: it waits for mods such as spark that answer a moment later./restart in <minutes> restarts with in-game warnings at 10 / 5 / 1 min, 30 s and 10 s; /restart cancel calls it off. Admin role only. Add "dailyRestart": "06:00" to a server in config.json for a daily restart at that local time (the countdown starts 10 minutes before)."dir" set to the server's folder in config.json, a crash alert comes with the crash report and JVM error log (hs_err_pid*.log) attached.✅ Backup finished or ❌ Backup failed. /backup start starts one now; /backup status shows the newest backup, the count and the total size; /backup list shows the 10 newest. Admin role only. Backups are read from "backupDir", or <dir>/backups if that isn't set. With "backupMaxAgeHours": 26, the bot warns once if no new backup appears for that long."dailySummary": "09:00" posts yesterday's stats every day at that time: uptime, peak and unique players, total playtime, top players, starts and crashes.Adding another game server: add an entry to servers in config.json, a token for its id under integrations.minecraft.tokens and a channel under integrations.discord.channels, restart the hub, and install the mod with that id and token.
cd hub && npm test && npm run typecheck
cd mod && ./gradlew spotlessApply build # build runs the JUnit tests
The wire protocol is in docs/protocol.md, the project's vocabulary in CONTEXT.md, and decisions in docs/adr/. Specs and tickets are GitHub issues. What's planned next is in docs/ROADMAP.md.
v2 makes orrery a hub for everything that runs or ships, not only GTNH. Discord, Minecraft servers, the host, systemd services and HTTP checks become integrations you switch on in config, and a browser dashboard (Angular) joins Discord as a way in. See docs/adr/0002-hub-with-integrations.md and docs/ROADMAP.md.
hooks/register.ts 65 lines1import type { Register } from 'claude-code'
2
3type Run = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
4type Part = { part: string; tag?: string; ahead: number }
5
6const PARTS = ['hub', 'mod', 'web'] as const
7const MOD_RELEASE = /release\.sh\s+mod\b/
8
9// Each part's latest tag and the commits under <part>/ since it (what release.sh's notes would list).
10export const parts = async (run: Run): Promise<Part[] | undefined> => {
11 const root = await run(['git', 'rev-parse', '--show-toplevel'])
12 if (root.exitCode !== 0) return undefined
13 const git = (...args: string[]) => run(['git', '-C', root.stdout.trim(), ...args])
14 const out = await Promise.all(PARTS.map(async part => {
15 const tags = await git('tag', '-l', `${part}-v*`, '--sort=-v:refname')
16 const tag = tags.stdout.split('\n').find(t => /^\w+-v\d+\.\d+\.\d+$/.test(t))
17 const count = await git('rev-list', '--count', tag ? `${tag}..HEAD` : 'HEAD', '--', `${part}/`)
18 return { part, tag, ahead: Number(count.stdout.trim()) || 0 }
19 }))
20 return out.some(p => p.tag) ? out : undefined
21}
22
23export const statusText = (ps: Part[]) =>
24 ps.map(p => `${p.part} ${p.tag?.slice(p.part.length + 1) ?? '—'}${p.ahead ? ` +${p.ahead}` : ''}`).join(' · ')
25
26export const register: Register = on => {
27 // ponytail: per-load memory; a reload re-arms the warning, which errs on the safe side.
28 const warned = new Set<string>()
29
30 on('session.start', async ($, e, next) => {
31 const run: Run = argv => $.process.run(argv)
32 const refresh = async () => {
33 const ps = await parts(run).catch(() => undefined)
34 $.ui.status(ps && statusText(ps))
35 }
36 void refresh()
37 $.clock.every(60_000, () => void refresh())
38 return next(e)
39 })
40
41 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
42 const run: Run = argv => $.process.run(argv)
43 if (MOD_RELEASE.test(e.command)) {
44 const ps = await parts(run).catch(() => undefined)
45 const hub = ps?.find(p => p.part === 'hub')
46 const head = (await run(['git', 'rev-parse', 'HEAD'])).stdout.trim()
47 if (hub && hub.ahead > 0 && !warned.has(head)) {
48 warned.add(head)
49 return {
50 deny: `${$.plugin.name}: hub has ${hub.ahead} commit(s) since ${hub.tag ?? 'the start'} that aren't released. ` +
51 'If this Mod release relies on new hub behaviour (deploy logic, protocol), release and deploy the hub first ' +
52 '(.github/release.sh hub …, then deploy it from the Host page). Tell the user; if they confirm the Mod ' +
53 "doesn't need it, run the same command again and it will go through.",
54 }
55 }
56 }
57 const ran = await next(e)
58 if (/\bgit\b|release\.sh/.test(e.command)) {
59 const ps = await parts(run).catch(() => undefined)
60 $.ui.status(ps && statusText(ps))
61 }
62 return ran
63 })
64}
65