feat(cli): add self-update mechanism (MR-612)

- New `omnigraph update` subcommand: GitHub Releases API → archive download
  → SHA256 verification → atomic POSIX rename of both binaries.
- Detects Homebrew installs and short-circuits with a hint to run
  `brew upgrade ModernRelay/tap/omnigraph`.
- Channels: `--channel stable` (default) or `edge`. `--check` for
  check-only, `--yes` to skip the confirmation prompt.
- Best-effort startup version check (24h cached at
  `~/.cache/omnigraph/update-check.json`) with one-line stderr notice.
  Cache is refreshed via a detached `__refresh-update-cache` subprocess so
  the foreground command never blocks on the network. Suppression: CI,
  `OMNIGRAPH_NO_UPDATE_CHECK=1`, non-TTY stdout, and `version`/`update`
  subcommands.
- Integration tests use an in-process raw-TCP HTTP fixture (no extra
  dev-deps) and override `OMNIGRAPH_UPDATE_API_BASE` /
  `OMNIGRAPH_UPDATE_DOWNLOAD_BASE` to keep the suite hermetic.
- Docs: `install.md`, `cli.md`, `cli-reference.md` updated.

Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
This commit is contained in:
Devin AI 2026-05-10 20:45:55 +00:00
parent d6d2763609
commit fa27c7d318
10 changed files with 1340 additions and 0 deletions

34
Cargo.lock generated
View file

@ -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"

View file

@ -70,6 +70,8 @@ url = "2"
cedar-policy = "4.9"
sha2 = "0.10"
subtle = "2"
flate2 = "1"
tar = "0.4"
[profile.dev]
debug = 0

View file

@ -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"

View file

@ -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 {

View file

@ -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(&current_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<PathBuf> {
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<String> {
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> {
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<ReleaseInfo> {
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<String> {
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<ReleaseInfo> {
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::<ReleaseInfo>(&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<String> {
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<Vec<PathBuf>> {
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());
}
}
}
}

View file

@ -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<CacheEntry> {
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<PathBuf> {
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<PathBuf> {
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");
}
}
}

View file

@ -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<u8>, 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<Mutex<HashMap<String, (u16, &'static str, Vec<u8>)>>>;
struct Fixture {
addr: String,
_shutdown: TcpListener, // dropped at end of test, ignored
routes: Routes,
_join: Option<thread::JoinHandle<()>>,
}
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<u8>) {
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<u8> {
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}"
);
}

View file

@ -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

View file

@ -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).

View file

@ -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-<platform>.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`).