diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9c6ed2c..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 @@ -232,6 +233,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 +273,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 +312,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 +349,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 +370,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 @@ -376,6 +382,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/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/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 d3bbb89..4949885 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 (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 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." + ); + } + // 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")); + } +}