forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
12487794a3
commit
69c1440cbe
5 files changed
+103
No files matched your search
@@ -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"] }
|
||||
@@ -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 <id> | replay <id> | dir | clear [<id>]]
|
||||
//! 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"));
|
||||
|
||||
@@ -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::<Vec<_>>().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(())
|
||||
}
|
||||
@@ -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::<Value>(&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());
|
||||
}
|
||||
Reference in new issue
Block a user