diff --git a/.centy/.centy-manifest.json b/.centy/.centy-manifest.json index b193937..4f4d218 100644 --- a/.centy/.centy-manifest.json +++ b/.centy/.centy-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "centyVersion": "0.1.0", + "centyVersion": "0.4.0", "createdAt": "2026-02-05T19:53:31.935069+00:00", - "updatedAt": "2026-02-05T20:17:38.081217+00:00" + "updatedAt": "2026-02-15T16:05:10.522038+00:00" } \ No newline at end of file diff --git a/.centy/config.json b/.centy/config.json index d703995..e30fefb 100644 --- a/.centy/config.json +++ b/.centy/config.json @@ -1,20 +1,16 @@ { - "version": "0.1.0", - "priorityLevels": 3, - "customFields": [], - "defaults": {}, "allowedStates": [ "open", "in-progress", "closed" ], + "customFields": [], "defaultState": "open", - "stateColors": {}, + "defaults": {}, + "hooks": [], "priorityColors": {}, - "llm": { - "autoCloseOnComplete": false, - "updateStatusOnStart": true, - "allowDirectEdits": false, - "defaultWorkspaceMode": 0 - } + "priorityLevels": 3, + "stateColors": {}, + "version": "0.1.0", + "workspace.updateStatusOnOpen": true } \ No newline at end of file diff --git a/.centy/issues/4e567375-8889-4b11-9265-fc6d944b8bae.md b/.centy/issues/4e567375-8889-4b11-9265-fc6d944b8bae.md index f180f90..ec11b43 100644 --- a/.centy/issues/4e567375-8889-4b11-9265-fc6d944b8bae.md +++ b/.centy/issues/4e567375-8889-4b11-9265-fc6d944b8bae.md @@ -1,9 +1,9 @@ --- displayNumber: 4 -status: open +status: closed priority: 1 createdAt: 2026-02-15T12:00:39.132475+00:00 -updatedAt: 2026-02-15T12:00:39.132475+00:00 +updatedAt: 2026-02-15T16:05:10.521455+00:00 --- # Only install pre-release versions when explicitly requested via flag diff --git a/install.sh b/install.sh index 8a5e885..ffdef0b 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,15 @@ main() { detect_arch target="${ARCH}-${OS}" - version="${1:-}" + pre_flag="" + version="" + for arg in "$@"; do + case "$arg" in + --pre) pre_flag="--pre" ;; + *) version="$arg" ;; + esac + done + if [ -z "$version" ]; then version=$(fetch_latest_version) fi @@ -36,7 +44,11 @@ main() { fi chmod +x "$binary" - "$binary" + if [ -n "$pre_flag" ]; then + "$binary" "$pre_flag" + else + "$binary" + fi } detect_os() { diff --git a/lib/src/github.rs b/lib/src/github.rs index b1164d9..c749e05 100644 --- a/lib/src/github.rs +++ b/lib/src/github.rs @@ -4,6 +4,12 @@ use crate::platform::Platform; const REPO: &str = "centy-io/centy-daemon"; +#[derive(Debug)] +pub struct VersionInfo { + pub tag: String, + pub notice: Option, +} + pub struct ReleaseInfo { /// Retained for consumers that need the resolved tag (e.g. for display/logging). #[allow(dead_code)] @@ -13,33 +19,81 @@ pub struct ReleaseInfo { pub asset_name: String, } -/// Resolve the version tag to use. If `version` is None, fetch the latest release -/// (including pre-releases) from the GitHub API. -pub fn resolve_version(client: &Client, version: Option<&str>) -> Result { - resolve_version_from(client, version, "https://api.github.com") +/// Resolve the version tag to use. +/// +/// When no `version` is specified: +/// - `prerelease = false` (default): fetches the latest stable release via `/releases/latest` +/// - `prerelease = true`: fetches all releases (including pre-releases) and picks the first one +/// +/// When a stable version is resolved, an additional check is made for a newer pre-release +/// and a notification is included in the returned `VersionInfo` if one exists. +pub fn resolve_version( + client: &Client, + version: Option<&str>, + prerelease: bool, +) -> Result { + resolve_version_from(client, version, prerelease, "https://api.github.com") } pub fn resolve_version_from( client: &Client, version: Option<&str>, + prerelease: bool, api_base: &str, -) -> Result { +) -> Result { if let Some(v) = version { let tag = if v.starts_with('v') { v.to_string() } else { format!("v{v}") }; - return Ok(tag); + return Ok(VersionInfo { tag, notice: None }); } - // Fetch all releases and pick the first one (most recent, includes pre-releases) - let url = format!("{api_base}/repos/{REPO}/releases"); + if prerelease { + // Fetch all releases and pick the first one (most recent, includes pre-releases) + let url = format!("{api_base}/repos/{REPO}/releases"); + let tag = fetch_tag_from_releases_array(client, &url)?; + return Ok(VersionInfo { tag, notice: None }); + } + + // Default: fetch the latest stable (non-pre-release) version + let url = format!("{api_base}/repos/{REPO}/releases/latest"); let resp = client .get(&url) .header("User-Agent", "centy-installer") .header("Accept", "application/vnd.github+json") .send() + .map_err(|e| format!("failed to fetch latest release: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("GitHub API returned {}", resp.status())); + } + + let text = resp + .text() + .map_err(|e| format!("failed to read response body: {e}"))?; + let body: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("failed to parse release JSON: {e}"))?; + + let tag = body["tag_name"] + .as_str() + .ok_or("no stable release found")? + .to_string(); + + // Check if a newer pre-release is available and notify the user + let notice = check_prerelease_notice(client, api_base, &tag); + + Ok(VersionInfo { tag, notice }) +} + +/// Fetch the tag name from the first entry of a releases array endpoint. +fn fetch_tag_from_releases_array(client: &Client, url: &str) -> Result { + let resp = client + .get(url) + .header("User-Agent", "centy-installer") + .header("Accept", "application/vnd.github+json") + .send() .map_err(|e| format!("failed to fetch releases: {e}"))?; if !resp.status().is_success() { @@ -52,14 +106,40 @@ pub fn resolve_version_from( let body: serde_json::Value = serde_json::from_str(&text) .map_err(|e| format!("failed to parse releases JSON: {e}"))?; - let tag = body - .as_array() + body.as_array() .and_then(|releases| releases.first()) .and_then(|r| r["tag_name"].as_str()) - .ok_or("no releases found")? - .to_string(); + .ok_or_else(|| "no releases found".to_string()) + .map(String::from) +} - Ok(tag) +/// Check if the most recent release on GitHub is a pre-release newer than the +/// resolved stable tag, and return a user-facing notice if so. +fn check_prerelease_notice(client: &Client, api_base: &str, stable_tag: &str) -> Option { + let url = format!("{api_base}/repos/{REPO}/releases"); + let resp = client + .get(&url) + .header("User-Agent", "centy-installer") + .header("Accept", "application/vnd.github+json") + .send() + .ok()?; + + if !resp.status().is_success() { + return None; + } + + let text = resp.text().ok()?; + let body: serde_json::Value = serde_json::from_str(&text).ok()?; + + let latest_tag = body.as_array()?.first()?["tag_name"].as_str()?; + + if latest_tag == stable_tag { + None + } else { + Some(format!( + "Note: Pre-release {latest_tag} is available. Use --pre to install it." + )) + } } /// Build release info (download URLs) for the given version tag and platform. @@ -232,35 +312,102 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz #[test] fn resolve_version_with_v_prefix() { let client = Client::new(); - let tag = resolve_version(&client, Some("v1.0.0")).unwrap(); - assert_eq!(tag, "v1.0.0"); + let info = resolve_version(&client, Some("v1.0.0"), false).unwrap(); + assert_eq!(info.tag, "v1.0.0"); + assert!(info.notice.is_none()); } #[test] fn resolve_version_without_v_prefix() { let client = Client::new(); - let tag = resolve_version(&client, Some("1.0.0")).unwrap(); - assert_eq!(tag, "v1.0.0"); + let info = resolve_version(&client, Some("1.0.0"), false).unwrap(); + assert_eq!(info.tag, "v1.0.0"); } #[test] - fn resolve_version_none_fetches_latest() { + fn resolve_version_prerelease_fetches_first_release() { let mut server = mockito::Server::new(); let mock = server .mock("GET", "/repos/centy-io/centy-daemon/releases") .with_status(200) .with_header("content-type", "application/json") - .with_body(r#"[{"tag_name": "v0.5.0"}, {"tag_name": "v0.4.0"}]"#) + .with_body(r#"[{"tag_name": "v0.5.0-alpha.1"}, {"tag_name": "v0.4.0"}]"#) .create(); let client = Client::new(); - let tag = resolve_version_from(&client, None, &server.url()).unwrap(); - assert_eq!(tag, "v0.5.0"); + let info = resolve_version_from(&client, None, true, &server.url()).unwrap(); + assert_eq!(info.tag, "v0.5.0-alpha.1"); + assert!(info.notice.is_none()); mock.assert(); } #[test] - fn resolve_version_none_api_error() { + fn resolve_version_stable_uses_latest_endpoint() { + let mut server = mockito::Server::new(); + let latest_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"tag_name": "v0.4.0"}"#) + .create(); + let releases_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"tag_name": "v0.4.0"}]"#) + .create(); + + let client = Client::new(); + let info = resolve_version_from(&client, None, false, &server.url()).unwrap(); + assert_eq!(info.tag, "v0.4.0"); + assert!(info.notice.is_none()); + latest_mock.assert(); + releases_mock.assert(); + } + + #[test] + fn resolve_version_stable_notifies_about_prerelease() { + let mut server = mockito::Server::new(); + let latest_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"tag_name": "v0.4.0"}"#) + .create(); + let releases_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"tag_name": "v0.5.0-alpha.1"}, {"tag_name": "v0.4.0"}]"#) + .create(); + + let client = Client::new(); + let info = resolve_version_from(&client, None, false, &server.url()).unwrap(); + assert_eq!(info.tag, "v0.4.0"); + let notice = info.notice.unwrap(); + assert!(notice.contains("v0.5.0-alpha.1")); + assert!(notice.contains("--pre")); + latest_mock.assert(); + releases_mock.assert(); + } + + #[test] + fn resolve_version_stable_api_error() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(403) + .create(); + + let client = Client::new(); + let result = resolve_version_from(&client, None, false, &server.url()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("GitHub API returned 403")); + mock.assert(); + } + + #[test] + fn resolve_version_prerelease_api_error() { let mut server = mockito::Server::new(); let mock = server .mock("GET", "/repos/centy-io/centy-daemon/releases") @@ -268,14 +415,30 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz .create(); let client = Client::new(); - let result = resolve_version_from(&client, None, &server.url()); + let result = resolve_version_from(&client, None, true, &server.url()); assert!(result.is_err()); assert!(result.unwrap_err().contains("GitHub API returned 403")); mock.assert(); } #[test] - fn resolve_version_none_invalid_json() { + fn resolve_version_stable_invalid_json() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(200) + .with_body("not-json") + .create(); + + let client = Client::new(); + let result = resolve_version_from(&client, None, false, &server.url()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("failed to parse release JSON")); + mock.assert(); + } + + #[test] + fn resolve_version_prerelease_invalid_json() { let mut server = mockito::Server::new(); let mock = server .mock("GET", "/repos/centy-io/centy-daemon/releases") @@ -284,14 +447,14 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz .create(); let client = Client::new(); - let result = resolve_version_from(&client, None, &server.url()); + let result = resolve_version_from(&client, None, true, &server.url()); assert!(result.is_err()); assert!(result.unwrap_err().contains("failed to parse releases JSON")); mock.assert(); } #[test] - fn resolve_version_none_empty_releases() { + fn resolve_version_prerelease_empty_releases() { let mut server = mockito::Server::new(); let mock = server .mock("GET", "/repos/centy-io/centy-daemon/releases") @@ -301,14 +464,31 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz .create(); let client = Client::new(); - let result = resolve_version_from(&client, None, &server.url()); + let result = resolve_version_from(&client, None, true, &server.url()); assert!(result.is_err()); assert!(result.unwrap_err().contains("no releases found")); mock.assert(); } #[test] - fn resolve_version_none_missing_tag_name() { + fn resolve_version_stable_missing_tag_name() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"name": "Release 1"}"#) + .create(); + + let client = Client::new(); + let result = resolve_version_from(&client, None, false, &server.url()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("no stable release found")); + mock.assert(); + } + + #[test] + fn resolve_version_prerelease_missing_tag_name() { let mut server = mockito::Server::new(); let mock = server .mock("GET", "/repos/centy-io/centy-daemon/releases") @@ -318,7 +498,7 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz .create(); let client = Client::new(); - let result = resolve_version_from(&client, None, &server.url()); + let result = resolve_version_from(&client, None, true, &server.url()); assert!(result.is_err()); assert!(result.unwrap_err().contains("no releases found")); mock.assert(); @@ -326,11 +506,32 @@ def456 centy-daemon-0.1.0-x86_64-unknown-linux-gnu.tar.gz #[test] fn resolve_version_from_with_version_ignores_api_base() { - // When a version is provided, the API base is never used let client = Client::new(); - let tag = - resolve_version_from(&client, Some("2.0.0"), "http://invalid-url.example.com") + let info = + resolve_version_from(&client, Some("2.0.0"), false, "http://invalid-url.example.com") .unwrap(); - assert_eq!(tag, "v2.0.0"); + assert_eq!(info.tag, "v2.0.0"); + } + + #[test] + fn resolve_version_prerelease_notice_skipped_on_api_failure() { + let mut server = mockito::Server::new(); + let latest_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases/latest") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"tag_name": "v0.4.0"}"#) + .create(); + let releases_mock = server + .mock("GET", "/repos/centy-io/centy-daemon/releases") + .with_status(500) + .create(); + + let client = Client::new(); + let info = resolve_version_from(&client, None, false, &server.url()).unwrap(); + assert_eq!(info.tag, "v0.4.0"); + assert!(info.notice.is_none()); + latest_mock.assert(); + releases_mock.assert(); } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 1dc5de1..ed3a0bc 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -38,17 +38,22 @@ pub(crate) fn extract_binary( /// Download and install the `centy-daemon` binary. /// -/// If `version` is `None`, the latest release (including pre-releases) is used. +/// If `version` is `None`, the latest stable release is used by default. +/// Set `prerelease` to `true` to allow installing pre-release versions. /// Returns the path to the installed binary (`~/.centy/bin/centy-daemon`). -pub fn install(version: Option<&str>) -> Result { +pub fn install(version: Option<&str>, prerelease: bool) -> Result { let platform = platform::detect().map_err(InstallerError::Platform)?; let client = reqwest::blocking::Client::new(); - let tag = github::resolve_version(&client, version) + let version_info = github::resolve_version(&client, version, prerelease) .map_err(InstallerError::VersionResolution)?; - let info = github::release_info(&tag, &platform); + if let Some(notice) = &version_info.notice { + eprintln!("{notice}"); + } + + let info = github::release_info(&version_info.tag, &platform); let asset = download::download_and_verify(&client, &info) .map_err(InstallerError::Download)?; diff --git a/lib/src/main.rs b/lib/src/main.rs index 4a7b4d5..ff43438 100644 --- a/lib/src/main.rs +++ b/lib/src/main.rs @@ -1,7 +1,9 @@ use std::process::ExitCode; fn main() -> ExitCode { - match centy_installer::install(None) { + let prerelease = std::env::args().any(|a| a == "--pre"); + + match centy_installer::install(None, prerelease) { Ok(path) => { println!("{}", path.display()); ExitCode::SUCCESS diff --git a/lib/tests/e2e_install.rs b/lib/tests/e2e_install.rs index b847d0e..f67fa10 100644 --- a/lib/tests/e2e_install.rs +++ b/lib/tests/e2e_install.rs @@ -35,7 +35,7 @@ fn install_pinned_version() { cleanup(); let path = - centy_installer::install(Some("v0.1.6")).expect("install v0.1.6 should succeed"); + centy_installer::install(Some("v0.1.6"), false).expect("install v0.1.6 should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); @@ -58,7 +58,7 @@ fn install_pinned_version() { fn install_latest_version() { cleanup(); - let path = centy_installer::install(None).expect("install latest should succeed"); + let path = centy_installer::install(None, false).expect("install latest should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); } @@ -67,7 +67,7 @@ fn install_latest_version() { fn install_version_without_v_prefix() { cleanup(); - let path = centy_installer::install(Some("0.1.6")) + let path = centy_installer::install(Some("0.1.6"), false) .expect("install without v prefix should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); @@ -77,7 +77,7 @@ fn install_version_without_v_prefix() { fn install_nonexistent_version_fails() { cleanup(); - let result = centy_installer::install(Some("v99.99.99")); + let result = centy_installer::install(Some("v99.99.99"), false); assert!( result.is_err(),