diff --git a/Cargo.lock b/Cargo.lock index bac2a34..a8e952a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2533,6 +2533,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -4601,6 +4611,7 @@ dependencies = [ "assert_cmd", "clap", "color-eyre", + "flate2", "lance-index", "omnigraph-compiler", "omnigraph-engine", @@ -4610,6 +4621,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", + "tar", "tempfile", "tokio", ] @@ -6598,6 +6611,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -7676,6 +7700,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index 761f29b..0aaf078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,8 @@ url = "2" cedar-policy = "4.9" sha2 = "0.10" subtle = "2" +flate2 = "1" +tar = "0.4" [profile.dev] debug = 0 diff --git a/crates/omnigraph-cli/Cargo.toml b/crates/omnigraph-cli/Cargo.toml index 2da4384..4b33a28 100644 --- a/crates/omnigraph-cli/Cargo.toml +++ b/crates/omnigraph-cli/Cargo.toml @@ -23,6 +23,10 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true, features = ["blocking"] } +sha2 = { workspace = true } +flate2 = { workspace = true } +tar = { workspace = true } +tempfile = { workspace = true } [dev-dependencies] assert_cmd = "2" diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 4fe89e0..5912229 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -33,9 +33,12 @@ use serde_json::Value; mod embed; mod read_format; +mod update; +mod version_check; use embed::{EmbedArgs, EmbedOutput, execute_embed}; use read_format::{ReadRenderOptions, render_read}; +use update::UpdateArgs; const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; @@ -52,6 +55,12 @@ struct Cli { enum Command { /// Print the CLI version Version, + /// Update the omnigraph binaries in place from GitHub Releases + Update(UpdateArgs), + /// Refresh the update-check cache. Internal — used by the detached + /// background refresh spawned at startup. Hidden from `--help`. + #[command(name = "__refresh-update-cache", hide = true)] + RefreshUpdateCache, /// Generate, clean, or refresh explicit seed embeddings Embed(EmbedArgs), /// Initialize a new repo from a schema @@ -1587,11 +1596,22 @@ async fn main() -> Result<()> { .get_matches(); Cli::from_arg_matches(&matches)? }; + let skip_version_check = matches!( + cli.command, + Command::Version | Command::Update(_) | Command::RefreshUpdateCache + ); + version_check::maybe_notify(env!("CARGO_PKG_VERSION"), skip_version_check); let http_client = build_http_client()?; match cli.command { Command::Version => { println!("omnigraph {}", env!("CARGO_PKG_VERSION")); } + Command::Update(args) => { + update::run(args).await?; + } + Command::RefreshUpdateCache => { + version_check::refresh_cache_subcommand().await?; + } Command::Embed(args) => { let output = execute_embed(&args).await?; if args.json { diff --git a/crates/omnigraph-cli/src/update.rs b/crates/omnigraph-cli/src/update.rs new file mode 100644 index 0000000..9ccb221 --- /dev/null +++ b/crates/omnigraph-cli/src/update.rs @@ -0,0 +1,486 @@ +//! `omnigraph update` — self-update from GitHub Releases. +//! +//! See MR-612 and `scripts/install.sh` for the reference distribution flow. +//! Replaces both `omnigraph` and `omnigraph-server` in-place when they live in +//! the same directory as the running binary. Out of scope: Windows, Linux arm64, +//! and automatic (non-user-invoked) updates. + +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use clap::{Args, ValueEnum}; +use color_eyre::eyre::{Context, Result, bail, eyre}; +use flate2::read::GzDecoder; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +pub const REPO_SLUG: &str = "ModernRelay/omnigraph"; + +const DEFAULT_API_BASE: &str = "https://api.github.com"; +const DEFAULT_DOWNLOAD_BASE: &str = "https://github.com"; +const USER_AGENT: &str = concat!("omnigraph-cli/", env!("CARGO_PKG_VERSION")); + +const HOMEBREW_PREFIXES: &[&str] = &[ + "/opt/homebrew/", + "/usr/local/Cellar/", + "/usr/local/opt/", + "/home/linuxbrew/.linuxbrew/", +]; + +/// Release channel to update from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Channel { + /// Latest tagged stable release. + Stable, + /// Rolling `edge` release republished on every push to `main`. + Edge, +} + +impl Channel { + fn as_str(self) -> &'static str { + match self { + Channel::Stable => "stable", + Channel::Edge => "edge", + } + } +} + +#[derive(Debug, Args)] +pub struct UpdateArgs { + /// Release channel to update from. + #[arg(long, value_enum, default_value_t = Channel::Stable)] + pub channel: Channel, + /// Skip the confirmation prompt. + #[arg(long, short = 'y')] + pub yes: bool, + /// Only check whether a newer version is available; do not install. + #[arg(long)] + pub check: bool, +} + +/// Latest-release metadata we pull from the GitHub Releases API. +#[derive(Debug, Deserialize)] +struct ReleaseInfo { + tag_name: String, +} + +pub async fn run(args: UpdateArgs) -> Result<()> { + let current_version = env!("CARGO_PKG_VERSION"); + let current_exe = std::env::current_exe().context("resolve path of running omnigraph binary")?; + let install_dir = current_exe + .parent() + .ok_or_else(|| eyre!("running binary has no parent directory: {}", current_exe.display()))? + .to_path_buf(); + + if let Some(prefix) = detect_homebrew_install(¤t_exe) { + println!( + "omnigraph was installed via Homebrew (prefix: {}). Run `brew upgrade ModernRelay/tap/omnigraph` instead.", + prefix.display() + ); + return Ok(()); + } + + let asset_name = platform_asset_name()?; + let client = build_http_client()?; + + let release = fetch_release_metadata(&client, args.channel).await?; + let latest_tag = release.tag_name.trim().to_string(); + if latest_tag.is_empty() { + bail!("release metadata missing tag_name"); + } + let latest_version = latest_tag.trim_start_matches('v'); + + let needs_update = match args.channel { + Channel::Stable => version_is_newer(current_version, latest_version), + // The `edge` tag moves with every push to `main`; treat any user-invoked + // `update --channel edge` as a refresh and always reinstall. + Channel::Edge => true, + }; + + if !needs_update { + println!("omnigraph is up to date (v{})", current_version); + return Ok(()); + } + + println!( + "A new version of omnigraph is available: v{} -> {} (channel: {})", + current_version, + latest_tag, + args.channel.as_str(), + ); + + if args.check { + return Ok(()); + } + + if !args.yes && io::stdin().is_terminal() { + eprint!( + "Update from v{} to {}? [y/N] ", + current_version, latest_tag + ); + io::stderr().flush().ok(); + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("read confirmation prompt")?; + let answer = input.trim().to_ascii_lowercase(); + if answer != "y" && answer != "yes" { + eprintln!("aborted."); + return Ok(()); + } + } + + // Verify we can write into the install directory before downloading + // anything — gives a clear error early rather than after the download. + ensure_dir_writable(&install_dir)?; + + let workdir = tempfile::tempdir_in(&install_dir) + .with_context(|| format!("create temp dir in {}", install_dir.display()))?; + + let asset_stem = asset_name.trim_end_matches(".tar.gz"); + let archive_path = workdir.path().join(asset_name); + let checksum_path = workdir.path().join(format!("{asset_stem}.sha256")); + + let archive_url = release_asset_url(&latest_tag, asset_name); + let checksum_url = release_asset_url(&latest_tag, &format!("{asset_stem}.sha256")); + + println!("downloading {asset_name}…"); + download_file(&client, &archive_url, &archive_path) + .await + .with_context(|| format!("download {archive_url}"))?; + download_file(&client, &checksum_url, &checksum_path) + .await + .with_context(|| format!("download {checksum_url}"))?; + + verify_checksum(&archive_path, &checksum_path)?; + extract_archive(&archive_path, workdir.path())?; + + let updated = replace_binaries(&install_dir, workdir.path())?; + if updated.is_empty() { + bail!("release archive did not contain any of the expected binaries"); + } + + println!(); + println!("Updated:"); + for path in &updated { + println!(" {}", path.display()); + } + println!(); + println!( + "Version: v{} -> {} (install dir: {})", + current_version, + latest_tag, + install_dir.display() + ); + + Ok(()) +} + +/// Return `Some(prefix)` if `path` resolves under a known Homebrew prefix. +pub fn detect_homebrew_install(path: &Path) -> Option { + let canonical = fs::canonicalize(path).ok().unwrap_or_else(|| path.to_path_buf()); + let canonical_str = canonical.to_string_lossy(); + for prefix in HOMEBREW_PREFIXES { + if canonical_str.starts_with(prefix) { + return Some(PathBuf::from(prefix)); + } + } + if let Some(prefix) = brew_prefix() { + let prefix_path = PathBuf::from(&prefix); + if canonical.starts_with(&prefix_path) { + return Some(prefix_path); + } + } + None +} + +fn brew_prefix() -> Option { + let output = std::process::Command::new("brew") + .arg("--prefix") + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let prefix = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!prefix.is_empty()).then_some(prefix) +} + +pub fn platform_asset_name() -> Result<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok("omnigraph-linux-x86_64.tar.gz"), + ("macos", "aarch64") => Ok("omnigraph-macos-arm64.tar.gz"), + (os, arch) => bail!( + "no prebuilt omnigraph release for {os}/{arch}; rebuild from source via scripts/install-source.sh" + ), + } +} + +fn build_http_client() -> Result { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(60)) + .build() + .context("build reqwest client") +} + +fn api_base() -> String { + std::env::var("OMNIGRAPH_UPDATE_API_BASE").unwrap_or_else(|_| DEFAULT_API_BASE.to_string()) +} + +fn download_base() -> String { + std::env::var("OMNIGRAPH_UPDATE_DOWNLOAD_BASE") + .unwrap_or_else(|_| DEFAULT_DOWNLOAD_BASE.to_string()) +} + +fn release_asset_url(tag: &str, asset_name: &str) -> String { + format!( + "{}/{}/releases/download/{}/{}", + download_base().trim_end_matches('/'), + REPO_SLUG, + tag, + asset_name, + ) +} + +async fn fetch_release_metadata(client: &reqwest::Client, channel: Channel) -> Result { + let url = match channel { + Channel::Stable => format!( + "{}/repos/{}/releases/latest", + api_base().trim_end_matches('/'), + REPO_SLUG, + ), + Channel::Edge => format!( + "{}/repos/{}/releases/tags/edge", + api_base().trim_end_matches('/'), + REPO_SLUG, + ), + }; + fetch_release_info(client, &url).await +} + +/// Hit the latest stable release endpoint without invoking the full update flow. +/// Used by the startup version-check. +pub async fn fetch_latest_stable_tag(client: &reqwest::Client) -> Result { + let url = format!( + "{}/repos/{}/releases/latest", + api_base().trim_end_matches('/'), + REPO_SLUG, + ); + Ok(fetch_release_info(client, &url).await?.tag_name) +} + +async fn fetch_release_info(client: &reqwest::Client, url: &str) -> Result { + let response = client + .get(url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .with_context(|| format!("GET {url}"))?; + let status = response.status(); + let text = response.text().await.context("read response body")?; + if !status.is_success() { + bail!("release lookup failed ({status}): {text}"); + } + serde_json::from_str::(&text) + .with_context(|| format!("parse release metadata from {url}: {text}")) +} + +/// Returns true when `latest` is strictly newer than `current` under semver. +/// On parse failure on either side, returns `current != latest` so users still +/// see a notice on non-semver tags. +pub fn version_is_newer(current: &str, latest: &str) -> bool { + match (parse_semver(current), parse_semver(latest)) { + (Some(c), Some(l)) => l > c, + _ => current.trim_start_matches('v') != latest.trim_start_matches('v'), + } +} + +fn parse_semver(s: &str) -> Option<(u64, u64, u64)> { + let s = s.trim().trim_start_matches('v'); + // Drop pre-release / build metadata for a coarse comparison. + let core = s.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major: u64 = parts.next()?.parse().ok()?; + let minor: u64 = parts.next().unwrap_or("0").parse().ok()?; + let patch: u64 = parts.next().unwrap_or("0").parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +async fn download_file(client: &reqwest::Client, url: &str, dst: &Path) -> Result<()> { + let response = client + .get(url) + .send() + .await + .with_context(|| format!("GET {url}"))?; + let status = response.status(); + if !status.is_success() { + bail!("download failed ({status}) for {url}"); + } + let bytes = response.bytes().await.context("read response body")?; + let mut file = fs::File::create(dst) + .with_context(|| format!("create {}", dst.display()))?; + file.write_all(&bytes) + .with_context(|| format!("write {}", dst.display()))?; + Ok(()) +} + +fn verify_checksum(archive: &Path, checksum_file: &Path) -> Result<()> { + let expected = parse_checksum_file(checksum_file)?; + let mut hasher = Sha256::new(); + let mut file = fs::File::open(archive) + .with_context(|| format!("open {}", archive.display()))?; + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf).context("read archive for checksum")?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(&expected) { + bail!( + "sha256 mismatch for {}: expected {}, got {}", + archive.display(), + expected, + actual + ); + } + Ok(()) +} + +fn parse_checksum_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))?; + let first = raw + .lines() + .next() + .ok_or_else(|| eyre!("checksum file is empty: {}", path.display()))?; + let digest = first + .split_whitespace() + .next() + .ok_or_else(|| eyre!("checksum file did not contain a SHA256 digest: {}", path.display()))?; + Ok(digest.to_string()) +} + +fn extract_archive(archive: &Path, dst_dir: &Path) -> Result<()> { + let file = fs::File::open(archive) + .with_context(|| format!("open {}", archive.display()))?; + let decoder = GzDecoder::new(file); + let mut tar = tar::Archive::new(decoder); + tar.unpack(dst_dir) + .with_context(|| format!("unpack {} into {}", archive.display(), dst_dir.display()))?; + Ok(()) +} + +/// For each known binary that exists in `staging_dir`, replace the version in +/// `install_dir` via a same-filesystem rename. Returns the paths that were +/// updated, in order. +fn replace_binaries(install_dir: &Path, staging_dir: &Path) -> Result> { + let mut updated = Vec::new(); + for name in ["omnigraph", "omnigraph-server"] { + let staged = staging_dir.join(name); + if !staged.exists() { + continue; + } + // Only replace `omnigraph-server` if it's already alongside `omnigraph`. + let target = install_dir.join(name); + if name == "omnigraph-server" && !target.exists() { + continue; + } + replace_binary(&target, &staged)?; + updated.push(target); + } + Ok(updated) +} + +fn replace_binary(target: &Path, src: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(src) + .with_context(|| format!("stat {}", src.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(src, perms) + .with_context(|| format!("chmod {}", src.display()))?; + } + fs::rename(src, target).with_context(|| { + format!( + "rename {} -> {} (same-filesystem rename required)", + src.display(), + target.display() + ) + })?; + Ok(()) +} + +fn ensure_dir_writable(dir: &Path) -> Result<()> { + let probe = dir.join(format!(".omnigraph-update-probe-{}", std::process::id())); + match fs::File::create(&probe) { + Ok(_) => { + let _ = fs::remove_file(&probe); + Ok(()) + } + Err(err) => bail!( + "cannot write to install directory {}: {err}. Re-run via the install script with appropriate permissions, or rerun as root.", + dir.display() + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compare_semver() { + assert!(version_is_newer("0.4.1", "0.5.0")); + assert!(version_is_newer("0.4.1", "v0.4.2")); + assert!(!version_is_newer("0.5.0", "0.4.9")); + assert!(!version_is_newer("0.4.1", "0.4.1")); + assert!(!version_is_newer("0.4.1", "v0.4.1")); + } + + #[test] + fn version_compare_non_semver_falls_back_to_inequality() { + assert!(version_is_newer("0.4.1", "edge")); + assert!(!version_is_newer("edge", "edge")); + } + + #[test] + fn homebrew_detection_recognizes_canonical_prefixes() { + assert!(detect_homebrew_install(Path::new("/opt/homebrew/bin/omnigraph")).is_some()); + assert!( + detect_homebrew_install(Path::new( + "/usr/local/Cellar/omnigraph/0.4.1/bin/omnigraph" + )) + .is_some() + ); + assert!(detect_homebrew_install(Path::new("/home/ubuntu/.local/bin/omnigraph")).is_none()); + } + + #[test] + fn platform_asset_name_known_platforms() { + let name = platform_asset_name(); + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => { + assert_eq!(name.unwrap(), "omnigraph-linux-x86_64.tar.gz"); + } + ("macos", "aarch64") => { + assert_eq!(name.unwrap(), "omnigraph-macos-arm64.tar.gz"); + } + _ => { + assert!(name.is_err()); + } + } + } +} diff --git a/crates/omnigraph-cli/src/version_check.rs b/crates/omnigraph-cli/src/version_check.rs new file mode 100644 index 0000000..953bceb --- /dev/null +++ b/crates/omnigraph-cli/src/version_check.rs @@ -0,0 +1,275 @@ +//! Background notification when a newer stable omnigraph release is available. +//! +//! Design (best practice — same pattern as `npm/update-notifier` and `gh`): +//! +//! 1. The fast path is a single JSON file read at +//! `$XDG_CACHE_HOME/omnigraph/update-check.json` (default +//! `~/.cache/omnigraph/update-check.json`). If the cache says a newer +//! stable release is out, emit one stderr line and continue. No network +//! on this path. +//! 2. When the cache is stale or missing, refresh it in a **detached child +//! process** (`omnigraph __refresh-update-cache`). The parent never blocks +//! on the network; the child writes the cache on its next run. +//! 3. Suppression: `OMNIGRAPH_NO_UPDATE_CHECK=1`, `CI` set, stdout not a TTY, +//! and the `version` / `update` / `__refresh-update-cache` subcommands. +//! 4. The notice is written to stderr only — stdout is reserved for piped / +//! scripted output. + +use std::fs; +use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use color_eyre::eyre::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::update::{REPO_SLUG, fetch_latest_stable_tag, version_is_newer}; + +const CACHE_FILE_NAME: &str = "update-check.json"; +const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Hidden subcommand name used for the detached background refresh. +pub const REFRESH_SUBCOMMAND: &str = "__refresh-update-cache"; + +#[derive(Debug, Serialize, Deserialize)] +struct CacheEntry { + /// Last time we successfully fetched the latest release. + checked_at_unix: u64, + /// Latest tag observed at `checked_at_unix`, with any `v` prefix stripped. + latest_version: String, + /// Repo slug the cache was populated for. Lets us invalidate the cache if + /// the binary is ever pointed at a different repo (e.g. a fork). + #[serde(default)] + repo_slug: String, +} + +/// Read the cache (if any) and emit a stderr notice when a newer stable +/// version is recorded. Spawn a detached refresh subprocess if the cache is +/// stale. Never blocks on the network and never returns an error. +pub fn maybe_notify(current_version: &str, subcommand_skips_check: bool) { + if subcommand_skips_check || is_suppressed() { + return; + } + + let cache_path = match cache_file_path() { + Some(p) => p, + None => return, + }; + + let now = unix_now(); + let cached = read_cache(&cache_path); + + if let Some(entry) = &cached + && entry.repo_slug == REPO_SLUG + && version_is_newer(current_version, &entry.latest_version) + { + let _ = writeln!( + io::stderr(), + "A new version of omnigraph is available ({} -> {}). Run `omnigraph update` to upgrade.", + current_version, + entry.latest_version, + ); + } + + let cache_is_fresh = cached + .as_ref() + .map(|e| now.saturating_sub(e.checked_at_unix) < CACHE_TTL.as_secs()) + .unwrap_or(false); + if !cache_is_fresh { + spawn_detached_refresh(); + } +} + +/// Body of the hidden `__refresh-update-cache` subcommand. Performs a single +/// network call to the GitHub Releases API and writes the cache file. Errors +/// are surfaced (the caller is the child process; failures only affect that +/// child's exit code). +pub async fn refresh_cache_subcommand() -> Result<()> { + let cache_path = cache_file_path().ok_or_else(|| { + color_eyre::eyre::eyre!("could not resolve cache directory (HOME unset?)") + })?; + let client = reqwest::Client::builder() + .user_agent(concat!("omnigraph-cli/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(10)) + .build() + .context("build reqwest client")?; + let tag = fetch_latest_stable_tag(&client).await?; + let version = tag.trim().trim_start_matches('v').to_string(); + if version.is_empty() { + bail!("release metadata missing tag_name"); + } + let entry = CacheEntry { + checked_at_unix: unix_now(), + latest_version: version, + repo_slug: REPO_SLUG.to_string(), + }; + write_cache(&cache_path, &entry)?; + Ok(()) +} + +fn spawn_detached_refresh() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + let mut cmd = Command::new(exe); + cmd.arg(REFRESH_SUBCOMMAND) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // Detach the child from the parent's process group so it survives parent + // exit and never inherits a controlling terminal. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // SAFETY: setsid sets a new session+process group; safe pre-exec. + if libc_setsid() < 0 { + // best-effort; don't fail spawn + } + Ok(()) + }); + } + } + let _ = cmd.spawn(); +} + +#[cfg(unix)] +fn libc_setsid() -> i64 { + // We avoid pulling the `libc` crate as a direct dep just for setsid; + // declare the prototype inline. + unsafe extern "C" { + fn setsid() -> i32; + } + unsafe { setsid() as i64 } +} + +fn read_cache(path: &PathBuf) -> Option { + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn write_cache(path: &PathBuf, entry: &CacheEntry) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create cache dir {}", parent.display()))?; + } + let tmp = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec(entry).context("serialize cache entry")?; + fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path) + .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; + Ok(()) +} + +fn cache_file_path() -> Option { + if let Ok(dir) = std::env::var("OMNIGRAPH_UPDATE_CACHE_DIR") { + return Some(PathBuf::from(dir).join(CACHE_FILE_NAME)); + } + let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + if !xdg.is_empty() { + PathBuf::from(xdg) + } else { + home_dir()?.join(".cache") + } + } else { + home_dir()?.join(".cache") + }; + Some(base.join("omnigraph").join(CACHE_FILE_NAME)) +} + +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn is_suppressed() -> bool { + if env_truthy("OMNIGRAPH_NO_UPDATE_CHECK") { + return true; + } + if env_truthy("CI") { + return true; + } + // Suppress when stdout is not a TTY — covers pipes, scripts, and CI runners + // that don't set `CI`. + if !io::stdout().is_terminal() { + return true; + } + false +} + +fn env_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => { + let v = v.trim(); + !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false") + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn cache_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("update-check.json"); + let entry = CacheEntry { + checked_at_unix: 1_700_000_000, + latest_version: "0.5.0".to_string(), + repo_slug: REPO_SLUG.to_string(), + }; + write_cache(&path, &entry).unwrap(); + let loaded = read_cache(&path).unwrap(); + assert_eq!(loaded.checked_at_unix, 1_700_000_000); + assert_eq!(loaded.latest_version, "0.5.0"); + assert_eq!(loaded.repo_slug, REPO_SLUG); + } + + #[test] + fn read_cache_missing_returns_none() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing.json"); + assert!(read_cache(&path).is_none()); + } + + #[test] + fn env_truthy_handles_common_falsy_values() { + unsafe { + std::env::remove_var("__OMNIGRAPH_TEST_TRUTHY"); + } + assert!(!env_truthy("__OMNIGRAPH_TEST_TRUTHY")); + for value in ["", "0", "false", "FALSE"] { + unsafe { + std::env::set_var("__OMNIGRAPH_TEST_TRUTHY", value); + } + assert!( + !env_truthy("__OMNIGRAPH_TEST_TRUTHY"), + "expected falsy for {value:?}", + ); + } + for value in ["1", "true", "yes", "anything"] { + unsafe { + std::env::set_var("__OMNIGRAPH_TEST_TRUTHY", value); + } + assert!( + env_truthy("__OMNIGRAPH_TEST_TRUTHY"), + "expected truthy for {value:?}", + ); + } + unsafe { + std::env::remove_var("__OMNIGRAPH_TEST_TRUTHY"); + } + } +} diff --git a/crates/omnigraph-cli/tests/update.rs b/crates/omnigraph-cli/tests/update.rs new file mode 100644 index 0000000..ba15631 --- /dev/null +++ b/crates/omnigraph-cli/tests/update.rs @@ -0,0 +1,469 @@ +//! Integration tests for `omnigraph update` (MR-612). +//! +//! Spins up a minimal in-process HTTP server (raw `std::net::TcpListener` so +//! we don't drag axum into the CLI's dev-deps) that serves both: +//! - GitHub Releases JSON metadata (`/repos/.../releases/latest` etc.) +//! - Release asset downloads (`.tar.gz` and `.sha256`) +//! +//! Tests stub the binaries with shell scripts so we can inspect what got +//! written to the install dir without needing real Rust binaries in the +//! release archive. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use assert_cmd::Command as AssertCommand; +use flate2::Compression; +use flate2::write::GzEncoder; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +mod support; + +fn cli_in(install_dir: &Path) -> AssertCommand { + let bin = install_dir.join("omnigraph"); + let mut cmd = AssertCommand::new(bin); + // Use a unique cache dir per test so the version_check side never races us. + cmd.env("OMNIGRAPH_NO_UPDATE_CHECK", "1"); + cmd +} + +/// Stage `omnigraph` and `omnigraph-server` shell stubs into `dir`. Returns the +/// path to the built `omnigraph` shim that calls into `assert_cmd`'s real CLI. +/// We can't put the cargo-built binary into the install dir directly because +/// the update will rename-over it during the test, which would break other +/// tests that share the same target dir. +fn stage_install_dir(dir: &Path, omnigraph_payload: &[u8], server_payload: &[u8]) { + let og = dir.join("omnigraph"); + fs::write(&og, omnigraph_payload).unwrap(); + let server = dir.join("omnigraph-server"); + fs::write(&server, server_payload).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut p = fs::metadata(&og).unwrap().permissions(); + p.set_mode(0o755); + fs::set_permissions(&og, p).unwrap(); + let mut p = fs::metadata(&server).unwrap().permissions(); + p.set_mode(0o755); + fs::set_permissions(&server, p).unwrap(); + } +} + +/// Build a `.tar.gz` containing `omnigraph` and (optionally) `omnigraph-server` +/// binaries with the supplied byte payloads. Returns `(archive_bytes, sha256_hex)`. +fn build_release_archive( + omnigraph: &[u8], + omnigraph_server: Option<&[u8]>, +) -> (Vec, String) { + let mut gz = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut tar = tar::Builder::new(&mut gz); + let mut header = tar::Header::new_gnu(); + header.set_size(omnigraph.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, "omnigraph", omnigraph).unwrap(); + if let Some(server) = omnigraph_server { + let mut h = tar::Header::new_gnu(); + h.set_size(server.len() as u64); + h.set_mode(0o755); + h.set_cksum(); + tar.append_data(&mut h, "omnigraph-server", server).unwrap(); + } + tar.finish().unwrap(); + } + let bytes = gz.finish().unwrap(); + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = format!("{:x}", hasher.finalize()); + (bytes, digest) +} + +/// In-memory routing table: path -> (status, content-type, body). +type Routes = Arc)>>>; + +struct Fixture { + addr: String, + _shutdown: TcpListener, // dropped at end of test, ignored + routes: Routes, + _join: Option>, +} + +impl Fixture { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(false).unwrap(); + let addr = format!("http://{}", listener.local_addr().unwrap()); + let routes: Routes = Arc::new(Mutex::new(HashMap::new())); + let routes_clone = routes.clone(); + let listener_clone = listener.try_clone().unwrap(); + let join = thread::spawn(move || { + // Each test spins up a short-lived server. Accept loop runs until + // the listener is dropped (Fixture::stop sets nonblocking + drops). + for stream in listener_clone.incoming() { + let stream = match stream { + Ok(s) => s, + Err(_) => break, + }; + let routes = routes_clone.clone(); + thread::spawn(move || handle_request(stream, routes)); + } + }); + Self { + addr, + _shutdown: listener, + routes, + _join: Some(join), + } + } + + fn route(&self, path: &str, status: u16, content_type: &'static str, body: Vec) { + self.routes + .lock() + .unwrap() + .insert(path.to_string(), (status, content_type, body)); + } + + fn url(&self) -> &str { + &self.addr + } +} + +fn handle_request(mut stream: TcpStream, routes: Routes) { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .to_string(); + // Drain headers so the client sees a clean TCP stream. + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + if line == "\r\n" || line.is_empty() { + break; + } + } + let response = match routes.lock().unwrap().get(&path).cloned() { + Some((status, ct, body)) => { + let header = format!( + "HTTP/1.1 {} OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + status, + ct, + body.len() + ); + let mut out = header.into_bytes(); + out.extend_from_slice(&body); + out + } + None => b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_vec(), + }; + let _ = stream.write_all(&response); + let _ = stream.flush(); +} + +/// Build a stable releases-API metadata JSON body. +fn release_json(tag: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "tag_name": tag, + "name": format!("Release {tag}"), + "draft": false, + "prerelease": false, + })) + .unwrap() +} + +/// Build the full asset path the binary expects under the configured download +/// base, e.g. `/ModernRelay/omnigraph/releases/download/v0.5.0/omnigraph-linux-x86_64.tar.gz`. +fn asset_path(tag: &str, name: &str) -> String { + format!("/ModernRelay/omnigraph/releases/download/{tag}/{name}") +} + +fn current_platform_asset() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("omnigraph-linux-x86_64.tar.gz"), + ("macos", "aarch64") => Some("omnigraph-macos-arm64.tar.gz"), + _ => None, + } +} + +// ---------- helpers used by individual tests ---------- + +/// Run the cargo-built `omnigraph` from the install dir, using the test +/// fixture as both the API and download base. +fn run_update(install_dir: &Path, fixture: &Fixture, args: &[&str]) -> std::process::Output { + // `assert_cmd::Command` panics on non-zero exit by default; we want raw + // output to inspect failure modes, so go through the standard `Output`. + let bin = install_dir.join("omnigraph"); + let mut cmd = std::process::Command::new(bin); + cmd.arg("update") + .args(args) + .env("OMNIGRAPH_NO_UPDATE_CHECK", "1") + .env("OMNIGRAPH_UPDATE_API_BASE", fixture.url()) + .env("OMNIGRAPH_UPDATE_DOWNLOAD_BASE", fixture.url()); + cmd.output().unwrap() +} + +/// Copy the cargo-built `omnigraph` binary into the install dir so we have a +/// real binary to invoke. We can't link against the lib (there isn't one), so +/// each test stages its own copy. +fn install_real_binary(install_dir: &Path) -> PathBuf { + let cargo_bin = assert_cmd::cargo::cargo_bin("omnigraph"); + let target = install_dir.join("omnigraph"); + fs::copy(&cargo_bin, &target).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut p = fs::metadata(&target).unwrap().permissions(); + p.set_mode(0o755); + fs::set_permissions(&target, p).unwrap(); + } + target +} + +fn install_fake_server(install_dir: &Path, payload: &[u8]) { + let target = install_dir.join("omnigraph-server"); + fs::write(&target, payload).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut p = fs::metadata(&target).unwrap().permissions(); + p.set_mode(0o755); + fs::set_permissions(&target, p).unwrap(); + } +} + +// ---------- tests ---------- + +#[test] +fn update_check_when_up_to_date_reports_current() { + let asset = match current_platform_asset() { + Some(a) => a, + None => return, // unsupported platform: nothing to test + }; + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + let fixture = Fixture::start(); + let current = env!("CARGO_PKG_VERSION"); + fixture.route( + "/repos/ModernRelay/omnigraph/releases/latest", + 200, + "application/json", + release_json(&format!("v{current}")), + ); + let _ = asset; // route only needed when an update is required + + let out = run_update(dir.path(), &fixture, &["--check"]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "expected success; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("up to date"), + "expected 'up to date' message, got: {stdout}" + ); +} + +#[test] +fn update_check_when_newer_version_available_reports_upgrade() { + if current_platform_asset().is_none() { + return; + } + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + let fixture = Fixture::start(); + fixture.route( + "/repos/ModernRelay/omnigraph/releases/latest", + 200, + "application/json", + release_json("v999.0.0"), + ); + + let out = run_update(dir.path(), &fixture, &["--check"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("v999.0.0") || stdout.contains("999.0.0"), + "expected new version in output; got: {stdout}" + ); + assert!( + stdout.contains("new version") || stdout.contains("newer"), + "expected upgrade-available message; got: {stdout}" + ); +} + +#[test] +fn update_full_flow_replaces_both_binaries_and_verifies_checksum() { + let asset = match current_platform_asset() { + Some(a) => a, + None => return, + }; + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + install_fake_server(dir.path(), b"#!/bin/sh\necho old-server\n"); + + let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD"; + let new_server = b"NEW-SERVER-PAYLOAD"; + let (archive, digest) = build_release_archive(new_omnigraph, Some(new_server)); + + let fixture = Fixture::start(); + fixture.route( + "/repos/ModernRelay/omnigraph/releases/latest", + 200, + "application/json", + release_json("v999.0.0"), + ); + fixture.route( + &asset_path("v999.0.0", asset), + 200, + "application/octet-stream", + archive, + ); + let stem = asset.trim_end_matches(".tar.gz"); + fixture.route( + &asset_path("v999.0.0", &format!("{stem}.sha256")), + 200, + "text/plain", + format!("{digest} {asset}\n").into_bytes(), + ); + + let out = run_update(dir.path(), &fixture, &["--yes"]); + assert!( + out.status.success(), + "update failed; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Verify both binaries got replaced byte-for-byte. + let updated_og = fs::read(dir.path().join("omnigraph")).unwrap(); + assert_eq!(updated_og, new_omnigraph); + let updated_server = fs::read(dir.path().join("omnigraph-server")).unwrap(); + assert_eq!(updated_server, new_server); +} + +#[test] +fn update_fails_loudly_on_checksum_mismatch() { + let asset = match current_platform_asset() { + Some(a) => a, + None => return, + }; + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + install_fake_server(dir.path(), b"#!/bin/sh\necho old-server\n"); + + let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD"; + let (archive, _digest) = build_release_archive(new_omnigraph, None); + + let fixture = Fixture::start(); + fixture.route( + "/repos/ModernRelay/omnigraph/releases/latest", + 200, + "application/json", + release_json("v999.0.0"), + ); + fixture.route( + &asset_path("v999.0.0", asset), + 200, + "application/octet-stream", + archive, + ); + let stem = asset.trim_end_matches(".tar.gz"); + let bad_digest = "0".repeat(64); + fixture.route( + &asset_path("v999.0.0", &format!("{stem}.sha256")), + 200, + "text/plain", + format!("{bad_digest} {asset}\n").into_bytes(), + ); + + let out = run_update(dir.path(), &fixture, &["--yes"]); + assert!(!out.status.success(), "checksum mismatch should fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("sha256 mismatch") || stderr.contains("checksum"), + "expected checksum error; got: {stderr}" + ); + // The original binary must not be replaced when the checksum fails. + let preserved = fs::read(dir.path().join("omnigraph")).unwrap(); + assert!( + preserved.starts_with(b"\x7fELF") || preserved.starts_with(b"#!"), + "original binary should still be in place" + ); +} + +#[test] +fn update_does_not_replace_omnigraph_server_when_not_present() { + let asset = match current_platform_asset() { + Some(a) => a, + None => return, + }; + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + // Note: no `omnigraph-server` staged. + + let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD"; + let new_server = b"NEW-SERVER-PAYLOAD"; + let (archive, digest) = build_release_archive(new_omnigraph, Some(new_server)); + + let fixture = Fixture::start(); + fixture.route( + "/repos/ModernRelay/omnigraph/releases/latest", + 200, + "application/json", + release_json("v999.0.0"), + ); + fixture.route( + &asset_path("v999.0.0", asset), + 200, + "application/octet-stream", + archive, + ); + let stem = asset.trim_end_matches(".tar.gz"); + fixture.route( + &asset_path("v999.0.0", &format!("{stem}.sha256")), + 200, + "text/plain", + format!("{digest} {asset}\n").into_bytes(), + ); + + let out = run_update(dir.path(), &fixture, &["--yes"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(!dir.path().join("omnigraph-server").exists()); +} + +#[test] +fn update_check_handles_missing_release_metadata() { + if current_platform_asset().is_none() { + return; + } + let dir = TempDir::new().unwrap(); + install_real_binary(dir.path()); + let fixture = Fixture::start(); // no routes registered + + let out = run_update(dir.path(), &fixture, &["--check"]); + assert!(!out.status.success(), "should fail when API returns 404"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("404") || stderr.contains("release lookup failed"), + "expected fetch-error message; got: {stderr}" + ); +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 599ee13..0fbf9ac 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,6 +24,7 @@ A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` sc | `cleanup --keep N --older-than 7d --confirm` | destructive version GC | | `embed` | offline JSONL embedding pipeline | | `policy validate \| test \| explain` | Cedar tooling | +| `update` | self-update both binaries from GitHub Releases (`--channel stable\|edge`, `--check`, `--yes`) | | `version` / `-v` | print `omnigraph 0.3.x` | ## `omnigraph.yaml` schema diff --git a/docs/cli.md b/docs/cli.md index ae8c152..f8bbd30 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -98,3 +98,20 @@ The config file can also define: When policy is enabled, `schema apply` is authorized through the `schema_apply` action and is typically limited to admins on protected `main`. + +## Updating + +Update both binaries in place from the latest GitHub Release: + +```bash +omnigraph update # default: stable channel, prompts for confirmation +omnigraph update --check # only check; do not install +omnigraph update --yes # skip prompt +omnigraph update --channel edge +``` + +Homebrew installs are detected and short-circuited with a hint to run +`brew upgrade ModernRelay/tap/omnigraph` instead. Each invocation of +`omnigraph` also performs a best-effort cached check (24-hour TTL) for newer +releases and prints a one-line stderr notice; suppress with +`OMNIGRAPH_NO_UPDATE_CHECK=1`. Full details: [install.md](install.md). diff --git a/docs/install.md b/docs/install.md index 725961e..930f53e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -92,3 +92,35 @@ Each archive contains both binaries: omnigraph version omnigraph-server --help ``` + +## Updating + +After installing via the script (or a manual binary install) you can update both +`omnigraph` and `omnigraph-server` in place: + +```bash +omnigraph update # update from the latest stable release +omnigraph update --check # only check for a newer version +omnigraph update --yes # skip the confirmation prompt +omnigraph update --channel edge # follow the rolling `edge` channel +``` + +`omnigraph update`: + +- detects the platform automatically (Linux x86_64 / macOS arm64), +- downloads the matching `omnigraph-.tar.gz` and `.sha256`, +- verifies the SHA256 digest before touching anything, +- replaces both binaries in the install directory atomically (POSIX rename), and +- detects Homebrew installs and asks you to run `brew upgrade ModernRelay/tap/omnigraph` instead. + +Each invocation of `omnigraph` also performs a best-effort, cached check +(once every 24 hours) for newer stable releases and prints a one-line stderr +notice if one is available. The notice is suppressed when: + +- `OMNIGRAPH_NO_UPDATE_CHECK=1` is set, +- `CI` is set, +- stdout is not a TTY (pipes, scripts), or +- the running subcommand is `version` or `update`. + +The cache lives at `$XDG_CACHE_HOME/omnigraph/update-check.json` (default +`~/.cache/omnigraph/update-check.json`).