From 963f0e2c7c18171f46b52abc3fe0fc0ab83db529 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 17:18:18 -0400 Subject: [PATCH] Check GitHub App installation during arc init Catches missing GitHub App installations early by verifying the app is installed for the repo's GitHub remote after scaffolding project files. Shows install URL and optional interactive prompt if not installed; never fails init on check errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/arc-cli/src/init.rs | 133 +++++++++++++++++++++++++++++++ lib/crates/arc-cli/src/main.rs | 4 +- lib/crates/arc-github/src/lib.rs | 91 +++++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) diff --git a/lib/crates/arc-cli/src/init.rs b/lib/crates/arc-cli/src/init.rs index f727dd3c2..31935ccdc 100644 --- a/lib/crates/arc-cli/src/init.rs +++ b/lib/crates/arc-cli/src/init.rs @@ -104,5 +104,138 @@ root = \"arc/\" .apply_to("arc run hello") ); + check_github_app_installation().await; + Ok(()) } + +async fn check_github_app_installation() { + // Get the git remote origin URL + let output = match std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return, // No origin remote — skip silently + }; + + let remote_url = match String::from_utf8(output.stdout) { + Ok(s) => s.trim().to_string(), + Err(_) => return, + }; + + // Convert SSH URL to HTTPS and parse owner/repo + let https_url = arc_github::ssh_url_to_https(&remote_url); + let (owner, repo) = match arc_github::parse_github_owner_repo(&https_url) { + Ok(pair) => pair, + Err(_) => return, // Not a GitHub repo — skip silently + }; + + // Load CLI config to get app_id and slug + let cli_config = match crate::cli_config::load_cli_config(None) { + Ok(c) => c, + Err(_) => return, + }; + + let app_id = match cli_config.app_id() { + Some(id) => id.to_string(), + None => { + eprintln!( + "\n Run {} to set up the GitHub App", + console::Style::new().cyan().bold().apply_to("arc install") + ); + return; + } + }; + + let slug = cli_config.slug().map(String::from); + + // Build GitHub App credentials + let creds = match crate::build_github_app_credentials(Some(&app_id)) { + Some(c) => c, + None => { + eprintln!( + "\n Set {} to enable GitHub App integration", + console::Style::new() + .cyan() + .bold() + .apply_to("GITHUB_APP_PRIVATE_KEY") + ); + return; + } + }; + + let jwt = match arc_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) { + Ok(j) => j, + Err(e) => { + eprintln!("\n Warning: failed to sign GitHub App JWT: {e}"); + return; + } + }; + + let client = reqwest::Client::new(); + + match arc_github::check_app_installed(&client, &jwt, &owner, &repo, "https://api.github.com") + .await + { + Ok(true) => { + let green = console::Style::new().green(); + eprintln!( + "\n {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") + ); + } + Ok(false) => { + let install_url = match &slug { + Some(s) => format!("https://github.com/apps/{s}/installations/new"), + None => format!("https://github.com/organizations/{owner}/settings/installations"), + }; + + let yellow = console::Style::new().yellow(); + eprintln!( + "\n {} GitHub App is not installed for {owner}/{repo}", + yellow.apply_to("!") + ); + eprintln!(" Install at: {install_url}"); + + // Only prompt if stdin is a terminal + if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + eprintln!(" Press Enter to continue after installing..."); + let _ = tokio::task::spawn_blocking(|| { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + }) + .await; + + // Re-check after user presses Enter + match arc_github::check_app_installed( + &client, + &jwt, + &owner, + &repo, + "https://api.github.com", + ) + .await + { + Ok(true) => { + let green = console::Style::new().green(); + eprintln!( + " {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") + ); + } + Ok(false) => { + eprintln!(" GitHub App is still not installed."); + eprintln!(" Install at: {install_url}"); + } + Err(e) => { + eprintln!(" Warning: could not re-check GitHub App installation: {e}"); + } + } + } + } + Err(e) => { + eprintln!("\n Warning: could not check GitHub App installation: {e}"); + } + } +} diff --git a/lib/crates/arc-cli/src/main.rs b/lib/crates/arc-cli/src/main.rs index a69dd9890..ad10c67af 100644 --- a/lib/crates/arc-cli/src/main.rs +++ b/lib/crates/arc-cli/src/main.rs @@ -127,7 +127,9 @@ enum LlmCommand { Chat(arc_llm::cli::ChatArgs), } -fn build_github_app_credentials(app_id: Option<&str>) -> Option { +pub(crate) fn build_github_app_credentials( + app_id: Option<&str>, +) -> Option { let app_id = app_id?; let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?; let private_key_pem = if raw.starts_with("-----") { diff --git a/lib/crates/arc-github/src/lib.rs b/lib/crates/arc-github/src/lib.rs index 174166807..66dfeaea6 100644 --- a/lib/crates/arc-github/src/lib.rs +++ b/lib/crates/arc-github/src/lib.rs @@ -351,6 +351,42 @@ pub async fn branch_exists( } } +/// Check whether a GitHub App is installed for a specific repository. +/// +/// Uses the App JWT to query `GET /repos/{owner}/{repo}/installation`. +/// Returns `Ok(true)` on 200, `Ok(false)` on 404. +pub async fn check_app_installed( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + let url = format!("{base_url}/repos/{owner}/{repo}/installation"); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .send() + .await + .map_err(|e| format!("Failed to check GitHub App installation: {e}"))?; + + match resp.status().as_u16() { + 200 => Ok(true), + 404 => Ok(false), + 401 => Err("GitHub App authentication failed. \ + Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." + .to_string()), + 403 => Err("GitHub App installation is suspended. \ + Re-enable it in your organization's GitHub App settings." + .to_string()), + status => Err(format!( + "Unexpected status {status} checking GitHub App installation" + )), + } +} + /// Resolve git clone credentials for a GitHub repository. /// /// Returns `(username, password)` for authenticated cloning. @@ -794,4 +830,59 @@ mod tests { let result = branch_exists(&creds, "owner", "repo", "broken", &server.url()).await; assert!(result.is_err()); } + + // ----------------------------------------------------------------------- + // check_app_installed + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn check_app_installed_returns_true_on_200() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), true); + } + + #[tokio::test] + async fn check_app_installed_returns_false_on_404() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(404) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), false); + } + + #[tokio::test] + async fn check_app_installed_returns_error_on_401() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(401) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("authentication failed"), + "expected auth error" + ); + } }