Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 49 additions & 27 deletions crates/fff-mcp/src/update_check.rs
Original file line number Diff line number Diff line change
@@ -1,63 +1,51 @@
//! Background update checker — compares the embedded build hash against
//! the latest GitHub release tag to surface upgrade notices in MCP instructions.

use std::sync::OnceLock;

const REPO: &str = "dmtrKovalenko/fff.nvim";
const BUILD_HASH: &str = env!("FFF_GIT_HASH");
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Holds the result of the update check (empty string = up to date or check failed).
static UPDATE_NOTICE: OnceLock<String> = OnceLock::new();

/// Returns the update notice if the check has completed, empty string otherwise.
pub fn get_update_notice() -> &'static str {
UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("")
}

/// Kick off the update check in a background thread so it never blocks the server.
pub fn spawn_update_check() {
std::thread::spawn(|| {
let notice = check_latest_release();
let _ = UPDATE_NOTICE.set(notice);
});
}

/// Fetch the latest release tag from GitHub and compare against the build hash.
fn check_latest_release() -> String {
match fetch_latest_tag() {
Ok(tag) => compare_versions(BUILD_HASH, &tag),
match fetch_latest_stable_tag() {
Ok(tag) => compare_versions(CURRENT_VERSION, &tag),
Err(_) => String::new(),
}
}

/// Compare a build hash against a release tag.
/// Returns an update notice string, or empty if up-to-date.
fn compare_versions(build_hash: &str, release_tag: &str) -> String {
fn compare_versions(current_version: &str, release_tag: &str) -> String {
let tag = release_tag.trim();
if tag.is_empty() || build_hash == "unknown" {
return String::new();
}

let our_short = &build_hash[..build_hash.len().min(tag.len())];
if our_short == tag {
let tag_version = tag.strip_prefix('v').unwrap_or(tag);
if tag.is_empty() || tag_version == current_version {
return String::new();
}

format!(
"\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
"\n[fff update available ({current_version} -> {tag_version}): `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n"
)
}

/// Shell out to curl to fetch the latest release tag name from GitHub API.
fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
// Uses /releases/latest — GitHub excludes prereleases here, matching the
// stable channel that install-mcp.sh installs from.
fn fetch_latest_stable_tag() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("curl")
.args([
"-fsSL",
"--max-time",
"5",
"-H",
"Accept: application/vnd.github.v3+json",
&format!("https://api.github.com/repos/{REPO}/releases?per_page=1"),
&format!("https://api.github.com/repos/{REPO}/releases/latest"),
])
.output()?;

Expand All @@ -66,13 +54,47 @@ fn fetch_latest_tag() -> Result<String, Box<dyn std::error::Error>> {
}

let body = String::from_utf8(output.stdout)?;
let releases: Vec<serde_json::Value> = serde_json::from_str(&body)?;
let tag = releases
.first()
.and_then(|r| r.get("tag_name"))
let release: serde_json::Value = serde_json::from_str(&body)?;
let tag = release
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();

Ok(tag)
}

#[cfg(test)]
mod tests {
use super::compare_versions;

#[test]
fn same_version_with_v_prefix_is_silent() {
assert_eq!(compare_versions("0.10.1", "v0.10.1"), "");
}

#[test]
fn same_version_without_v_prefix_is_silent() {
assert_eq!(compare_versions("0.10.1", "0.10.1"), "");
}

#[test]
fn empty_tag_is_silent() {
assert_eq!(compare_versions("0.10.1", ""), "");
assert_eq!(compare_versions("0.10.1", " "), "");
}

#[test]
fn older_current_reports_update() {
let notice = compare_versions("0.10.0", "v0.10.1");
assert!(notice.contains("0.10.0 -> 0.10.1"), "got: {notice}");
assert!(notice.contains("install-mcp.sh"));
}

#[test]
fn nightly_tag_never_equals_stable_current() {
let notice = compare_versions("0.10.1", "0.10.2-nightly.6a239e9");
assert!(!notice.is_empty());
assert!(notice.contains("0.10.1 -> 0.10.2-nightly.6a239e9"));
}
}
10 changes: 8 additions & 2 deletions packages/fff-node/test/watch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,14 @@ describe("fff-node watch", { concurrency: 1 }, () => {
const created = FileFinder.create({ basePath: dir });
if (!created.ok) throw new Error(created.error);
const finder = created.value;
await finder.waitForScan(10_000);
const sub = finder.watch("**/*.txt", () => {});
const wait = await finder.waitForScan(10_000);
if (!wait.ok || !wait.value) throw new Error("waitForScan failed");
const deadline = Date.now() + 2_000;
let sub = finder.watch("**/*.txt", () => {});
while (!sub.ok && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
sub = finder.watch("**/*.txt", () => {});
}
if (!sub.ok) throw new Error(sub.error);
await new Promise((r) => setTimeout(r, 300));
sub.value();
Expand Down
Loading