mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
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:
parent
d6d2763609
commit
fa27c7d318
10 changed files with 1340 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
486
crates/omnigraph-cli/src/update.rs
Normal file
486
crates/omnigraph-cli/src/update.rs
Normal 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(¤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<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
275
crates/omnigraph-cli/src/version_check.rs
Normal file
275
crates/omnigraph-cli/src/version_check.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue