All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
`install --game rust` ended every instance with "Rust server "alpha" is connected to its sidecar" — printed unconditionally, before the plugin had dialled anything, on a stopped server where it had not even loaded, and beside "No service was registered, so nothing is listening yet". The phase 18 walk read it as a claim and then found beta's plugin connected to the wrong sidecar. It now reads "Rust server "alpha" is set up." followed by either "The plugin loads now; `doctor --game rust --server-id alpha` confirms it connected." or "The plugin connects when the server next starts.". The top-level --help no longer describes `install` as the uo-link sidecar and overlay for both games, and says what `uninstall` removes for Rust. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
477 lines
19 KiB
Rust
477 lines
19 KiB
Rust
//! Command-line surface.
|
|
//!
|
|
//! The shape here is not invented: `docs/installer/INSTALL.md` §2 was written before the binary and
|
|
//! fixes every command and flag an operator can type. This module parses that surface *whole*, even
|
|
//! where a later phase implements it — a parser written once against the published contract cannot
|
|
//! drift from it, and a flag belonging to an unbuilt phase gets an explicit notice at the point
|
|
//! where it would have taken effect (see `install.rs`). The tri-state on `--patches` is the part
|
|
//! that carries weight: "not mentioned" has to stay distinguishable from "explicitly declined",
|
|
//! because only the first may prompt and only an explicit yes may edit a stock ServUO file.
|
|
//!
|
|
//! Hand-rolled, like `link/sidecar/src/cli.rs`: a handful of flags, no completions, no subcommand
|
|
//! trees. A parsing crate would be larger than the code it replaced.
|
|
|
|
use std::fmt;
|
|
|
|
/// The verb. `Install` is the only one built so far; the rest parse so that running them reports
|
|
/// which phase they arrive in rather than "unrecognized argument", which would read as a typo
|
|
/// rather than as an unfinished tool.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Command {
|
|
Install,
|
|
Doctor,
|
|
Update,
|
|
Uninstall,
|
|
}
|
|
|
|
impl Command {
|
|
fn parse(token: &str) -> Option<Self> {
|
|
match token {
|
|
"install" => Some(Self::Install),
|
|
"doctor" => Some(Self::Doctor),
|
|
"update" => Some(Self::Update),
|
|
"uninstall" => Some(Self::Uninstall),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Command {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(match self {
|
|
Self::Install => "install",
|
|
Self::Doctor => "doctor",
|
|
Self::Update => "update",
|
|
Self::Uninstall => "uninstall",
|
|
})
|
|
}
|
|
}
|
|
|
|
/// What the patch tier was told to do. Tri-state on purpose: "not mentioned" is a different input
|
|
/// from "explicitly declined", because only the first one may prompt.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PatchChoice {
|
|
Ask,
|
|
Yes,
|
|
No,
|
|
}
|
|
|
|
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
|
|
/// default, so every command line written before Rust existed means exactly what it meant.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Game {
|
|
ServUo,
|
|
Rust,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Mode {
|
|
Run(Command),
|
|
Help,
|
|
Version,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Cli {
|
|
pub mode: Mode,
|
|
/// `--game servuo|rust`.
|
|
pub game: Game,
|
|
/// `--rust <path>`: a Rust server root (the directory holding `RustDedicated`).
|
|
pub rust: Option<String>,
|
|
/// `--server-id <id>`: names one Rust instance — its service, config, database and ports — and
|
|
/// is the plugin's `ServerId` (D148).
|
|
pub server_id: Option<String>,
|
|
/// `--web-port <port>`: the port the website reaches a Rust instance's sidecar on.
|
|
pub web_port: Option<u16>,
|
|
/// `--verify`: report every change that would be made, write nothing.
|
|
pub verify: bool,
|
|
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
|
|
pub servuo: Option<String>,
|
|
/// `--bundle <tag>`: pin a published bundle instead of resolving the current one.
|
|
pub bundle: Option<String>,
|
|
pub patches: PatchChoice,
|
|
/// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree.
|
|
pub patches_unsupported_servuo: bool,
|
|
/// `--host <name>`: the hostname to print in the website URLs.
|
|
pub host: Option<String>,
|
|
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
|
|
pub site_url: Option<String>,
|
|
/// `--yes`: assume the default answer to every prompt.
|
|
pub assume_yes: bool,
|
|
/// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set
|
|
/// and every backup.
|
|
pub purge: bool,
|
|
/// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3).
|
|
pub no_backup: bool,
|
|
}
|
|
|
|
impl Default for Cli {
|
|
fn default() -> Self {
|
|
Self {
|
|
mode: Mode::Help,
|
|
game: Game::ServUo,
|
|
rust: None,
|
|
server_id: None,
|
|
web_port: None,
|
|
verify: false,
|
|
servuo: None,
|
|
bundle: None,
|
|
patches: PatchChoice::Ask,
|
|
patches_unsupported_servuo: false,
|
|
host: None,
|
|
site_url: None,
|
|
assume_yes: false,
|
|
purge: false,
|
|
no_backup: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const USAGE: &str = "\
|
|
Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic
|
|
Gateway website.
|
|
|
|
Usage: runicgateway-installer <COMMAND> [OPTIONS]
|
|
|
|
Commands:
|
|
install Deploy the game-side plugin (the ServUO overlay, or the Rust
|
|
plugin), install the sidecar and its service, record what was
|
|
deployed, and print the values the website needs.
|
|
doctor Diagnose an existing deployment end to end.
|
|
update Re-resolve the bundle and move both components to it.
|
|
uninstall Remove what the installer exclusively owns. Never edits the
|
|
ServUO tree — it prints what to remove there. For Rust it
|
|
removes only its own plugin file and keeps the plugin config.
|
|
|
|
Options:
|
|
--game <servuo|rust> Which game's shard side. Default servuo.
|
|
--verify install, update. Dry run: report every
|
|
change that would be made, write nothing.
|
|
--servuo <PATH> install, doctor, update. The ServUO root,
|
|
instead of detecting or prompting for it.
|
|
--bundle <TAG> install, update. Pin an exact published
|
|
bundle (e.g. 2026.08.04) instead of the
|
|
current one.
|
|
--patches / --no-patches install. Decide the patch tier without
|
|
being prompted. --patches never loosens
|
|
the region check.
|
|
--patches-unsupported-servuo install. Required IN ADDITION TO --patches
|
|
to run the patch tier on a ServUO that is
|
|
not 57.4. Unsupported and untested.
|
|
--host <NAME> install. The hostname to print in the
|
|
website URLs.
|
|
--site-url <URL> install. Your site's base URL, for the
|
|
Admin → Shard (uo-link) link.
|
|
--yes Assume the default answer to every prompt.
|
|
On uninstall it means yes: that prompt
|
|
defaults to no, and typing `uninstall
|
|
--yes` is not an accident.
|
|
--no-backup install, update. Do not copy the files
|
|
this run is about to overwrite. They are
|
|
otherwise saved under <state>/backups/,
|
|
newest 3 kept.
|
|
--purge uninstall. Also delete sidecar.toml,
|
|
uo-link.db, the cached patch set and every
|
|
backup, all of which are otherwise kept.
|
|
Rust (with --game rust):
|
|
--rust <PATH> install. The Rust server root — the
|
|
directory holding RustDedicated. Oxide or
|
|
Carbon is detected, never asked.
|
|
--server-id <ID> install: required. doctor, uninstall:
|
|
one instance instead of all. The id the
|
|
website knows this server by, and the
|
|
name of its sidecar's service.
|
|
--web-port <PORT> install. The sidecar's website port.
|
|
Default: the first free from 8090.
|
|
-V, --version Print the installer version and exit.
|
|
-h, --help Print this help and exit.
|
|
|
|
Environment:
|
|
RUNICGATEWAY_STATE_DIR Relocates the installer's own state (install.json,
|
|
the cached patch set) away from /etc/runicgateway or
|
|
%ProgramData%\\RunicGateway. For testing a run
|
|
without root; an installed deployment should not
|
|
set it.
|
|
|
|
The installer never contacts your website, never deletes anything from your
|
|
ServUO tree, and never starts or stops your shard.
|
|
";
|
|
|
|
/// Parses arguments **without** the program name.
|
|
///
|
|
/// The error string is what the caller prints on stderr before exiting `2`.
|
|
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
|
let mut cli = Cli::default();
|
|
let mut command: Option<Command> = None;
|
|
let mut it = args.into_iter().peekable();
|
|
|
|
while let Some(arg) = it.next() {
|
|
// `--flag=value` is normalized here so each flag below is written once. Splitting on the
|
|
// first '=' only: a URL or a Windows path may legitimately contain more.
|
|
let (name, inline) = match arg.split_once('=') {
|
|
Some((n, v)) if n.starts_with("--") => (n.to_string(), Some(v.to_string())),
|
|
_ => (arg.clone(), None),
|
|
};
|
|
|
|
match name.as_str() {
|
|
"-h" | "--help" => {
|
|
cli.mode = Mode::Help;
|
|
return Ok(cli);
|
|
}
|
|
"-V" | "--version" => {
|
|
cli.mode = Mode::Version;
|
|
return Ok(cli);
|
|
}
|
|
"--verify" => cli.verify = true,
|
|
"--yes" | "-y" => cli.assume_yes = true,
|
|
"--purge" => cli.purge = true,
|
|
"--no-backup" => cli.no_backup = true,
|
|
"--patches" => cli.patches = PatchChoice::Yes,
|
|
"--no-patches" => cli.patches = PatchChoice::No,
|
|
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
|
|
"--servuo" => cli.servuo = Some(take_value(&name, inline, &mut it)?),
|
|
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
|
|
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
|
|
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
|
|
"--game" => {
|
|
cli.game = match take_value(&name, inline, &mut it)?.as_str() {
|
|
"servuo" => Game::ServUo,
|
|
"rust" => Game::Rust,
|
|
other => return Err(format!("--game must be servuo or rust, not {other:?}")),
|
|
}
|
|
}
|
|
"--rust" => cli.rust = Some(take_value(&name, inline, &mut it)?),
|
|
"--server-id" => {
|
|
let id = take_value(&name, inline, &mut it)?;
|
|
if !valid_server_id(&id) {
|
|
return Err(format!(
|
|
"--server-id {id:?} is not a valid server id: lowercase letters, digits \
|
|
and '-', 1 to 64 characters, not starting with '-' (the website's rule)"
|
|
));
|
|
}
|
|
cli.server_id = Some(id);
|
|
}
|
|
"--web-port" => {
|
|
let raw = take_value(&name, inline, &mut it)?;
|
|
cli.web_port = Some(raw.parse::<u16>().ok().filter(|p| *p >= 1024).ok_or_else(
|
|
|| format!("--web-port must be a port from 1024 to 65535, not {raw:?}"),
|
|
)?);
|
|
}
|
|
other if other.starts_with('-') => {
|
|
return Err(format!("unrecognized argument: {other}"))
|
|
}
|
|
other => match Command::parse(other) {
|
|
Some(c) if command.is_none() => command = Some(c),
|
|
// Two verbs is ambiguous, and picking the first would run something the operator
|
|
// did not ask for while looking like it worked.
|
|
Some(c) => return Err(format!("only one command may be given (saw {c} as well)")),
|
|
None => return Err(format!("unrecognized command: {other}")),
|
|
},
|
|
}
|
|
}
|
|
|
|
// The Rust flags belong to `--game rust` only. Silently ignoring one on a ServUO run would leave
|
|
// an operator believing they had named a server they had not.
|
|
if cli.game == Game::ServUo {
|
|
for (flag, set) in [
|
|
("--rust", cli.rust.is_some()),
|
|
("--server-id", cli.server_id.is_some()),
|
|
("--web-port", cli.web_port.is_some()),
|
|
] {
|
|
if set {
|
|
return Err(format!("{flag} needs --game rust"));
|
|
}
|
|
}
|
|
}
|
|
|
|
match command {
|
|
Some(c) => cli.mode = Mode::Run(c),
|
|
// No verb is not an error worth an exit code — it is someone typing the binary's name to
|
|
// see what it does.
|
|
None => cli.mode = Mode::Help,
|
|
}
|
|
Ok(cli)
|
|
}
|
|
|
|
/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`. The same rule the plugin and the
|
|
/// egg apply, so an id that passes here is one all three accept.
|
|
pub fn valid_server_id(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& !id.starts_with('-')
|
|
&& id
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
|
}
|
|
|
|
/// Pulls a flag's value, from `--flag=value` or from the next token.
|
|
///
|
|
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
|
|
/// otherwise fall through to auto-detection and deploy into a directory nobody named.
|
|
fn take_value<I: Iterator<Item = String>>(
|
|
flag: &str,
|
|
inline: Option<String>,
|
|
rest: &mut I,
|
|
) -> Result<String, String> {
|
|
match inline {
|
|
Some(v) if v.is_empty() => Err(format!("{flag} requires a value")),
|
|
Some(v) => Ok(v),
|
|
None => rest
|
|
.next()
|
|
.ok_or_else(|| format!("{flag} requires a value")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn parse_str(args: &[&str]) -> Result<Cli, String> {
|
|
parse(args.iter().map(|s| s.to_string()))
|
|
}
|
|
|
|
#[test]
|
|
fn no_arguments_prints_help() {
|
|
assert_eq!(parse_str(&[]).unwrap().mode, Mode::Help);
|
|
}
|
|
|
|
#[test]
|
|
fn every_documented_command_parses() {
|
|
for (token, want) in [
|
|
("install", Command::Install),
|
|
("doctor", Command::Doctor),
|
|
("update", Command::Update),
|
|
("uninstall", Command::Uninstall),
|
|
] {
|
|
assert_eq!(parse_str(&[token]).unwrap().mode, Mode::Run(want));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn flags_accept_both_spellings() {
|
|
let spaced = parse_str(&["install", "--servuo", "/opt/ServUO"]).unwrap();
|
|
let equals = parse_str(&["install", "--servuo=/opt/ServUO"]).unwrap();
|
|
assert_eq!(spaced.servuo.as_deref(), Some("/opt/ServUO"));
|
|
assert_eq!(spaced, equals);
|
|
}
|
|
|
|
#[test]
|
|
fn a_value_containing_equals_survives() {
|
|
// Site URLs carry query strings and Windows paths carry drive colons; splitting on every
|
|
// '=' would truncate both.
|
|
let cli = parse_str(&["install", "--site-url=https://s.example/x?a=b=c"]).unwrap();
|
|
assert_eq!(cli.site_url.as_deref(), Some("https://s.example/x?a=b=c"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_flag_without_its_value_is_an_error() {
|
|
for args in [
|
|
vec!["install", "--servuo"],
|
|
vec!["install", "--servuo="],
|
|
vec!["install", "--bundle"],
|
|
vec!["install", "--host"],
|
|
vec!["install", "--site-url"],
|
|
] {
|
|
assert!(parse_str(&args).is_err(), "{args:?} should be rejected");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_patch_tier_is_tri_state() {
|
|
// "Not mentioned" must stay distinguishable from "declined": only the first may prompt,
|
|
// and only an explicit --patches is consent.
|
|
assert_eq!(parse_str(&["install"]).unwrap().patches, PatchChoice::Ask);
|
|
assert_eq!(
|
|
parse_str(&["install", "--patches"]).unwrap().patches,
|
|
PatchChoice::Yes
|
|
);
|
|
assert_eq!(
|
|
parse_str(&["install", "--no-patches"]).unwrap().patches,
|
|
PatchChoice::No
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_servuo_consent_is_its_own_flag() {
|
|
// --patches alone is deliberately not enough on a non-57.4 tree (PLAN.md §2.2.2), so the
|
|
// two must not collapse into one another.
|
|
let cli = parse_str(&["install", "--patches", "--patches-unsupported-servuo"]).unwrap();
|
|
assert_eq!(cli.patches, PatchChoice::Yes);
|
|
assert!(cli.patches_unsupported_servuo);
|
|
assert!(
|
|
!parse_str(&["install", "--patches"])
|
|
.unwrap()
|
|
.patches_unsupported_servuo
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_and_version_win_immediately() {
|
|
assert_eq!(
|
|
parse_str(&["install", "--help", "--bogus"]).unwrap().mode,
|
|
Mode::Help
|
|
);
|
|
assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_tokens_are_rejected() {
|
|
// A typo'd flag must not start a run that is not the one that was asked for.
|
|
assert!(parse_str(&["install", "--verfiy"]).is_err());
|
|
assert!(parse_str(&["instal"]).is_err());
|
|
assert!(parse_str(&["install", "update"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn servuo_stays_the_default_game() {
|
|
assert_eq!(parse_str(&["install"]).unwrap().game, Game::ServUo);
|
|
let rust = parse_str(&[
|
|
"install",
|
|
"--game",
|
|
"rust",
|
|
"--rust",
|
|
"/srv/rust",
|
|
"--server-id",
|
|
"alpha",
|
|
"--web-port=8091",
|
|
])
|
|
.unwrap();
|
|
assert_eq!(rust.game, Game::Rust);
|
|
assert_eq!(rust.server_id.as_deref(), Some("alpha"));
|
|
assert_eq!(rust.web_port, Some(8091));
|
|
}
|
|
|
|
#[test]
|
|
fn a_rust_flag_without_game_rust_is_refused() {
|
|
for args in [
|
|
vec!["install", "--rust", "/srv/rust"],
|
|
vec!["doctor", "--server-id", "alpha"],
|
|
vec!["install", "--web-port", "8091"],
|
|
] {
|
|
let err = parse_str(&args).unwrap_err();
|
|
assert!(err.contains("--game rust"), "{args:?}: {err}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_id_follows_the_websites_rule() {
|
|
for good in ["main", "alpha", "eu-2", "0", &"a".repeat(64)] {
|
|
assert!(valid_server_id(good), "{good}");
|
|
}
|
|
for bad in ["", "-a", "Alpha", "a_b", "a.b", &"a".repeat(65)] {
|
|
assert!(!valid_server_id(bad), "{bad}");
|
|
}
|
|
assert!(parse_str(&["install", "--game", "rust", "--server-id", "Bad"]).is_err());
|
|
assert!(parse_str(&["install", "--game", "rust", "--web-port", "80"]).is_err());
|
|
assert!(parse_str(&["install", "--game", "minecraft"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn order_does_not_matter() {
|
|
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();
|
|
let b = parse_str(&["install", "--yes", "--verify"]).unwrap();
|
|
assert_eq!(a, b);
|
|
assert!(a.verify && a.assume_yes);
|
|
}
|
|
}
|