Files
installer/src/cli.rs
wtclaude 3b2881eb9c
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
fix(rust): the handoff says what the run did, not that a link exists (D156)
`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
2026-09-26 01:21:32 -05:00

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);
}
}