From 69c1440cbeb0c6da2da02d858c89e1511d3268e5 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:26:55 -0400 Subject: [PATCH] CLI: fieldcraft-cli update --check | --install (#231) --check runs app.update.check and prints its JSON; --install downloads the latest release for this install, verifies the checksum list's signature and the asset's checksum, and swaps it in (or, for .deb/.rpm, prints the package manager command for the verified download). Tested against a local release server through FIELDCRAFT_UPDATE_HOST. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- Cargo.lock | 1 + apps/fieldcraft-cli/Cargo.toml | 4 +++ apps/fieldcraft-cli/src/main.rs | 6 ++++ apps/fieldcraft-cli/src/update.rs | 56 +++++++++++++++++++++++++++++ apps/fieldcraft-cli/tests/update.rs | 36 +++++++++++++++++++ 5 files changed, 103 insertions(+) create mode 100644 apps/fieldcraft-cli/src/update.rs create mode 100644 apps/fieldcraft-cli/tests/update.rs diff --git a/Cargo.lock b/Cargo.lock index f9ae3d9..e90c2d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1293,6 +1293,7 @@ dependencies = [ "fieldcraft-engine", "fieldcraft-io", "fieldcraft-mcp", + "fieldcraft-update", "serde_json", ] diff --git a/apps/fieldcraft-cli/Cargo.toml b/apps/fieldcraft-cli/Cargo.toml index cfb9b71..4af9b5b 100644 --- a/apps/fieldcraft-cli/Cargo.toml +++ b/apps/fieldcraft-cli/Cargo.toml @@ -12,4 +12,8 @@ workspace = true fieldcraft-engine = { workspace = true } fieldcraft-io = { workspace = true } fieldcraft-mcp = { workspace = true } +fieldcraft-update = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +fieldcraft-update = { workspace = true, features = ["mock"] } diff --git a/apps/fieldcraft-cli/src/main.rs b/apps/fieldcraft-cli/src/main.rs index d8d1519..68bd121 100644 --- a/apps/fieldcraft-cli/src/main.rs +++ b/apps/fieldcraft-cli/src/main.rs @@ -3,11 +3,13 @@ //! fieldcraft-cli features //! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] //! fieldcraft-cli crashes [list [--json] | show | replay | dir | clear []] +//! fieldcraft-cli update --check | --install //! fieldcraft-cli --version #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] mod crashes; +mod update; use std::path::Path; use std::process::ExitCode; @@ -36,6 +38,9 @@ usage: computer (docs/crash-reports.md): list them (--json), show one, replay one (does the crash happen again?), print their folder, delete them + fieldcraft-cli update --check is a newer FieldCraft published? (app.update.check; JSON) + fieldcraft-cli update --install download the latest release, verify its signature and + checksum, and install it over this copy (docs/updates.md) fieldcraft-cli --version print the version "; @@ -61,6 +66,7 @@ fn main() -> ExitCode { } Some("mcp") => mcp(args.get(1..).unwrap_or(&[])), Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])), + Some("update") => update::run(args.get(1..).unwrap_or(&[])), Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)), Some("-V" | "--version") => { println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION")); diff --git a/apps/fieldcraft-cli/src/update.rs b/apps/fieldcraft-cli/src/update.rs new file mode 100644 index 0000000..8c647ac --- /dev/null +++ b/apps/fieldcraft-cli/src/update.rs @@ -0,0 +1,56 @@ +//! `fieldcraft-cli update --check | --install` (docs/updates.md): the same check as the app's +//! Help › Check for Updates (`app.update.check`), and installing the latest release over this +//! copy of the CLI's install, verified against the release key first. `FIELDCRAFT_UPDATE_HOST` +//! points both at a local test server. + +use fieldcraft_engine::Session; +use fieldcraft_update::install::{self, Finish}; +use fieldcraft_update::net::Client; +use fieldcraft_update::stage::{self, Options}; +use serde_json::json; + +pub const USAGE: &str = "usage: fieldcraft-cli update --check | --install"; + +pub fn run(args: &[String]) -> Result<(), String> { + match args.iter().map(String::as_str).collect::>().as_slice() { + ["--check"] => check(), + ["--install"] => install(), + _ => Err(USAGE.into()), + } +} + +fn check() -> Result<(), String> { + fieldcraft_engine::enable_update_checks(true); + let report = Session::default().execute("app.update.check", &json!({})).map_err(|e| e.to_string())?; + println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?); + Ok(()) +} + +fn install() -> Result<(), String> { + let client = Client::from_env(); + let check = client.check().map_err(|e| e.to_string())?; + if !check.newer() { + println!("FieldCraft {} is up to date (the latest release is {}).", check.current, check.latest.version); + return Ok(()); + } + let here = install::here(); + let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates")); + eprintln!("Downloading FieldCraft {} for {}…", check.latest.version, here.describe()); + let progress = |done: u64, total: u64| { + if total > 0 && done == total { + eprintln!("Downloaded {} MB; verifying…", total / (1024 * 1024)); + } + }; + let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress }; + let staged = stage::stage(&client, &check.latest, &here, &options).map_err(|e| e.to_string())?; + match install::plan(&staged, false).map_err(|e| e.to_string())? { + Finish::Swap(plan) => { + stage::apply(&plan).map_err(|e| e.to_string())?; + println!("Installed FieldCraft {} ({}). A running FieldCraft app uses it from its next start.", staged.version, here.describe()); + } + Finish::Package { file, command } => { + println!("Downloaded and verified {}.\nInstall it with the system's package manager:\n {command}", file.display()); + } + } + Ok(()) +} diff --git a/apps/fieldcraft-cli/tests/update.rs b/apps/fieldcraft-cli/tests/update.rs new file mode 100644 index 0000000..d1b41c7 --- /dev/null +++ b/apps/fieldcraft-cli/tests/update.rs @@ -0,0 +1,36 @@ +//! `fieldcraft-cli update --check` against a local release server (FIELDCRAFT_UPDATE_HOST). + +use std::process::Command; + +use fieldcraft_update::mock::Server; +use serde_json::{Value, json}; + +fn cli(host: &str, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_UPDATE_HOST", host).output().unwrap() +} + +#[test] +fn update_check_reports_the_latest_release() { + let server = Server::start().unwrap(); + server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/jyviko/fieldcraft/releases/tag/v99.0.0", "assets": []})); + let out = cli(&server.url(), &["update", "--check"]); + assert!(out.status.success(), "{out:?}"); + let report: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["current"], env!("CARGO_PKG_VERSION")); + assert_eq!((report["latest"].as_str(), report["newer"].as_bool(), report["signed"].as_bool()), (Some("99.0.0"), Some(true), Some(false))); + // A test build is not a release package: it checks but would not install. + assert_eq!(report["asset"], Value::Null); + assert_eq!(server.requests(), 1); + + // The same version: not newer, and --install has nothing to do. + server.release("latest", &json!({"tag_name": format!("v{}", env!("CARGO_PKG_VERSION")), "assets": []})); + let out = cli(&server.url(), &["update", "--check"]); + assert_eq!(serde_json::from_slice::(&out.stdout).unwrap()["newer"], false); + let out = cli(&server.url(), &["update", "--install"]); + assert!(out.status.success() && String::from_utf8_lossy(&out.stdout).contains("is up to date"), "{out:?}"); + + // Unreachable: an error, not a crash. + let out = cli("http://127.0.0.1:1", &["update", "--check"]); + assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("could not reach"), "{out:?}"); + assert!(!cli(&server.url(), &["update"]).status.success()); +}