diff --git a/Cargo.lock b/Cargo.lock index 14891c1e..9bfaaa16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,7 +263,7 @@ dependencies = [ [[package]] name = "cora-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/README.md b/README.md index 409690bd..9afba1fc 100644 --- a/README.md +++ b/README.md @@ -34,34 +34,40 @@ ## 📦 Installation -### Cargo (Recommended) +### Quick Install (Linux/macOS) ```bash -cargo install cora-cli +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh ``` -### Binary Download - -Download the latest release from [GitHub Releases](https://github.com/codecoradev/cora-cli/releases): +Installs to `~/.local/bin`. Add to PATH if needed: ```bash -# Determine your platform tag from the releases page, e.g.: -# cora-aarch64-unknown-linux-gnu-v0.2.0.tar.gz -# cora-x86_64-unknown-linux-gnu-v0.2.0.tar.gz -# cora-aarch64-apple-darwin-v0.2.0.tar.gz -# cora-x86_64-pc-windows-msvc-v0.2.0.zip - -# Example: Linux aarch64 -VERSION=$(curl -s https://api.github.com/repos/codecoradev/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4) -curl -L "https://github.com/codecoradev/cora-cli/releases/download/${VERSION}/cora-aarch64-unknown-linux-gnu-${VERSION}.tar.gz" | tar xz -sudo mv cora /usr/local/bin/ +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc ``` -> **Tip:** Visit the [Releases page](https://github.com/codecoradev/cora-cli/releases) to find the correct asset name for your platform. +### Pre-built Binaries + +Download from [GitHub Releases](https://github.com/codecoradev/cora-cli/releases): + +| Platform | Asset | +|----------|-------| +| Linux x86_64 | `cora-x86_64-unknown-linux-gnu-v*.tar.gz` | +| Linux ARM64 | `cora-aarch64-unknown-linux-gnu-v*.tar.gz` | +| macOS Apple Silicon | `cora-aarch64-apple-darwin-v*.tar.gz` | +| Windows x86_64 | `cora-x86_64-pc-windows-msvc-v*.zip` | + +> **Windows users:** Extract the zip and place `cora.exe` somewhere in your PATH (e.g. `C:\Users\\.local\bin`). Run from Command Prompt, PowerShell, or Windows Terminal — do not double-click the `.exe` (it will flash and close). + +### Cargo + +```bash +cargo install --git https://github.com/codecoradev/cora-cli +``` ### Homebrew -> 🚧 Homebrew tap is planned — check back soon! +> 🚧 Homebrew tap is planned — see [#151](https://github.com/codecoradev/cora-cli/issues/151). ### Build from Source @@ -129,6 +135,9 @@ cora review --base origin/main # Review a pull request diff from a file cora review --diff-file pr.diff +# CI mode: skip diff size limit, hard gate on any findings +cora review --ci --base ${{ github.base_ref }} + # Use a specific model cora review --model gpt-4o @@ -283,6 +292,7 @@ hook: mode: warn # warn | block min_severity: major # info | minor | major | critical max_diff_size: 51200 # Max diff size in bytes (50 KB) + on_violation: warn # warn | disallow — block commit on oversized diff # Output settings output: diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..d0f53d17 --- /dev/null +++ b/install.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env sh +# cora installer — https://github.com/codecoradev/cora-cli +# Usage: curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh + +set -e + +REPO="codecoradev/cora-cli" +BINARY_NAME="cora" +INSTALL_DIR="${CORA_INSTALL_DIR:-$HOME/.local/bin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +warn() { + printf "${YELLOW}[WARN]${NC} %s\n" "$1" +} + +error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" + exit 1 +} + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) OS="linux";; + Darwin*) OS="darwin";; + *) error "Unsupported operating system: $(uname -s)";; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64";; + arm64|aarch64) ARCH="aarch64";; + *) error "Unsupported architecture: $(uname -m)";; + esac +} + +# Get latest release version +# Primary: parse the 302 redirect on /releases/latest (no API call, no rate limit). +# Fallback: the GitHub REST API (subject to 60 req/hour anonymous limit). +get_latest_version() { + # Try the web redirect first — does not count against the API rate limit. + VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" \ + | grep -i '^location:' \ + | sed -E 's|.*/tag/([^[:space:]]+).*|\1|' \ + | tr -d '\r') + + # Fallback to the REST API if the redirect didn't yield a tag. + if [ -z "$VERSION" ]; then + warn "Redirect lookup failed, falling back to GitHub API..." + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' \ + | sed -E 's/.*"([^"]+)".*/\1/') + fi + + if [ -z "$VERSION" ]; then + error "Failed to get latest version. Set CORA_VERSION=vX.Y.Z to pin a version." + fi +} + +# Build target triple and archive name +get_target() { + case "$OS" in + linux) + case "$ARCH" in + x86_64) TARGET="x86_64-unknown-linux-gnu";; + aarch64) TARGET="aarch64-unknown-linux-gnu";; + esac + ;; + darwin) + # Only aarch64 (Apple Silicon) is currently published + if [ "$ARCH" != "aarch64" ]; then + warn "No pre-built binary for x86_64 macOS. Install via cargo:" + warn " cargo install --git https://github.com/${REPO}" + exit 0 + fi + TARGET="aarch64-apple-darwin" + ;; + esac +} + +# Download and install +install() { + info "Detected: $OS $ARCH" + info "Target: $TARGET" + info "Version: $VERSION" + + ARCHIVE_NAME="${BINARY_NAME}-${TARGET}-${VERSION}.tar.gz" + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE_NAME}" + TEMP_DIR=$(mktemp -d) + ARCHIVE="${TEMP_DIR}/${ARCHIVE_NAME}" + + info "Downloading from: $DOWNLOAD_URL" + if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then + error "Failed to download ${ARCHIVE_NAME}" + fi + + # Verify archive contents before extraction (CWE-22 path traversal). + # Reject any entry with an absolute path or a ".." component. + info "Verifying archive..." + if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then + error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" + fi + + info "Extracting..." + tar -xzf "$ARCHIVE" -C "$TEMP_DIR" + + mkdir -p "$INSTALL_DIR" + mv "${TEMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/" + + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + + # Cleanup + rm -rf "$TEMP_DIR" + + info "Successfully installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" +} + +# Verify installation +verify() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + INSTALLED_VERSION=$("$BINARY_NAME" --version 2>/dev/null || echo "unknown") + info "Verification: $INSTALLED_VERSION" + else + warn "Binary installed but not in PATH. Add to your shell profile:" + case "${SHELL:-}" in + */zsh) + warn ' echo '\''export PATH="$HOME/.local/bin:$PATH"'\'' >> ~/.zshrc' + warn ' source ~/.zshrc' + ;; + */bash) + warn ' echo '\''export PATH="$HOME/.local/bin:$PATH"'\'' >> ~/.bashrc' + warn ' source ~/.bashrc' + ;; + */fish) + warn ' fish_add_path ~/.local/bin' + ;; + *) + warn ' export PATH="$HOME/.local/bin:$PATH"' + ;; + esac + fi +} + +main() { + info "Installing ${BINARY_NAME}..." + + detect_os + detect_arch + get_target + if [ -n "$CORA_VERSION" ]; then + VERSION="$CORA_VERSION" + info "Using pinned version from CORA_VERSION: $VERSION" + else + get_latest_version + fi + install + verify + + echo "" + info "Installation complete! Run '${BINARY_NAME} --help' to get started." +} + +main diff --git a/src/commands/review.rs b/src/commands/review.rs index 11403762..1f41f6e9 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -37,6 +37,8 @@ pub struct ReviewOptions { pub severity: Option, /// Disable review caching. pub no_cache: bool, + /// CI mode: skip diff size limit, hard gate on any findings. + pub ci: bool, } /// Result of a review command execution. @@ -82,9 +84,27 @@ pub async fn execute_review( progress.parsing_diff(files_changed, lines_changed); } - // 3. Validate size + // 3. Validate size (skip in CI mode) let max_size = opts.max_diff_size.unwrap_or(config.hook.max_diff_size); - if diff.len() > max_size { + if !opts.ci && diff.len() > max_size { + if config.hook.on_violation == "disallow" { + // Return blocked result instead of error + return Ok(ReviewResult { + exit_code: EXIT_BLOCKED, + output: format!( + "{}\n", + format!( + "❌ Diff too large ({} chars, max {}). Commit blocked. \ + Use --base to review a specific branch, increase \ + hook.max_diff_size, or run: git commit --no-verify", + diff.len(), + max_size + ) + .red() + .bold(), + ), + }); + } anyhow::bail!( "Diff too large ({} chars, max {}). Use --base to review a specific branch, or increase hook.max_diff_size.", diff.len(), @@ -156,7 +176,14 @@ pub async fn execute_review( let output = formatter.format_review(&filtered_response)?; // 8. Return exit code - let exit_code = if response.should_block && config.hook.mode == "block" { + let exit_code = if opts.ci { + // CI mode: hard gate — any finding blocks + if !filtered_response.issues.is_empty() { + EXIT_BLOCKED + } else { + EXIT_OK + } + } else if response.should_block && config.hook.mode == "block" { EXIT_BLOCKED } else { EXIT_OK diff --git a/src/config/schema.rs b/src/config/schema.rs index 75eddf40..06db2395 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -68,6 +68,7 @@ pub struct HookConfig { pub mode: String, pub min_severity: String, pub max_diff_size: usize, + pub on_violation: String, // "warn" | "disallow" } /// Output configuration. @@ -105,6 +106,7 @@ impl Default for Config { mode: "warn".to_string(), min_severity: "major".to_string(), max_diff_size: 50 * 1024, + on_violation: "warn".to_string(), }, output: OutputConfig { format: "pretty".to_string(), @@ -187,6 +189,8 @@ pub struct HookSection { pub min_severity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_diff_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_violation: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -326,6 +330,9 @@ impl CoraFile { if let Some(v) = h.max_diff_size { config.hook.max_diff_size = v; } + if let Some(v) = &h.on_violation { + config.hook.on_violation.clone_from(v); + } } if let Some(o) = &self.output { if let Some(v) = &o.format { @@ -443,6 +450,7 @@ mod tests { assert_eq!(cfg.hook.mode, "warn"); assert_eq!(cfg.hook.min_severity, "major"); assert_eq!(cfg.hook.max_diff_size, 50 * 1024); + assert_eq!(cfg.hook.on_violation, "warn"); } #[test] @@ -543,6 +551,7 @@ mod tests { mode: Some("block".to_string()), min_severity: Some("critical".to_string()), max_diff_size: Some(1024), + on_violation: Some("disallow".to_string()), }), ..Default::default() }; @@ -550,6 +559,7 @@ mod tests { assert_eq!(cfg.hook.mode, "block"); assert_eq!(cfg.hook.min_severity, "critical"); assert_eq!(cfg.hook.max_diff_size, 1024); + assert_eq!(cfg.hook.on_violation, "disallow"); } #[test] @@ -620,6 +630,7 @@ output: mode: "warn".to_string(), min_severity: "critical".to_string(), max_diff_size: 1024, + on_violation: "warn".to_string(), }; assert_eq!(cfg.min_severity_level(), Severity::Critical); } @@ -630,6 +641,7 @@ output: mode: "warn".to_string(), min_severity: "whatever".to_string(), max_diff_size: 1024, + on_violation: "warn".to_string(), }; assert_eq!(cfg.min_severity_level(), Severity::Info); } diff --git a/src/main.rs b/src/main.rs index b139d3c0..f06531e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,6 +125,10 @@ enum Command { #[clap(long)] no_cache: bool, + /// CI mode: skip diff size limit, exit 2 if any findings + #[clap(long)] + ci: bool, + /// Upload SARIF output to GitHub Code Scanning after review /// (implies --format sarif) #[clap(long)] @@ -302,6 +306,7 @@ async fn main() -> Result<()> { quiet, severity, no_cache, + ci, upload, repo, ref_name, @@ -326,6 +331,7 @@ async fn main() -> Result<()> { ref_name, token, no_cache, + ci, }, ) .await? @@ -434,6 +440,7 @@ struct ReviewOpts { ref_name: Option, token: Option, no_cache: bool, + ci: bool, } /// Struct to hold scan options from CLI. @@ -486,6 +493,7 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { quiet: opts.quiet || opts.progress, severity: opts.severity.clone(), no_cache: opts.no_cache, + ci: opts.ci, }; // When streaming and not quiet/progress, show a simpler message