# Runic Gateway Installer — plan Status: **Phase 1 built, on `edge`.** Phase 0's prerequisites all landed, the installer repo publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) specified the operator-facing run before the binary existed. The crate now exists and implements the installer core — bundle resolution, ServUO detection and validation, the overlay sync and `install.json` — on the `edge` branch, not `main`, so no half-capable binary is released (see [Phase 1 as built](#phase-1--installer-core)). **Phase 2 (uo-link install + service) is next.** This document is the design of record; it supersedes the informal overview it grew out of, which described a ServUO integration that does not match how `servuo-plugins` actually ships (see [Corrections](#corrections-to-the-original-overview)). | Phase 0 item | State | |---|---| | 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) | | 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) | | 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) | | 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run | | — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) | --- ## 1. Purpose Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while keeping the components separated and independently maintainable. The installer handles environment detection, ServUO overlay deployment, the optional stock-file patch tier, uo-link installation and service registration, version tracking, diagnostics, and updates from Gitea releases. **It is a deployment tool, not a hosted bootstrapper.** There is no `curl | bash`, no installer service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run. **It does not replace ServUO startup behavior.** ServUO keeps running through its existing release/start scripts. The installer never writes a launcher. ### Decisions locked | Question | Decision | |---|---| | Audience | **Public** — any ServUO operator, not just shards we run | | Code signing | **Unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling | | Language | **Rust** — single static binary per OS, reuses the cross-compile pattern already proven in `link/.gitea/workflows/release.yml` | | Plugin source | **Release tarball artifact** — no git and no Gitea credentials on the shard host | | Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle ` pins one. Component releases regenerate JSON, not the installer binary | | Token handoff | **Print token + prefilled admin URL** at the end of the run | | Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency | | ServUO version | **57.4 is the only supported version.** The patch tier's gate is content, not a version string: a patch applies where the lines it edits are still stock and is handed to the operator where they are not (§2.2.1). Forks and hand-edited trees are the norm in a public audience, so a non-57.4 tree is still *allowed* to attempt the tier — but **unsupported, untested and not guaranteed**, behind a loud banner, a defaulted-to-no prompt and its own opt-in flag (§2.2.2) | | Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call | --- ## 2. Corrections to the original overview These are not wording nits — each one changes what the installer has to do. ### 2.1 There is no `RunicGateway.dll` and no `Plugins/` directory The plugin ships as **C# source** and ServUO compiles it at boot. The real deployable is `servuo-plugins/overlay/`, which mirrors the server root: ``` overlay/ ├── Config/Bridge.cfg └── Scripts/ ├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file └── Custom/Bridge/*.cs # 22 files ``` So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the overview assumed. The sting is that **a successful copy does not mean a working bridge.** Per `link/SHARD_PREREQS.md`, `ScriptCompiler.Compile()` shells out to `dotnet build`, prints the output, **ignores the exit code**, and reloads the existing `Scripts.dll`. A broken script build is invisible: the shard boots clean on stale code. Diagnostics must therefore verify *post-boot* state, never treat "files copied" as success. ### 2.2 Stock ServUO files *are* modified — by an optional tier `servuo-plugins/patches/` holds unified diffs against stock ServUO 57.4, plus two `.cs` files that can only be copied *after* their patch lands (they reference symbols the patch introduces): | Patch | Target | Companion file | Rebuild required | |---|---|---|---| | `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | `BridgeVendorSale.cs` | **Core** — `dotnet build ServUO.sln`; the dynamic script build is not enough | | `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | (same unit as above) | script build | | `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | `BridgeModerationAudit.cs` | script build | Plus `overlay/Scripts/Scripts.csproj`, which overwrites a stock file (Phase 0 — it fixes the silent ServUO build bug above). This is the hardest part of the installer. `git apply` against a hand-modified shard will fail, and most real shards are hand-modified. Therefore: - The patch tier is **opt-in and skippable**. The base install must complete without it. - Always dry-run (`git apply --check`) before applying, and report per-patch. - When skipped or failed, say plainly what is lost: **no `vendor.sale` events, no in-game moderation audit forwarding**. - The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a shard restart. - Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it (`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent, and uninstall can print the exact hunks offline long after the release tarball is gone (§5, Phase 4). #### 2.2.1 A whole-file hash mismatch is not a verdict — check the region A file-level hash compare answers "is this entire file stock?", which is the wrong question. The patches touch three small regions of three large files; an operator who added a custom command to `Logging.cs` or a hook to `EventSink.cs` has changed the file's hash without going anywhere near the lines the patch edits. Refusing on the file hash alone hands most real shards a manual patch job they did not need. So the decision is made in three rungs, cheapest and safest first, and only the last one gives up: | Rung | Test | Outcome | |---|---|---| | **0 — already applied** | The hunk's *post*-patch text appears in the file | No-op, recorded as applied. Keeps re-runs idempotent | | **1 — file is stock** | Whole-file hash matches the patch's pre-image (`index ..` in the diff — `git hash-object` on the target reproduces it) | Apply verbatim with `git apply` | | **2 — region is stock** | File differs, but every hunk's stock-side region is still byte-identical | Apply hunk-by-hunk at the matched offsets | | **3 — region is modified** | Anything else | **Do not touch the file.** Print the path, the hunks and what is lost; the operator patches by hand | Rung 2 is the semantic review, and it needs no new metadata: a unified diff already carries the stock text of the region it edits — the context lines plus the `-` lines *are* the pre-image. For each hunk the installer reconstructs that block and searches the target file for it, under these rules: - **Exact match, not fuzzy.** Only line-ending (CRLF/LF) and trailing-whitespace normalization is allowed. No `patch --fuzz`, no context reduction: dropping context to force a match is precisely how a patch lands in the wrong method. - **Exactly one occurrence, or it fails.** Zero means the region moved or was edited. More than one means the anchor is ambiguous and the installer cannot know which the author meant. Both are rung 3. - **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest candidate when reporting; the match itself is by content, since insertions above the region shift every number below it. - **All-or-nothing per patch file.** If one hunk of a patch reaches rung 3, none of that patch's hunks are applied. A half-patched `EventSink.cs` compiles against a companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to unpick than an untouched file. - **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already present and others are not is a hand-merge in progress, not an idempotent re-run — that is rung 3. #### 2.2.2 Non-57.4 is allowed, unsupported, and must say so loudly The rung ladder replaces the blanket ServUO-version gate. The old rule skipped the entire tier on anything other than stock 57.4 on the grounds that unverified diffs must not be applied to an unknown tree — but forks are the norm (§1), so that rule skipped the tier for most of the audience. Content matching gives a stronger guarantee than a version string does: on a non-57.4 tree, rung 1 is simply unavailable (its pre-image hash cannot be trusted), the tier goes straight to rung 2, and a hunk lands only where the surrounding lines are still character-for-character the ones the patch was written against. **That is a mechanical safety guarantee about where text lands. It is not a support commitment, and the installer must never let the two be confused.** Runic Gateway is designed, built and tested against **stock ServUO 57.4**. On anything else the patch tier is **unsupported, untested, and not guaranteed to work** — a hunk can match textually and still be wrong for a tree whose surrounding behaviour has diverged, and neither the shard's silent script build (§2.1) nor the installer will tell you that. So: - **The disclaimer is unmissable, not a footnote.** On a non-57.4 tree the tier prints a banner before it is even offered — that 57.4 is the only supported version, that the operator is on their own here, and that a bad outcome may not surface until the shard is running. - **It is off by default and takes an explicit, separate yes.** The interactive prompt defaults to **no** on a non-57.4 tree, and `--patches` alone is **not** consent: an unattended run must pass `--patches-unsupported-servuo` as well. A flag an operator had to look up cannot be hit by accident in a script copied from somewhere else. - **The label follows the install.** `install.json` records the detected version and the fact that the tier ran unsupported; `doctor` shows that row on every subsequent run, not just at install time; and the uninstall report carries it too. An operator who inherits this shard six months later must be able to see it without being told. - **It is the first thing quoted back in a bug report.** The tier's summary line names the detected version, so a pasted install log answers "which ServUO?" before anyone asks. The version is detected and reported everywhere; it just no longer *silently* decides. A refusal becomes an informed choice, which is the point — but it stays visibly the operator's choice. **What the installer records.** `install.json` stores, per patch, which rung applied it (`stock-hash`, `region-match`, `already-present`) and the hunk offsets it matched. `doctor` and `uninstall` report that: a `region-match` apply on a modified file is a different support story from a clean apply to a stock tree, and the operator should be able to see which one they have without re-deriving it. ### 2.3 Config paths collide with what the sidecar actually reads The sidecar reads `$UOLINK_CONFIG`, else `sidecar.toml` in the **working directory** (`link/sidecar/src/config.rs`), with keys `[shard].bind`, `[web].bind`, `[web].auth_token`, `[store].path`. The overview proposed a `config.toml` with `[updates]`, `[link]`, `[servuo]` — keys the sidecar cannot read. Two files, two owners: | File | Owner | Contents | |---|---|---| | `/etc/runicgateway/sidecar.toml` | uo-link | The sidecar's own schema, unchanged. Service sets `UOLINK_CONFIG` to this path | | `/etc/runicgateway/install.json` | installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps | **Working-directory trap:** the sidecar wrote both `sidecar.toml` and `uo-link.db` relative to CWD. Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.2 fixed the second half in the sidecar — a relative `[store].path` now resolves against the directory holding `sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic — but the config path itself is still CWD-relative by default, and "deterministic" is not the same as "where this install wants it". The service definitions therefore still pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly: - Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated service user - Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`** ### 2.4 The token handoff was missing entirely The whole point is the website reaching the sidecar, and today that is manual and undocumented in the install flow: the sidecar generates a token on first run and logs it, then a human pastes base URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and becomes write-only. This is the largest "I installed it and nothing happened" failure mode. The installer closes it by printing a copy-paste block at the end of a successful run — see §6. Phase 0.2 supplied the missing half of that: `uo-link-sidecar --print-config` provisions the config if absent and prints the resolved settings — token, both binds, `ws_path`, protocol version, db path — as JSON. The installer reads the block it prints out of that one call. **It never parses the log**, which was the alternative and would have made the handoff depend on a log format that is not a contract. ### 2.5 `deploy.ps1` cannot be the cross-platform deployer It is PowerShell-only; a Linux ServUO host running .NET typically has no `pwsh`. It also hard-throws when the ServUO process is running — correct behavior, and the installer must inherit it (detect and refuse, rather than corrupt a live `Scripts.dll`). The installer reimplements the sync natively; it is a short hash-compare-and-copy that never deletes. `deploy.ps1` **stays** in `servuo-plugins` as the developer-facing tool. The installer is for operators. ### 2.6 Prerequisites the overview assumed away - **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced by a release tarball, which had to be built first — Phase 0 item 1, now in review ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)). - **arm64 is not buildable today.** `link/release.yml` cross-compiles only `x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`. An arm64 `.deb` needs another cross toolchain. - **The compat matrix has no home.** `PROTOCOL_VERSION` lives in `link/sidecar/src/main.rs`. The sidecar publishes it via `X-UOLink-Version` and `/health`, and the website stores an expected value — but the *plugin's* protocol version is not queryable before boot. Phase 0 item 1 gives it a home: `servuo-plugins/overlay.toml`, declared into the overlay manifest. See §7.0 / §7.1. --- ## 3. Distribution model Components are published as Gitea release artifacts. Operators download from the release page (browser, `curl`/`wget`, or `scp` to the server) and run the binary. ``` Runic Gateway Installer v1.0.0 ├── runicgateway-installer-windows-x86_64.exe ├── runicgateway-installer-linux-x86_64 └── SHA256SUMS uo-link v1.1.0 (existing release, extended) ├── uo-link-sidecar-windows-x86_64.exe ├── uo-link-sidecar-linux-x86_64 ├── runicgateway-link__amd64.deb (Phase 5) └── SHA256SUMS servuo-plugins v (new release, Phase 0) ├── runicgateway-overlay-.tar.gz # overlay/ + patches/ + manifest.json └── SHA256SUMS ``` Binding those together is the **bundle manifest** (§7.1) — published by the installer repo's CI, not by any component, and the thing the installer actually resolves against. ```bash scp runicgateway-installer-linux-x86_64 user@server:/tmp/ chmod +x runicgateway-installer-linux-x86_64 sudo ./runicgateway-installer-linux-x86_64 ``` ### Unsigned-binary posture Because releases are unsigned, trust is anchored on checksums and the operator's own verification. The docs must state this up front rather than let users discover it as a scary dialog: - Every release publishes `SHA256SUMS`; the install docs lead with the verification command for both OSes. - Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path. - The installer verifies the SHA256 of everything **it** downloads (overlay tarball, sidecar binary) against the release's `SHA256SUMS` and refuses on mismatch. Self-verification is not optional just because the installer itself is unsigned. - Revisit signing if it ever becomes affordable; the release layout should not have to change. --- ## 4. Component architecture ``` Runic Gateway Installer (Rust, one binary per OS) │ ┌───────────────┴────────────────┐ ▼ ▼ ServUO integration uo-link │ │ ┌────────┴────────┐ ┌────────┴────────┐ ▼ ▼ ▼ ▼ overlay sync patch tier (opt-in) binary install service registration (never deletes) (per-region rungs) + config + data (systemd / Windows SCM) ``` Each component keeps its own lifecycle. ServUO's existing startup process is untouched. --- ## 5. Phases ### Phase 0 — prerequisites (no installer code) Repo work that must land before an installer can exist. 1. **`servuo-plugins`: add `.gitea/workflows/release.yml`.** Retarget the release *engine* half of `link/release.yml` (its header comment explicitly anticipates this — the plan/release steps consume only `{version, changelog, artifacts}`). The adapter half produces `runicgateway-overlay-.tar.gz` containing `overlay/`, `patches/`, and a `manifest.json` (version, commit, per-file SHA256, declared protocol version, minimum ServUO version). As built ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)), with three deviations from `link`'s copy that each fell out of the repo rather than being chosen: - **No build gates, structural gates instead.** Nothing in that repo can be compiled without ServUO reference assemblies, so CI asserts what it honestly can: `Bridge.cfg` and the Bridge scripts present, `Scripts.csproj` present (its absence ships code that never compiles while ServUO reports success — §2.1), every `.patch` parseable via `git apply --stat`, and each patch's companion `.cs` present. - **No bump commit, so no push to `main`.** `link` writes the version into `Cargo.toml` because the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag *is* the version. That workflow needs no branch-protection exception. - **`overlay.toml` at the repo root** holds the declared `protocol` and the ServUO compatibility values, read by CI into the manifest. It exists because the number needs one maintained home — see §7 for why the plugin cannot simply be asked. The tarball uses a **fixed** top-level directory, `runicgateway-overlay/`, not a versioned one: the installer looks for `overlay/`, `patches/` and `manifest.json` at known paths rather than parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a given tree yields a byte-identical tarball and its checksum moves only when its contents do. 2. **`link`: make the sidecar installable.** Confirm/settle default data paths, and add a way to read back config non-interactively (e.g. `--print-config` emitting JSON: bind addresses, token, protocol version, db path) so the installer does not have to scrape logs for the token. As built ([link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24)) — the sidecar had **no CLI at all** before this, so the shape was chosen rather than inherited: - **Four flags, hand-rolled:** `--print-config`, `--config `, `--version`, `--help`. No argument-parsing crate — it would be larger than the code it replaced — and deliberately no flags that duplicate a config key, so `sidecar.toml` stays the single place settings live. An unrecognized argument exits `2`; silently ignoring a typo'd flag would start a sidecar that is not the one the installer asked for. - **`--print-config` performs first-run setup rather than only reporting.** It runs the same load path a normal start does, so a missing config file is written and a blank token is generated and saved. That collapses "provision the sidecar" and "find out its token" into one non-interactive call — which is exactly the sequence §6 needs. `config_created` and `token_generated` say whether *this* run did either, because the values alone cannot distinguish a fresh install from a re-read of an existing one, and a re-run must not report a token as newly minted. - **The document is the whole of stdout.** The log subscriber writes to stdout, so it is not started in this mode. `ws_path` is emitted from the same constant the route is registered with, so the installer's WebSocket URL cannot drift from the server's. - **Relative `[store].path` now anchors to the config file's directory, not the CWD** — see §2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent directories are created; `:memory:` and `file:` URIs are left alone. - **The db path is handed to sqlx as a path, not a `sqlite://` URL.** The URL spelling is parsed as one: it percent-decodes the path and splits it on `?`, so an installed path containing `%20` opened a different file than the operator named. - **No platform data directories are compiled in.** That is the "settle" half of this item, and the answer is that the *installer* owns layout (§2.3) and pins `UOLINK_CONFIG` / `UOLINK_DB_PATH` in the service definition. Baking `/etc` and `%ProgramData%` defaults into the binary would give the same paths two owners and break `cargo run` in a working tree. 3. **Bundle CI in the installer repo** (§7). Compose job (read both repos' latest releases → run the two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each component's release workflow. This must exist before Phase 1 is useful, since the installer resolves what to install *from* the bundle. As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), [link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) — `installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open: - **Bundles are committed to the installer repo, not published as releases** — see §7.1 for where and why. That was the one genuinely open question here, and the deciding factor is that this repo's *own* releases are the installer binaries. - **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary. `--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0` onward, and `--bundle ` has to be able to recompose a bundle from an older pair. Reading `sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution of a downloaded artifact, and does not provision a throwaway config whose auth token would then be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating "could not read" as "matches" is exactly how a mismatched pair would ship. - **Gate 2 records the hash CI computed itself**, after verifying the download against the publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no* `SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not mention, which would put an unverified artifact in the bundle. - **Release metadata is read anonymously**, on purpose: those are exactly the requests the shipped installer makes on a host with no Gitea credentials, so a repo flipped to private fails CI here instead of on an operator's machine. - **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job rather than silently omitting the new binary from every bundle. - **A run that changes nothing writes nothing** — the comparison excludes `bundle` and `generated`, which are metadata about the run. Without that the nightly cron would commit a dated duplicate of the same matrix every morning. The workflow's compose steps were run against the live releases before merge, producing the first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so the manifest exists ahead of the binary that reads it. 4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the shape is settled. As built ([`INSTALL.md`](INSTALL.md)) — written *before* the binary on purpose. Everything it installs is already released (items 1–3), so the guide is not speculation about a tool that might exist; it is the specification of what the run asks, where it writes, what it prints, and what the operator does next. Phase 1–4 implement it. - **It is useful before the installer exists.** Appendix A is the same deployment done by hand — bundle fetch, tarball verify + overlay copy, the optional patch tier, `--print-config` provisioning, and a systemd unit / `sc create` service — composed from the released artifacts' actual contents and the sidecar's config and CLI source rather than from memory. That appendix doubles as **Phase 1's acceptance test**: walking it end to end on a real shard is what proves the automated path has nothing left to discover. - **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on `PATH`; nothing places one there, and adding self-installation would give the tool a second lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it in later examples. - **The flag surface got fixed here**, because a guide cannot describe a run in the abstract: `--verify`, `--bundle`, `--purge` were already named by §5/§7; `--servuo`, `--patches` / `--no-patches`, `--host`, `--site-url` and `--yes` are the remainder, chosen so every prompt in §6's handoff has a non-interactive equivalent and an unattended install is expressible. - **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync rule inherited from `deploy.ps1`. - **Remote-website deployments needed an answer.** `[web] bind` defaults to `127.0.0.1`, which only works when the site runs on the shard host. The guide says to widen it, firewall it to the website's address, and front it with TLS or a VPN off a trusted network — because the token is always required but travels as a plain bearer token over HTTP. `[shard] bind` stays on loopback, since that socket carries inbound commands *into* the game. ### Phase 1 — installer core - ServUO root detection and validation (`ServUO.exe`, `Scripts/`, `Config/`), with version detection and an explicit refusal when the ServUO process is running. - Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change, **never delete**. Port of `deploy.ps1` semantics including its `-Verify` dry run (`--verify`). - **One deviation from `deploy.ps1`: an operator-modified `Config/Bridge.cfg` is reported, not overwritten.** `deploy.ps1` overwrites every file whose hash differs, which is right for a developer redeploying their own tree and wrong for an operator who has set `LinkUrl`, `PublicConnectAddress` and sweep intervals — an `update` would silently revert the shard's entire configuration. `install.json` records the hash deployed, so the installer can distinguish "the operator edited this" from "the overlay moved on" (§7.0) and act only on the second. The rule is specific to `Bridge.cfg`: it is the only file in the overlay that is *meant* to be edited in place, and it carries no code, so a stale copy cannot break the build. Every `.cs` file and `Scripts.csproj` still overwrite unconditionally. - Write `install.json`: component, version, source commit, per-file hashes, applied patches, timestamp. - Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing. **As built** ([installer#4](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/4)) — the crate at the repo root, `install` implemented end to end, `doctor`/`update`/`uninstall` parsed and answered with the phase they arrive in rather than "unrecognized command". The decisions that were not already settled above: - **It lands on `edge`, not `main`.** `release.yml` publishes an installer binary on every push to `main`, and its crate guard was written to arm "the moment Phase 1 lands the crate" — which would have published a binary that deploys the overlay but cannot install the sidecar, contradicting everything `INSTALL.md` promises a release does. Phases 1 and 2 land on `edge`; the `edge → main` cutover cuts the first release. `pr-checks.yml` gates PRs into `edge` on the same rules, so the branch where the work happens is not the ungated one. No workflow needed a temporary edit. - **The run says what it did *not* do.** A Phase 1 `install` ends with an unmissable block naming the sidecar as not installed, pointing at `INSTALL.md` A3/A4, and printing the bundle's binary URL and SHA256 so a hand install matches the pair. `--patches` is the sharp edge here: it is accepted (so the flag surface is the published one) but reports `REQUESTED BUT NOT APPLIED — no stock ServUO file has been touched`. A `--patches` run that completed quietly would be read as a patched shard. - **The crate is a library plus a thin binary, and the library is not named after it.** Windows applies UAC *installer detection* to unsigned executables whose file name contains `install`: it demands elevation before the process starts, and a non-interactive session gets `os error 740` instead of a program. That is survivable for the shipped binary — it needs Administrator anyway, and `INSTALL.md` already says to run it from an elevated shell — but Cargo names test harnesses after their target, so a target called `runicgateway_installer` makes `cargo test` **unrunnable on Windows**, on the machine the shard smoke tests live on. The code therefore sits in a library called `rgdeploy`, the binary target keeps its published name, and `[[bin]] test = false` stops Cargo building a harness under it. Nothing an operator sees changes. - **Dependencies chosen for the MinGW cross-build:** `ureq` (blocking HTTP over rustls/ring — no OpenSSL to cross-compile, and no async runtime for a tool that makes four sequential requests), `flate2` on its pure-Rust backend, `tar`, `sha2`, `serde`/`serde_json`, `chrono`, `anyhow`, and `sysinfo` for the running-shard check. - **The shard-running check matches by path, not by process name.** `deploy.ps1` can look for a process called `ServUO` because it only runs on Windows; on Linux the same shard is `mono` or `dotnet` with `ServUO.exe` as an argument, and a name match would answer "not running" for a live shard — the one wrong answer that corrupts `Scripts.dll`. The installer requires a process whose executable or command line names *both* the tree being deployed into and `ServUO.exe`, so a second shard elsewhere on the host does not block this deploy, and the installer never matches itself. - **ServUO's version is read from `Server/AssemblyInfo.cs`**, not from `ServUO.exe`'s PE metadata: it is the same *source* tree the patch tier diffs against, needs no dependency, and works identically on Linux. `57.4.0.0` and `57.4` are normalized to compare equal. An unreadable version is reported as `unknown` and treated as **not** supported — an unreadable version is not evidence of a good one — which is what Phase 3 will gate the tier on. - **`install.json` records a state, not a verb.** Per-file entries are `deployed` or `kept-operator-modified`, never `add`/`change`/`unchanged`. Recording the run's verb made the record differ between a first run and an identical second one, which rewrote the file on every run and broke "a second run writes nothing" in the least visible way available. What later commands need is whose copy is in the tree, and that does not change because time passed. - **The `Bridge.cfg` decision compares against the last hash the installer *deployed*, not the last hash it *saw*.** Once a file has been kept, the record's on-disk hash is the operator's content — so a rule phrased as "is the tree still what the record last saw?" matches on the very next run and overwrites exactly the file it had just protected. A keep has to stay kept for as long as the edit is there; a live three-run test covers it, because the bug only appears from the second run on. - **A prior record is only consulted when it names this tree.** A host whose `install.json` points at a different ServUO root — a shard moved or rebuilt beside the old one — is treated as having no prior deployment, which errs toward keeping the operator's file. - **The download is verified twice, for two different reasons.** The tarball's SHA256 is checked against the bundle while it is being written (the trust anchor — these artifacts are unsigned); then every extracted file is re-hashed against the release's own `manifest.json`, which catches a truncated extraction and is what makes the hashes copied into `install.json` worth trusting. The manifest's `protocol` and `version` are also cross-checked against the bundle, so an artifact that disagrees with the matrix that named it stops the run before anything is written. - **`RUNICGATEWAY_STATE_DIR` relocates the installer's own state**, so a run can be tested without root. Documented in `--help` rather than hidden: an undocumented variable that moves where a tool writes is worse than a documented one, and `doctor` must honour the same value to find what `install` wrote. Verified on this machine against a real ServUO 57.4 tree (`--verify`, which reported the tree's `Bridge.cfg` as operator-owned and 23 code files as changed) and end to end into a scratch tree: 24 files deployed, a second run reporting `unchanged` and leaving `install.json` untouched, an edited `Bridge.cfg` kept across three further runs while a hand-edited `.cs` was overwritten each time, a pinned `--bundle`, a missing bundle tag, and a refusal — pid and path named, exit 1 — with a process running out of the tree. ### Phase 2 — uo-link install and service - Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db → `/var/lib/runicgateway/`, systemd unit with a dedicated user, `enable` + `start`. - Windows: `%ProgramFiles%\RunicGateway\`, data in `%ProgramData%\RunicGateway\`, service registration with automatic start and restart-on-failure. - Both: `UOLINK_CONFIG` and `UOLINK_DB_PATH` pinned in the service definition (§2.3). - Token surfacing (§6): run the installed binary once as `uo-link-sidecar --print-config --config ` **before** registering the service. That both writes the config the service will read and returns the token to print, so the service never starts against a config that does not exist yet. ### Phase 3 — patch tier (opt-in) Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and degrade loudly rather than silently. The rung ladder of §2.2.1 is the bulk of the work here: parse each `.patch` into hunks, reconstruct each hunk's pre- and post-image blocks, and resolve the file through rungs 0–3 before writing anything. The unsupported-version path (§2.2.2) is part of this phase, not a later polish — the banner, the defaulted-to-no prompt, the `--patches-unsupported-servuo` flag, and the unsupported marker carried into `install.json`, `doctor` and the uninstall report. Two pieces carry the risk and want direct tests — the hunk parser (headers, `\ No newline at end of file`, CRLF files) and the uniqueness rule (a region that appears twice must fail, not pick the first). Fixtures are cheap: the three stock 57.4 files, each with a hand edit far from the patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched copy (must reach rung 0). ### Phase 4 — diagnostics and updates `runicgateway doctor` — the command that makes the whole thing supportable: ``` ✓ ServUO found /opt/ServUO (57.4) ✓ Overlay in sync 24 files, all hashes match install.json ⚠ Patch tier 1 of 3 applied (region-match) — vendor.sale unavailable ✓ uo-link installed 1.1.0 ✓ Service running, enabled ✓ Sidecar reachable 127.0.0.1:8080 /health ok ✓ Protocol sidecar 3 = overlay manifest 3 ✗ Shard connected no shard has dialed in since boot ``` The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge actually works" (§2.1). Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem: `--version` prints `uo-link-sidecar (protocol )`, and `--print-config` gives the config and db paths the *installed service* resolves — so `doctor` reports what the binary would actually do, not what `install.json` believes it was told to do. The protocol row compares that number against the overlay manifest's declared one (§7.0). `runicgateway update` — resolves the current bundle (§7.1), then acts asymmetrically by component, deliberately: - **uo-link**: compare the bundle's version against what is installed → download → verify checksum → replace binary → restart service. - **plugin overlay**: download the bundle's overlay tarball → verify → re-sync (leaving a modified `Bridge.cfg` alone — Phase 1) → record commit → tell the operator ServUO must restart (the installer does not restart the shard). Because both come from one bundle, an update always moves to a combination whose protocol versions were checked together, rather than to two independently-latest artifacts that may disagree. `runicgateway uninstall` — **removes only what it exclusively owns, and never edits the ServUO tree.** The installer cannot know what the operator has changed in those files since deployment, so a clever automatic revert risks silently eating their work. It removes and it reports: | Action | Scope | |---|---| | Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set | | Kept | `sidecar.toml` and `uo-link.db` (config and history survive; `--purge` to drop them) | | **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete | | **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files — with the rung that applied each one (§2.2.1), since a `region-match` apply means the surrounding file was already the operator's — for them to revert by hand | The printed report is also written to a file, so it survives the terminal scrollback of a long uninstall. ### Phase 5 — packaging polish `.deb` packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade. Deliberately last: v1 can register services directly (`sc create` / a written systemd unit) and ship plain binaries. Nothing in Phases 1–4 should have to change to add these. --- ## 6. Token handoff (the end of a successful run) ``` Runic Gateway is installed. One manual step remains — connect the website to this sidecar: Base URL http://:8080 WebSocket URL ws://:8080/ws Protocol version 3 Auth token 4f9c... (also in /etc/runicgateway/sidecar.toml) Paste these into Admin → Shard on your Runic Gateway site: https:///admin/shard The token is write-only once saved — the site will never show it back to you. ``` Every value in that block except the host and the site URL comes from one `uo-link-sidecar --print-config` call (§2.4): `web.auth_token`, `protocol`, and `web.bind` + `web.ws_path` for the two URLs. Only the **host** is substituted — `web.bind` is frequently `0.0.0.0`, which is not something to hand a website — so the installer composes the URLs from the host it detects or prompts for, rather than echoing the bind address. The installer prompts for the site URL only to build that link; it never contacts the website. A future "installer registers itself with the website" flow (claim code + authenticated endpoint) is explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added later without changing anything here. The printed token is a secret in transit: `--print-config` output must go to the operator's terminal and the config file, never into an installer log file or a support bundle. --- ## 7. Version tracking, the bundle, and release orchestration Three components version independently, bound by a protocol contract: - **sidecar** — `PROTOCOL_VERSION` in `link/sidecar/src/main.rs`, exposed on `/health` and as `X-UOLink-Version` on every response; a mismatch is rejected `409`. - **website** — stores an expected protocol version in `uoLinkConfig` (admin-managed). - **plugin overlay** — has no queryable version before ServUO boots. The overlay release `manifest.json` declares it, and `install.json` records what was deployed. ### 7.0 The overlay manifest Shipped inside every `runicgateway-overlay-.tar.gz`, generated by that repo's release workflow: ```json { "component": "servuo-plugins-overlay", "version": "0.1.0", "commit": "968b526…", "repo": "RunicGateway/servuo-plugins", "protocol": 3, "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, "files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" } } ``` `version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from `servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file. Two of these carry weight beyond documentation: - **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated in `overlay.toml` and in that repo's README: **bump it in the same PR that changes the emitters**, the way `link` bumps `PROTOCOL_VERSION`. - **`files` is what makes `doctor` able to tell "the operator edited a deployed file" from "the overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a mismatch against `install.json` alone means local edits. `min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds* files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified against exactly one version (§2.2). ### 7.1 The bundle manifest **The bundle is the compat matrix.** Rather than the installer hardcoding versions or blindly resolving "latest", CI publishes a small manifest naming an exact, checked combination: ```json { "schema": 1, "bundle": "2026.08.04", "generated": "2026-08-04T16:07:13Z", "protocol": 3, "link": { "repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3, "assets": { "linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" }, "windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" } } }, "overlay": { "repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1", "commit": "3a52abb…", "protocol": 3, "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, "asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" } } } ``` Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one hash could only ever have described one of them. `schema` versions this document's shape and is independent of `protocol` and of either component's release version — all three move separately. The installer fetches the current bundle at run time; `--bundle ` pins an older one for a reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of JSON and leaves the installer binary untouched** — operators do not re-download the installer to pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical. Two gates run at compose time, both cheap and both worth it: 1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version. This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator. The two halves are read from different places because they *are* different: the overlay's from `manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary). 2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`. The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that check — and the installer verifies every download against it. These artifacts are deliberately unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody verified would make the chain decorative. #### Where bundles are published Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea credentials (§1): ``` bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json bundles/bundle-.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle) ``` Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle on the same day — a sidecar release in the morning and an overlay release in the afternoon is the normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix. **Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless of kind — interleaving bundle releases would make "latest" intermittently resolve to a release carrying no installer binary. Committing also yields a reviewable diff and a git history of the compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit already requires the CI user to be able to push to `main`. ### 7.2 What triggers a bundle | Trigger | Why | |---|---| | `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step | | `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step | | Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar | `repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version, whereas dispatching an existing `workflow_dispatch` workflow via the API works today. **A failed dispatch is a warning, never a failed release.** By the time that step runs the component release is published and correct; failing the job would misreport it. This also keeps the dispatch from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch costs latency, not correctness, which is the whole reason the cron exists. ### 7.3 Stale-overlay handling: dispatch, don't wait Each component **self-releases on merge to its own `main`**, using the same conventional-commit engine. Note that "updated since the last release" must mean *releasable* commits — the engine sets `RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not** cut an overlay release, and the bundle keeps using the existing one. The compose job's copy of that rule additionally **excludes merge commits**, whose subject is `Merge pull request ''`. Without that, every squash-free merge of a `feat:` branch would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a `fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly declines to run. So by the time the installer's CI looks, the release normally already exists. If it finds `servuo-plugins` main ahead of its latest release *with* releasable commits, it: 1. fires that repo's release workflow via workflow-dispatch and **does not wait for it**, 2. composes this bundle from the assets that exist right now, 3. writes a loud warning into the job summary. The new overlay release lands minutes later on its own and the nightly cron folds it into the next bundle. This gets the automation without the flaky part: dispatching another repo's workflow is fine — that workflow still runs its own gates — but *polling* it is not, because Gitea's dispatch endpoint returns no run handle, so the job would have to guess which run is its own and hold a runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once rather than being silently retriggered every night forever. ### 7.4 Open risk **Settled as of the v3 cutover.** Protocol work landed on `edge` branches and the `edge → main` cutover has now merged, so `main` speaks protocol 3 consistently across the repos. The rule it motivated stands regardless and is not a temporary measure: **the installer hardcodes no protocol version anywhere.** It reads what the artifacts declare, and §7.1's gate 1 is what stops a mismatched pair from being published as a bundle — which is the mechanism that will matter at the *next* protocol bump, not just this one. See `docs/link/v3.md`. --- ## 8. Open questions 1. **Windows service mechanism** — `sc create` against the plain console binary (simplest, works today), a bundled WinSW/NSSM shim, or a native `--service` mode in the sidecar using the `windows-service` crate (cleanest, but changes `link`). Recommendation: `sc create` for v1, revisit if restart semantics prove inadequate. 2. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells the operator to restart afterward. Offering to stop/start would be friendlier but means owning another shard's process lifecycle, and the shard's own start scripts vary. 3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so sidecar and ServUO must share a host. Should the installer support installing only uo-link on a different host, or hard-assume co-location? Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version. **Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed: `servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies in principle — the installer hardcodes no protocol version, it reads what the artifacts declare — but the specific `edge`/`main` disagreement that motivated it is gone. --- ## 9. Administrator experience Before: ``` find plugins → copy files → edit ServUO → download bridge → start bridge → configure startup → find the token → troubleshoot paths ``` After: ``` download artifact → verify checksum → run installer → select ServUO directory → install components → paste 4 values into Admin → Shard → start ServUO normally ```