Files
installer/src/ui.rs
wtclaude dff4ad41c9
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
feat(installer): implement Phase 1 — the installer core
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>
2026-08-04 14:58:17 -05:00

124 lines
4.3 KiB
Rust

//! Terminal output and prompts.
//!
//! Two rules shape this module:
//!
//! 1. **A run's output is a support artifact.** `docs/installer/INSTALL.md` shows operators what a
//! run looks like, and the first thing anyone asks for in a bug report is a pasted log — so the
//! marks and the column layout here match the guide rather than being decided per call site.
//! 2. **Nothing here is a secret.** The auth token is printed by the handoff (Phase 2) straight to
//! the operator's terminal and never routed through a log file (PLAN.md §6).
use std::io::{self, IsTerminal, Write};
/// Enables UTF-8 on the Windows console so the status marks below are not mojibake.
///
/// The guide's illustrated output uses `✓ ⚠ ✗`, and PowerShell 5.1 on a machine whose console code
/// page is still 437/1252 renders those as garbage. `SetConsoleOutputCP` is the one-call fix; it is
/// declared inline rather than pulling in a Windows binding crate for a single symbol, and a
/// failure is ignored because a wrongly-encoded tick is a cosmetic problem, not a reason to refuse
/// to install.
#[cfg(windows)]
pub fn init_console() {
extern "system" {
fn SetConsoleOutputCP(code_page: u32) -> i32;
}
const CP_UTF8: u32 = 65001;
unsafe {
SetConsoleOutputCP(CP_UTF8);
}
}
#[cfg(not(windows))]
pub fn init_console() {}
pub fn ok(msg: &str) {
println!("{msg}");
}
pub fn warn(msg: &str) {
println!("{msg}");
}
pub fn heading(msg: &str) {
println!("\n{msg}");
}
/// A two-column row: ` label value`.
pub fn row(label: &str, value: &str) {
println!(" {label:<16} {value}");
}
/// Asks a yes/no question.
///
/// `assume_yes` (`--yes`) takes the default without asking, which is what makes an unattended run
/// expressible. A non-interactive run *without* `--yes` is an error rather than a silent default:
/// the questions this asks decide whether stock ServUO files get edited, and a pipe with no
/// terminal on the other end cannot consent to that.
pub fn confirm(question: &str, default: bool, assume_yes: bool) -> io::Result<bool> {
if assume_yes {
println!(
"{question} [{}] (--yes)",
if default { "Y/n" } else { "y/N" }
);
return Ok(default);
}
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"cannot ask \"{question}\" — stdin is not a terminal. \
Pass --yes to take the default, or the matching flag to answer it explicitly."
)));
}
loop {
print!("{question} [{}] ", if default { "Y/n" } else { "y/N" });
io::stdout().flush()?;
let mut line = String::new();
// EOF (0 bytes) is not "yes". It means the operator is gone; take the default and move on.
if io::stdin().read_line(&mut line)? == 0 {
println!();
return Ok(default);
}
match line.trim().to_ascii_lowercase().as_str() {
"" => return Ok(default),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!(" please answer y or n"),
}
}
}
/// Asks for a line of text. An empty answer keeps `default` when one is offered.
pub fn prompt(question: &str, default: Option<&str>) -> io::Result<String> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"cannot ask \"{question}\" — stdin is not a terminal. Pass the matching flag."
)));
}
loop {
match default {
Some(d) => print!("{question} [{d}]: "),
None => print!("{question}: "),
}
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
println!();
return match default {
Some(d) => Ok(d.to_string()),
None => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("no answer for \"{question}\""),
)),
};
}
let answer = line.trim();
if !answer.is_empty() {
return Ok(answer.to_string());
}
if let Some(d) = default {
return Ok(d.to_string());
}
println!(" an answer is required");
}
}