diff --git a/crates/uffs-cli/src/commands/update/mod.rs b/crates/uffs-cli/src/commands/update/mod.rs index 9d22dcca6..95ad4789a 100644 --- a/crates/uffs-cli/src/commands/update/mod.rs +++ b/crates/uffs-cli/src/commands/update/mod.rs @@ -110,7 +110,26 @@ pub(crate) fn run_update(args: &[String]) -> Result<()> { if repair && !forwarded.iter().any(|arg| arg == "--repair") { forwarded.push("--repair".to_owned()); } - return doctor::spawn(&snapshot_path, &forwarded); + // Helper health check (+ local self-heal when --repair): journal, + // backups, services, broker, release reach. Captured (not `?`-propagated) + // so a reported failure doesn't pre-empt the update-flow fix below. + let health = doctor::spawn(&snapshot_path, &forwarded); + + // Update-class issues — out-of-date, version-skewed, or **missing a core + // binary** — are fixed by the update flow itself, which already owns the + // core set. So doctor *redirects* there rather than teaching the + // health-check helper that set. `--offline` skips this (assess needs the + // release feed). With `--repair` we run it; interactively we ask; piped + // we just point. + if !args.iter().any(|arg| arg == "--offline") + && matches!(assess(&report), UpdatePlan::Available { .. }) + { + if repair || prompt_yes_no("Run `uffs --update` now to fix this?") { + return run_automatic_update(&report, verbose); + } + print_update_redirect_hint(); + } + return health; } let report = detect(); @@ -208,6 +227,35 @@ fn has_missing_core(report: &DetectionReport) -> bool { }) } +/// Point the user at the update flow — the fix for an out-of-date, skewed, or +/// incomplete install (doctor detects; `uffs --update` repairs). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_update_redirect_hint() { + println!( + "\n\u{2192} Run `uffs --update` to bring the install up to date and complete the core set." + ); +} + +/// Ask a yes/no question on an interactive terminal. Returns `false` **without +/// prompting** when stdin is not a TTY (scripts / pipes / CI), so callers fall +/// back to a printed hint instead of blocking on a read that can't be answered. +#[expect(clippy::print_stdout, reason = "interactive CLI prompt")] +fn prompt_yes_no(question: &str) -> bool { + use std::io::{IsTerminal as _, Write as _}; + if !std::io::stdin().is_terminal() { + return false; + } + print!("\n{question} [y/N] "); + if std::io::stdout().flush().is_err() { + return false; + } + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return false; + } + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + /// Run the full end-to-end update when one is needed; otherwise report the /// install is current. Journaled + auto-rollback (delegated to `apply`). fn run_automatic_update(report: &DetectionReport, verbose: bool) -> Result<()> { @@ -462,10 +510,13 @@ fn print_help() { \x20 atomically swap + smoke-test, commit, restart.\n\ \x20 Journaled + auto-rollback on failure.\n\ \x20 doctor End-to-end health check (versions, dirs, journal,\n\ - \x20 backups, services, broker pipe, release reach).\n\ - \x20 repair Diagnose + self-heal (= doctor --repair):\n\ - \x20 resume/roll back an interrupted update, sweep\n\ - \x20 stale backups, restart stopped services.\n\ + \x20 backups, services, broker pipe, release reach). If\n\ + \x20 out of date / skewed / missing a core binary, it\n\ + \x20 points to `uffs --update` (asks first on a TTY).\n\ + \x20 repair Diagnose + self-heal (= doctor --repair): resume/\n\ + \x20 roll back an interrupted update, sweep stale\n\ + \x20 backups, restart stopped services — and run the\n\ + \x20 update flow if the install is out of date.\n\ \x20 recover Finish or roll back an interrupted update now\n\ \x20 (foreground; the on-demand self-heal).\n\ \x20 bins Print the core binary stems (one per line) —\n\ diff --git a/docs/architecture/cli-grammar.md b/docs/architecture/cli-grammar.md index a9351dc76..daf7e1565 100644 --- a/docs/architecture/cli-grammar.md +++ b/docs/architecture/cli-grammar.md @@ -167,7 +167,7 @@ different (each internally-consistent) conventions. | `uffs aggregate\|agg ` | `uffs --agg ` | — | `--format` | | `uffs daemon ` | `uffs --daemon ` | `start` `status` `stats` `stop` `kill` `restart` `load` `hibernate` `preload` `forget` `status_drives` | `--data-dir` `--mft-file` `--elevate` | | `uffs mcp ` | `uffs --mcp ` | `run` `start` `status` `stop` `kill` `restart` `reload` | `--bind` `--port` `--data-dir` | -| `uffs update [--acquire\|--apply\|--snapshot]` + `uffs update doctor` | `uffs --update []` | ***(none = update end-to-end if needed)*** `check` `snapshot` `acquire` `apply` `doctor` `recover` | `--version` `--repair` `--offline` `--repo` `-v` | +| `uffs update [--acquire\|--apply\|--snapshot]` + `uffs update doctor` | `uffs --update []` | ***(none = update end-to-end if needed, incl. completing a missing core binary)*** `check` `snapshot` `acquire` `apply` `doctor` `repair` `recover` `bins` | `--version` `--repair` `--offline` `--repo` `-v` | | `uffs status` | `uffs --status` | — | — | | `uffs --help / --version` | `uffs --help / --version` *(unchanged; global)* | — | — | @@ -179,6 +179,15 @@ different (each internally-consistent) conventions. the update subsystem, so this keeps the surface uniform. (A top-level `--doctor` convenience alias is an open question, §12.) +`repair` is a first-class **alias for `doctor --repair`** (a bare `--repair` +flag routes there too) — verbs stay bare, options stay dashed, and the user +never has to remember which `repair` is. `doctor`/`repair` **redirect to the +update flow** when they find an update-class issue (out of date, version-skewed, +or a missing core binary) rather than duplicating the updater's logic. `bins` +prints the canonical core binary set (the single source of truth all flows +share); bare `uffs --update` reconciles that whole set, *adding* a missing core +binary, not just version-matching the ones present. + ## 6. Edge cases & escapes | Input | Result |