diff --git a/Cargo.lock b/Cargo.lock index e8aafe7..a5c93ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -306,6 +312,7 @@ dependencies = [ "clap_complete", "colored", "dirs", + "flate2", "fs2", "futures-util", "git2", @@ -321,6 +328,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sha2", + "tar", "tempfile", "thiserror", "tokio", @@ -369,6 +377,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -566,12 +583,32 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1170,6 +1207,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1715,6 +1762,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1803,6 +1856,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2674,6 +2738,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 1570d1d..be4b952 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,10 @@ rusqlite = { version = "0.31", features = ["bundled"] } usearch = "2" fs2 = "0.4" +# Self-update (upgrade command) +flate2 = "1" +tar = "0.4" + # Tree-sitter AST parsing (optional — enable with --features tree-sitter) tree-sitter = { version = "0.26", optional = true } tree-sitter-rust = { version = "0.24", optional = true } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index aca7103..4c6d46a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -15,5 +15,7 @@ pub mod review; pub mod routes; pub mod scan; pub mod serve; +pub mod update_check; +pub mod upgrade; pub mod upload; pub mod watch; diff --git a/src/commands/update_check.rs b/src/commands/update_check.rs new file mode 100644 index 0000000..f98f0c9 --- /dev/null +++ b/src/commands/update_check.rs @@ -0,0 +1,141 @@ +//! Background update notification — non-blocking check on startup. +//! +//! When `cora` runs any command, this module checks (in a background thread) +//! whether a newer release exists on GitHub. Results are cached for 24 hours +//! at `~/.codecora/cora-code/update-cache.json` to avoid rate-limiting. +//! +//! The notification is printed to stderr after the main command finishes, +//! so it never interferes with command output or piping. + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::data_dir; + +const REPO: &str = "codecoradev/cora-code"; +const CACHE_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours + +#[derive(Serialize, Deserialize)] +struct UpdateCache { + latest_version: String, + has_update: bool, + checked_at: u64, +} + +/// Spawn a background thread to check for updates. +/// +/// Call this right after `Cli::parse()`, before command dispatch. +/// The thread is detached — it will print to stderr when done. +/// +/// `enabled` = false (from config `update_check: false`) skips the check entirely. +pub fn spawn_background_check(enabled: bool) { + if !enabled { + return; + } + + // Check cache first — if fresh (< 24h), just print cached result + if let Some(cache) = read_cache() { + if cache.has_update { + print_update_banner(&cache.latest_version); + } + return; // Cache is still fresh, no need to re-check + } + + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + std::thread::spawn(move || { + let latest = match fetch_latest_version() { + Ok(v) => v, + Err(_) => return, // Silent — never show error for background check + }; + + let has_update = latest != current_version; + + // Write cache + write_cache(&latest, has_update); + + if has_update { + print_update_banner(&latest); + } + }); +} + +fn print_update_banner(latest: &str) { + let current = env!("CARGO_PKG_VERSION"); + eprintln!(); + eprintln!(" ⚠ Update available: {} (currently {})", latest, current); + eprintln!(" Run `cora upgrade` to update."); + eprintln!(" https://github.com/{}/releases/tag/{}", REPO, latest); + eprintln!(); +} + +fn cache_path() -> PathBuf { + data_dir::update_cache_path() +} + +fn read_cache() -> Option { + let path = cache_path(); + let content = fs::read_to_string(&path).ok()?; + let cache: UpdateCache = serde_json::from_str(&content).ok()?; + + // Check freshness + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + + if now.saturating_sub(cache.checked_at) > CACHE_TTL_SECS { + return None; // Expired + } + + Some(cache) +} + +fn write_cache(latest_version: &str, has_update: bool) { + // Ensure data dir exists + let _ = data_dir::ensure_data_dir(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let cache = UpdateCache { + latest_version: latest_version.to_string(), + has_update, + checked_at: now, + }; + + let path = cache_path(); + if let Ok(json) = serde_json::to_string(&cache) { + let _ = fs::write(&path, json); + } +} + +/// Fetch latest release tag from GitHub. +/// +/// Uses 302 redirect parsing (no API rate limit). +fn fetch_latest_version() -> Result { + let rt = tokio::runtime::Runtime::new().map_err(|e| format!("Runtime error: {e}"))?; + + rt.block_on(async { + let client = reqwest::Client::new(); + + let resp = client + .head(format!("https://github.com/{REPO}/releases/latest")) + .send() + .await + .map_err(|e| format!("HTTP error: {e}"))?; + + if let Some(location) = resp.headers().get("location") { + let loc = location.to_str().unwrap_or_default(); + if let Some(tag) = loc.rsplit('/').next() { + if tag.starts_with('v') { + return Ok(tag.trim_end_matches('?').to_string()); + } + } + } + + Err("Failed to parse latest version from redirect".into()) + }) +} diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs new file mode 100644 index 0000000..f2bf492 --- /dev/null +++ b/src/commands/upgrade.rs @@ -0,0 +1,389 @@ +//! `cora upgrade` — check for updates and self-upgrade. +//! +//! Detects OS/arch, fetches latest release from GitHub, downloads, +//! verifies checksum, replaces the running binary. +//! +//! Uses a blocking tokio runtime for the HTTP calls (reqwest is async-only +//! in cora-code, unlike uteke which uses reqwest::blocking). + +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use colored::Colorize; +use sha2::{Digest, Sha256}; + +const REPO: &str = "codecoradev/cora-code"; +const BINARY_NAME: &str = "cora"; + +/// Entry point for `cora upgrade`. +/// +/// `check_only` = true corresponds to `cora upgrade --check`: +/// only print whether an update is available, do not download. +pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { + let current_version = env!("CARGO_PKG_VERSION"); + println!("{} Current version: {current_version}", "[INFO]".green()); + + // Detect current binary path + let current_exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!( + "{} Cannot determine current binary path: {e}", + "[ERROR]".red() + ); + eprintln!(" If installed via cargo, run: cargo install --path ."); + return Ok(1); + } + }; + + // Detect OS and architecture + let os = detect_os(); + let arch = detect_arch(); + + // Get latest release version + let latest_version = match get_latest_version().await { + Ok(v) => v, + Err(e) => { + eprintln!("{} {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + // Normalize: strip leading 'v' from GitHub tag for comparison + let latest_clean = latest_version.trim_start_matches('v'); + + // Check if already up to date + if latest_clean == current_version { + println!( + "{} Already up to date ({current_version})", + "[INFO]".green() + ); + return Ok(0); + } + + println!("{} Latest version: {latest_version}", "[INFO]".cyan()); + println!( + "{} Release notes: https://github.com/{REPO}/releases/tag/{latest_version}", + "[INFO]".dimmed() + ); + + if check_only { + return Ok(0); + } + + // Confirm (unless --yes) + if !yes { + print!("? Update to {latest_version}? [y/N] "); + io::stdout() + .flush() + .map_err(|e| anyhow::anyhow!("stdout flush: {e}"))?; + let mut input = String::new(); + io::stdin() + .lock() + .read_line(&mut input) + .map_err(|e| anyhow::anyhow!("stdin read: {e}"))?; + let input = input.trim().to_lowercase(); + if input != "y" && input != "yes" { + println!("{} Update cancelled.", "[INFO]".dimmed()); + return Ok(0); + } + } + + // Build target and download + let target = match get_target(&os, &arch) { + Ok(t) => t, + Err(e) => { + eprintln!("{} {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + let archive_name = format!("{BINARY_NAME}-{target}-{latest_version}.tar.gz"); + let download_url = + format!("https://github.com/{REPO}/releases/download/{latest_version}/{archive_name}"); + + println!("{} Downloading {archive_name} ...", "[INFO]".green()); + + let temp_dir = std::env::temp_dir().join(format!("cora-update-{latest_version}")); + fs::create_dir_all(&temp_dir).map_err(|e| anyhow::anyhow!("Failed to create temp dir: {e}"))?; + let archive_path = temp_dir.join(&archive_name); + + // Download using a blocking tokio runtime (reqwest is async-only) + let archive_bytes = match download_async(&download_url).await { + Ok(b) => b, + Err(e) => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Download failed: {e}", "[ERROR]".red()); + return Ok(1); + } + }; + + fs::write(&archive_path, &archive_bytes) + .map_err(|e| anyhow::anyhow!("Failed to write archive: {e}"))?; + + // Verify checksum + let checksums_url = format!( + "https://github.com/{REPO}/releases/download/{latest_version}/checksums-sha256.txt" + ); + + println!("{} Verifying checksum ...", "[INFO]".green()); + + let skip_checksum = std::env::var("CORA_UPGRADE_SKIP_CHECKSUM") + .map(|v| v == "1" || v == "true") + .unwrap_or(false); + + if skip_checksum { + println!( + "{} Checksum verification skipped (CORA_UPGRADE_SKIP_CHECKSUM=1)", + "[WARN]".yellow() + ); + } else { + let checksums_text = match download_async(&checksums_url).await { + Ok(b) => String::from_utf8_lossy(&b).to_string(), + Err(e) => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Failed to download checksums: {e}", "[ERROR]".red()); + eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to skip."); + return Ok(1); + } + }; + + let expected = match parse_checksum(&checksums_text, &archive_name) { + Some(h) => h, + None => { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Checksum for '{archive_name}' not found in checksums file.", + "[ERROR]".red() + ); + eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to bypass."); + return Ok(1); + } + }; + + let actual = sha256_file(&archive_path)?; + if actual != expected { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Checksum mismatch! Expected: {expected}, got: {actual}", + "[ERROR]".red() + ); + return Ok(1); + } + println!("{} Checksum verified: {actual}", "[INFO]".green()); + } + + // Verify archive integrity (path traversal check) + let file = fs::File::open(&archive_path) + .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + for entry in archive + .entries() + .map_err(|e| anyhow::anyhow!("Failed to read archive entries: {e}"))? + { + let entry = entry.map_err(|e| anyhow::anyhow!("Failed to read archive entry: {e}"))?; + let path = entry + .path() + .map_err(|e| anyhow::anyhow!("Archive path error: {e}"))?; + let path_str = path.to_string_lossy(); + if path_str.starts_with('/') || path_str.contains("..") { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Archive contains unsafe paths — refusing to extract", + "[ERROR]".red() + ); + return Ok(1); + } + } + drop(archive); + + // Extract + println!("{} Extracting ...", "[INFO]".green()); + let file = fs::File::open(&archive_path) + .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + archive + .unpack(&temp_dir) + .map_err(|e| anyhow::anyhow!("Failed to extract archive: {e}"))?; + + // Find and replace binary + let extracted_binary = temp_dir.join(BINARY_NAME); + if !extracted_binary.exists() { + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} Binary '{BINARY_NAME}' not found in archive", + "[ERROR]".red() + ); + return Ok(1); + } + + let install_dir = current_exe + .parent() + .ok_or_else(|| anyhow::anyhow!("Cannot determine install directory"))?; + + // Copy to temp file first, then rename (atomic on POSIX) + let temp_new = install_dir.join(format!("{BINARY_NAME}.new")); + fs::copy(&extracted_binary, &temp_new) + .map_err(|e| anyhow::anyhow!("Failed to copy new binary: {e}"))?; + + // Verify the new binary runs + match std::process::Command::new(&temp_new) + .arg("--version") + .output() + { + Ok(output) if output.status.success() => { + let new_version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let extracted_version = new_version.split_whitespace().nth(1).unwrap_or("unknown"); + println!( + "{} Verified new binary: {extracted_version}", + "[INFO]".green() + ); + } + Ok(output) => { + let _ = fs::remove_file(&temp_new); + let _ = fs::remove_dir_all(&temp_dir); + eprintln!( + "{} New binary failed to run: {}", + "[ERROR]".red(), + String::from_utf8_lossy(&output.stderr) + ); + return Ok(1); + } + Err(e) => { + let _ = fs::remove_file(&temp_new); + let _ = fs::remove_dir_all(&temp_dir); + eprintln!("{} Failed to verify new binary: {e}", "[ERROR]".red()); + return Ok(1); + } + } + + // Atomic rename + fs::rename(&temp_new, ¤t_exe) + .map_err(|e| anyhow::anyhow!("Failed to replace binary: {e}"))?; + + // Cleanup + let _ = fs::remove_dir_all(&temp_dir); + + println!( + "{} Update complete. ({current_version} → {latest_version})", + "[INFO]".green().bold() + ); + + Ok(0) +} + +/// Download a URL using the current tokio runtime. +/// +/// reqwest in cora-code is async-only (no `blocking` feature). +/// This must be called from within a tokio runtime context. +async fn download_async(url: &str) -> anyhow::Result> { + let resp = reqwest::get(url) + .await + .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {status}: {body}"); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| anyhow::anyhow!("Failed to read response body: {e}"))?; + Ok(bytes.to_vec()) +} + +fn detect_os() -> String { + match std::env::consts::OS { + "linux" => "linux".to_string(), + "macos" => "darwin".to_string(), + os => os.to_string(), + } +} + +fn detect_arch() -> String { + match std::env::consts::ARCH { + "x86_64" => "x86_64".to_string(), + "aarch64" => "aarch64".to_string(), + arch => arch.to_string(), + } +} + +fn get_target(os: &str, arch: &str) -> Result { + match (os, arch) { + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".into()), + ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu".into()), + ("darwin", "aarch64") => Ok("aarch64-apple-darwin".into()), + ("darwin", "x86_64") => Ok("x86_64-apple-darwin".into()), + _ => Err(format!("Unsupported platform: {os} {arch}")), + } +} + +/// Get latest release tag from GitHub. +/// +/// Primary: parse 302 redirect (no API call, no rate limit). +/// Fallback: GitHub REST API. +async fn get_latest_version() -> Result { + let client = reqwest::Client::new(); + + // Primary: HEAD request, parse Location header redirect + let resp = client + .head(format!("https://github.com/{REPO}/releases/latest")) + .send() + .await + .map_err(|e| format!("Failed to check latest release: {e}"))?; + + if let Some(location) = resp.headers().get("location") { + let loc = location.to_str().unwrap_or_default(); + // Redirect URL: https://github.com/codecoradev/cora-code/releases/tag/v0.14.0 + if let Some(tag) = loc.rsplit('/').next() { + if tag.starts_with('v') { + return Ok(tag.trim_end_matches('?').to_string()); + } + } + } + + // Fallback: GitHub API + let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest"); + let resp = client + .get(&api_url) + .header("User-Agent", "cora-upgrade") + .send() + .await + .map_err(|e| format!("GitHub API failed: {e}"))?; + + if resp.status().is_success() { + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse GitHub API response: {e}"))?; + if let Some(tag) = json["tag_name"].as_str() { + return Ok(tag.to_string()); + } + } + + Err(format!( + "Failed to determine latest version. Check https://github.com/{REPO}/releases" + )) +} + +fn parse_checksum(checksums_text: &str, archive_name: &str) -> Option { + for line in checksums_text.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[1].contains(archive_name) { + return Some(parts[0].to_string()); + } + } + None +} + +fn sha256_file(path: &PathBuf) -> anyhow::Result { + let mut hasher = Sha256::new(); + let mut file = fs::File::open(path).map_err(|e| anyhow::anyhow!("Failed to open file: {e}"))?; + io::copy(&mut file, &mut hasher).map_err(|e| anyhow::anyhow!("Failed to read file: {e}"))?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 624f2b4..8add627 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -59,6 +59,19 @@ pub struct Config { /// Brain Mode configuration — embedding backend selection. #[serde(default, skip_serializing_if = "is_default")] pub brain: BrainConfig, + /// Enable background update check on startup (default: true). + #[serde(default = "default_update_check", skip_serializing_if = "is_true")] + pub update_check: bool, +} + +/// Default value for `update_check` config field. +fn default_update_check() -> bool { + true +} + +/// serde helper: skip serializing when value is `true` (the default). +fn is_true(b: &bool) -> bool { + *b } /// Provider configuration. @@ -145,6 +158,7 @@ impl Default for Config { profile: None, analysis: AnalysisConfig::default(), brain: BrainConfig::default(), + update_check: true, } } } diff --git a/src/data_dir.rs b/src/data_dir.rs index e22f8c7..9f802e7 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -61,6 +61,13 @@ pub fn graph_db_path() -> PathBuf { new_db } +/// Path to the update check cache file. +/// +/// Stored at `~/.codecora/cora-code/update-cache.json`. +pub fn update_cache_path() -> PathBuf { + cora_data_dir().join("update-cache.json") +} + /// Ensure the cora-code data directory exists. pub fn ensure_data_dir() -> anyhow::Result { let dir = cora_data_dir(); diff --git a/src/main.rs b/src/main.rs index 74c2bc2..507da76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -545,6 +545,17 @@ enum Command { /// Start MCP server with auto-reindex on startup Serve, + + /// Check for updates and self-upgrade the cora binary + Upgrade { + /// Skip confirmation prompt (useful for CI/automation) + #[clap(long)] + yes: bool, + + /// Only check if an update is available, do not download + #[clap(long)] + check: bool, + }, } #[derive(Subcommand, Debug)] @@ -662,6 +673,16 @@ async fn main() -> Result<()> { colored::control::set_override(false); } + // Background update check (non-blocking, cached 24h). + // Skip for `upgrade` command itself and when `--no-update-check` is set. + let skip_update_check = matches!(cli.command, Command::Upgrade { .. }) + || std::env::var("CORA_NO_UPDATE_CHECK") + .map(|v| v == "1" || v == "true") + .unwrap_or(false); + if !skip_update_check { + commands::update_check::spawn_background_check(true); + } + // Dispatch based on subcommand let exit_code = match cli.command { Command::Index { @@ -1637,6 +1658,7 @@ async fn main() -> Result<()> { commands::serve::execute_serve()?; 0 } + Command::Upgrade { yes, check } => commands::upgrade::run(yes, check).await?, }; std::process::exit(exit_code);