feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
Adds the Rust crate at the repo root and implements `install` end to end for the overlay half of a deployment: resolve the published bundle, find and validate the ServUO root, refuse to deploy under a running shard, sync the plugin overlay, and record what was deployed in install.json. `doctor`, `update` and `uninstall` parse and answer with the phase they arrive in rather than "unrecognized command", and the run states plainly that the uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed — `--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet completion would be read as a patched shard. Landing on `edge` rather than `main`: release.yml publishes a binary on every push to main, and an installer that deploys the overlay but cannot install the sidecar is not something to hand an operator. pr-checks.yml now gates PRs into edge on the same rules, so the branch the work happens on is not the ungated one. Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1: - The code lives in a library called `rgdeploy` with a thin binary that keeps the published name. Windows' UAC installer detection refuses to launch an unsigned executable whose file name contains "install" (os error 740), and Cargo names test harnesses after their target — so a target under that name makes `cargo test` unrunnable on Windows. - The running-shard check matches processes by path, not by process name: on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and a name match would report "not running" for a shard that is running. - install.json records a state (`deployed` / `kept-operator-modified`), not the run's verb, so an unchanged re-run produces an identical record and writes nothing. - The Bridge.cfg keep rule compares against the hash the installer last deployed, not the last hash it saw — otherwise a kept file is overwritten on the very next run. - Downloads are verified against the bundle's SHA256 while being written, then every extracted file is re-hashed against the release's own manifest.json, whose protocol and version are cross-checked against the bundle. Verified against a real ServUO 57.4 tree and end to end into a scratch tree: 24 files deployed, an unchanged re-run that writes nothing, an edited Bridge.cfg kept across repeated runs while code files are overwritten, bundle pinning, and a refusal with a shard running out of the tree. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
336
src/cli.rs
Normal file
336
src/cli.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
//! 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
|
||||
//! though Phase 1 implements only part of it — a parser written once against the published contract
|
||||
//! cannot drift from it, and a flag that belongs to a later phase gets an explicit "not in this
|
||||
//! build" notice at the point where it would have taken effect (see `install.rs`). The one thing it
|
||||
//! must never do is accept `--patches` silently, which would let an operator believe stock ServUO
|
||||
//! files were touched when nothing was.
|
||||
//!
|
||||
//! 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 Phase 1 implements; 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Run(Command),
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cli {
|
||||
pub mode: Mode,
|
||||
/// `--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 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` and `uo-link.db`.
|
||||
pub purge: bool,
|
||||
}
|
||||
|
||||
impl Default for Cli {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: Mode::Help,
|
||||
verify: false,
|
||||
servuo: None,
|
||||
bundle: None,
|
||||
patches: PatchChoice::Ask,
|
||||
patches_unsupported_servuo: false,
|
||||
host: None,
|
||||
site_url: None,
|
||||
assume_yes: false,
|
||||
purge: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
|
||||
|
||||
Usage: runicgateway-installer <COMMAND> [OPTIONS]
|
||||
|
||||
Commands:
|
||||
install Deploy the plugin overlay, install the uo-link 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.
|
||||
|
||||
Options:
|
||||
--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 link.
|
||||
--yes Assume the default answer to every prompt.
|
||||
--purge uninstall. Also delete sidecar.toml and
|
||||
uo-link.db, which are otherwise kept.
|
||||
-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,
|
||||
"--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)?),
|
||||
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}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user