From 46846a558a3aab83769ac8e68b3e5a59d06353ba Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 9 Jan 2026 14:16:50 -0500 Subject: [PATCH 1/4] feat(cli): implement upgrade command - Add upgrade command to check for and download latest releases from GitHub - Compare current version with latest release, skip if already up to date - Download new binaries for all configured platforms - Update init command to download real binaries from GitHub releases - Add git repo root check to init (errors if not at root) - Add serde_json dependency for GitHub API parsing - Add rustls-tls and json features to reqwest Closes #11 --- Cargo.toml | 5 +- src/commands/init.rs | 66 +++++++++----- src/commands/upgrade.rs | 198 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 230 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70b33c0..8fac043 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,10 @@ dialoguer = "0.11" console = "0.15" # HTTP client for init/upgrade -reqwest = { version = "0.12", features = ["blocking"], default-features = false, optional = true } +reqwest = { version = "0.12", features = ["blocking", "rustls-tls", "json"], default-features = false, optional = true } + +# JSON parsing for GitHub API +serde_json = "1" # Async runtime for parallel execution tokio = { version = "1", features = ["rt-multi-thread", "process", "sync"], optional = true } diff --git a/src/commands/init.rs b/src/commands/init.rs index d3bbb89..5d2720e 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -38,6 +38,15 @@ pub fn run(args: &InitArgs) -> Result<()> { return Ok(()); } + // Error if not at git repo root + if !is_git_repo_root()? { + bail!( + "This directory does not appear to be a git repository root.\n\ + rnr must be initialized at the root of a git repository.\n\ + Please run 'rnr init' from the directory containing your .git folder." + ); + } + // Determine platforms to install let platforms = select_platforms(args)?; @@ -49,6 +58,13 @@ pub fn run(args: &InitArgs) -> Result<()> { initialize(&platforms) } +/// Check if the current directory is a git repository root +fn is_git_repo_root() -> Result { + let current_dir = std::env::current_dir().context("Failed to get current directory")?; + let git_dir = current_dir.join(".git"); + Ok(git_dir.exists()) +} + /// Select platforms based on args or interactively fn select_platforms(args: &InitArgs) -> Result> { // --all-platforms @@ -191,28 +207,42 @@ fn download_binaries(platforms: &[Platform], bin_directory: &Path) -> Result<()> Ok(()) } +/// GitHub repository for releases +const GITHUB_REPO: &str = "CodingWithCalvin/rnr.cli"; + /// Download a single binary from GitHub releases #[cfg(feature = "network")] fn download_binary(platform: Platform, dest: &Path) -> Result<()> { - use std::io::Write; - - // TODO: Replace with actual release URL once we have releases - // For now, use GitHub releases URL pattern let url = format!( - "https://github.com/CodingWithCalvin/rnr.cli/releases/latest/download/{}", + "https://github.com/{}/releases/latest/download/{}", + GITHUB_REPO, platform.binary_name() ); - // For now, create a placeholder since we don't have releases yet - // In production, this would download from the URL - let placeholder = format!( - "#!/bin/sh\necho 'Placeholder binary for {}. Replace with actual binary from releases.'\n", - platform.id() - ); + let client = reqwest::blocking::Client::builder() + .user_agent("rnr-cli") + .build() + .context("Failed to create HTTP client")?; - let mut file = - fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?; - file.write_all(placeholder.as_bytes())?; + let response = client + .get(&url) + .send() + .with_context(|| format!("Failed to download {}", platform.binary_name()))?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to download {}: HTTP {}", + platform.binary_name(), + response.status().as_u16() + ); + } + + let bytes = response + .bytes() + .with_context(|| format!("Failed to read response for {}", platform.binary_name()))?; + + // Write to file + fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?; // Make executable on Unix #[cfg(unix)] @@ -223,14 +253,6 @@ fn download_binary(platform: Platform, dest: &Path) -> Result<()> { fs::set_permissions(dest, perms)?; } - // TODO: Actual download implementation: - // let response = reqwest::blocking::get(&url) - // .with_context(|| format!("Failed to download {}", url))?; - // let bytes = response.bytes()?; - // fs::write(dest, bytes)?; - - let _ = url; // Suppress unused warning for now - Ok(()) } diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 9dd4433..404a99a 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,6 +1,15 @@ +//! Upgrade rnr binaries to the latest version + use anyhow::{Context, Result}; +use std::fs; use std::path::PathBuf; +use crate::platform::Platform; +use crate::rnr_config::RnrConfig; + +/// GitHub repository for releases +const GITHUB_REPO: &str = "CodingWithCalvin/rnr.cli"; + /// Run the upgrade command pub fn run() -> Result<()> { let rnr_dir = find_rnr_dir()?; @@ -10,16 +19,26 @@ pub fn run() -> Result<()> { anyhow::bail!("rnr is not initialized. Run 'rnr init' first."); } - println!("Upgrading rnr binaries..."); + // Load current config + let config_path = rnr_dir.join("config.yaml"); + let mut config = RnrConfig::load_from(&config_path)?; + let platforms = config.get_platforms(); + + if platforms.is_empty() { + anyhow::bail!("No platforms configured. Run 'rnr init' to set up platforms."); + } + + println!("Checking for updates...\n"); + println!(" Current version: v{}", config.version); #[cfg(feature = "network")] { - upgrade_binaries(&bin_dir)?; + upgrade_binaries(&bin_dir, &mut config, &config_path, &platforms)?; } #[cfg(not(feature = "network"))] { - println!("Network feature is disabled. Cannot download updates."); + println!("\nNetwork feature is disabled. Cannot check for updates."); println!("Please manually update binaries in .rnr/bin/"); } @@ -46,22 +65,169 @@ fn find_rnr_dir() -> Result { anyhow::bail!("No .rnr directory found. Run 'rnr init' first.") } -/// Download and replace binaries +/// Upgrade binaries to the latest version #[cfg(feature = "network")] -fn upgrade_binaries(bin_dir: &std::path::Path) -> Result<()> { - // TODO: Implement actual binary downloads - // 1. Check current version - // 2. Check latest version from server - // 3. Download if newer version available - // 4. Replace binaries +fn upgrade_binaries( + bin_dir: &std::path::Path, + config: &mut RnrConfig, + config_path: &std::path::Path, + platforms: &[Platform], +) -> Result<()> { + // Get latest release info from GitHub + let latest_version = get_latest_version()?; + println!(" Latest version: v{}", latest_version); - println!(" Checking for updates..."); - println!(" TODO: Check https://rnr.dev/bin/latest/"); - println!(" TODO: Download updated binaries"); - println!("\nUpgrade complete!"); + // Compare versions + if !is_newer_version(&config.version, &latest_version) { + println!("\nYou're already on the latest version!"); + return Ok(()); + } - // Placeholder for actual implementation - let _ = bin_dir; + println!("\nUpgrading to v{}...\n", latest_version); + + // Download new binaries for all configured platforms + for platform in platforms { + print!(" Downloading {}...", platform.binary_name()); + let binary_path = bin_dir.join(platform.binary_name()); + download_binary(*platform, &latest_version, &binary_path)?; + println!(" done"); + } + + // Update config version + config.version = latest_version.clone(); + config.save_to(config_path)?; + + println!("\nUpgrade complete! Now running v{}", latest_version); Ok(()) } + +/// Get the latest release version from GitHub +#[cfg(feature = "network")] +fn get_latest_version() -> Result { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + + let client = reqwest::blocking::Client::builder() + .user_agent("rnr-cli") + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(&url) + .send() + .context("Failed to fetch latest release info")?; + + if !response.status().is_success() { + if response.status().as_u16() == 404 { + anyhow::bail!("No releases found. This may be the first version."); + } + anyhow::bail!( + "Failed to fetch release info: HTTP {}", + response.status().as_u16() + ); + } + + let json: serde_json::Value = response + .json() + .context("Failed to parse release info as JSON")?; + + let tag = json["tag_name"] + .as_str() + .context("Release missing tag_name")?; + + // Strip 'v' prefix if present + let version = tag.strip_prefix('v').unwrap_or(tag); + Ok(version.to_string()) +} + +/// Download a binary for a specific platform and version +#[cfg(feature = "network")] +fn download_binary(platform: Platform, version: &str, dest: &std::path::Path) -> Result<()> { + let url = format!( + "https://github.com/{}/releases/download/v{}/{}", + GITHUB_REPO, + version, + platform.binary_name() + ); + + let client = reqwest::blocking::Client::builder() + .user_agent("rnr-cli") + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(&url) + .send() + .with_context(|| format!("Failed to download {}", platform.binary_name()))?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to download {}: HTTP {}", + platform.binary_name(), + response.status().as_u16() + ); + } + + let bytes = response + .bytes() + .with_context(|| format!("Failed to read response for {}", platform.binary_name()))?; + + // Write to file + fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?; + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(dest)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(dest, perms)?; + } + + Ok(()) +} + +/// Compare semantic versions, returns true if latest is newer than current +#[cfg(feature = "network")] +fn is_newer_version(current: &str, latest: &str) -> bool { + let parse_version = |v: &str| -> (u32, u32, u32) { + let parts: Vec<&str> = v.split('.').collect(); + let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + (major, minor, patch) + }; + + let (cur_major, cur_minor, cur_patch) = parse_version(current); + let (lat_major, lat_minor, lat_patch) = parse_version(latest); + + if lat_major > cur_major { + return true; + } + if lat_major == cur_major && lat_minor > cur_minor { + return true; + } + if lat_major == cur_major && lat_minor == cur_minor && lat_patch > cur_patch { + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "network")] + fn test_version_comparison() { + assert!(is_newer_version("0.1.0", "0.2.0")); + assert!(is_newer_version("0.1.0", "1.0.0")); + assert!(is_newer_version("0.1.0", "0.1.1")); + assert!(!is_newer_version("0.2.0", "0.1.0")); + assert!(!is_newer_version("1.0.0", "0.9.0")); + assert!(!is_newer_version("0.1.0", "0.1.0")); + } +} From 7d6a11f07c678a6e2f9ce7323ffb9bbaef8e0681 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 9 Jan 2026 14:21:52 -0500 Subject: [PATCH 2/4] test: initialize git repo in integration tests Init command now requires being at a git repo root, so the integration tests need to run git init in temp directories before testing the init command. --- .github/workflows/integration-test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9c6ed2c..4f3ae16 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -232,6 +232,7 @@ jobs: run: | INIT_DIR=$(mktemp -d) cd "$INIT_DIR" + git init $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --current-platform-only # Verify files were created @@ -271,6 +272,7 @@ jobs: run: | INIT_DIR=$(mktemp -d) cd "$INIT_DIR" + git init $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64,macos-arm64,windows-amd64 # Verify config has the right platforms @@ -309,6 +311,7 @@ jobs: run: | INIT_DIR=$(mktemp -d) cd "$INIT_DIR" + git init # First init with one platform $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64 @@ -345,6 +348,7 @@ jobs: run: | INIT_DIR=$(mktemp -d) cd "$INIT_DIR" + git init $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64,windows-amd64 OUTPUT=$($GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --show-platforms 2>&1) @@ -365,6 +369,7 @@ jobs: run: | INIT_DIR=$(mktemp -d) cd "$INIT_DIR" + git init $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --platforms linux-amd64 # Try to remove the only platform - should fail From 3da81820d9b66ccb3e92c9f60d831b307d666368 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 9 Jan 2026 14:23:58 -0500 Subject: [PATCH 3/4] feat(cli): add --force flag to skip git repo root check Adds --force flag to init command that allows initialization in directories that aren't git repository roots. Useful for edge cases or non-git projects. Also adds integration test to verify the flag works correctly. --- .github/workflows/integration-test.yml | 25 +++++++++++++++++++++++++ src/cli.rs | 4 ++++ src/commands/init.rs | 8 ++++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 4f3ae16..e788f91 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -381,6 +381,31 @@ jobs: echo "✅ init correctly refuses to remove last platform" rm -rf "$INIT_DIR" + - name: "Test: init --force skips git repo check" + shell: bash + run: | + INIT_DIR=$(mktemp -d) + cd "$INIT_DIR" + # Do NOT run git init - this is intentionally not a git repo + + # Without --force, should fail + if $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --current-platform-only 2>&1; then + echo "ERROR: init should fail without --force in non-git directory" + exit 1 + fi + + # With --force, should succeed + $GITHUB_WORKSPACE/tests/${{ matrix.binary }} init --current-platform-only --force + + # Verify files were created + if [ ! -f ".rnr/config.yaml" ]; then + echo "ERROR: .rnr/config.yaml not created with --force" + exit 1 + fi + + echo "✅ init --force correctly skips git repo check" + rm -rf "$INIT_DIR" + # ==================== Error Cases ==================== - name: "Test: nonexistent task (should fail)" diff --git a/src/cli.rs b/src/cli.rs index 6a6141c..b7b3f3e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -51,4 +51,8 @@ pub struct InitArgs { /// Show currently configured platforms #[arg(long)] pub show_platforms: bool, + + /// Skip git repository root check + #[arg(long)] + pub force: bool, } diff --git a/src/commands/init.rs b/src/commands/init.rs index 5d2720e..4949885 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -38,12 +38,12 @@ pub fn run(args: &InitArgs) -> Result<()> { return Ok(()); } - // Error if not at git repo root - if !is_git_repo_root()? { + // Error if not at git repo root (unless --force is used) + if !args.force && !is_git_repo_root()? { bail!( "This directory does not appear to be a git repository root.\n\ - rnr must be initialized at the root of a git repository.\n\ - Please run 'rnr init' from the directory containing your .git folder." + rnr is typically initialized at the root of a git repository.\n\ + Use --force to initialize anyway, or run from the directory containing your .git folder." ); } From e3629c34e30f672b725285b36c088c97d606e128 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 9 Jan 2026 14:27:26 -0500 Subject: [PATCH 4/4] test: build without network feature for integration tests No releases exist yet, so init cannot download real binaries. Building without network feature uses placeholder binaries instead. --- .github/workflows/integration-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index e788f91..7f65892 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -62,7 +62,8 @@ jobs: ${{ runner.os }}-cargo- - name: Build release binary - run: cargo build --release + # Build without network feature so init uses placeholder binaries (no releases exist yet) + run: cargo build --release --no-default-features --features parallel - name: Copy binary to test location shell: bash