mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +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
34
Cargo.lock
generated
34
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ url = "2"
|
|||
cedar-policy = "4.9"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
469
crates/omnigraph-cli/tests/update.rs
Normal file
469
crates/omnigraph-cli/tests/update.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
docs/cli.md
17
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).
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue