//! 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 { 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 `: a Rust server root (the directory holding `RustDedicated`). pub rust: Option, /// `--server-id `: names one Rust instance — its service, config, database and ports — and /// is the plugin's `ServerId` (D148). pub server_id: Option, /// `--web-port `: the port the website reaches a Rust instance's sidecar on. pub web_port: Option, /// `--verify`: report every change that would be made, write nothing. pub verify: bool, /// `--servuo `: name the ServUO root instead of detecting or prompting. pub servuo: Option, /// `--bundle `: pin a published bundle instead of resolving the current one. pub bundle: Option, pub patches: PatchChoice, /// `--patches-unsupported-servuo`: required *in addition to* `--patches` on a non-57.4 tree. pub patches_unsupported_servuo: bool, /// `--host `: the hostname to print in the website URLs. pub host: Option, /// `--site-url `: the site's base URL, for the Admin → Shard (uo-link) link. pub site_url: Option, /// `--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 [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 Which game's shard side. Default servuo. --verify install, update. Dry run: report every change that would be made, write nothing. --servuo install, doctor, update. The ServUO root, instead of detecting or prompting for it. --bundle 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 install. The hostname to print in the website URLs. --site-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 /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 install. The Rust server root — the directory holding RustDedicated. Oxide or Carbon is detected, never asked. --server-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 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>(args: I) -> Result { let mut cli = Cli::default(); let mut command: Option = 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::().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>( flag: &str, inline: Option, rest: &mut I, ) -> Result { 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 { 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); } }