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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 17:18:18 -04:00
parent 92f40e59fb
commit 963f0e2c7c
3 changed files with 227 additions and 1 deletions

View file

@ -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}");
}
}
}

View file

@ -127,7 +127,9 @@ enum LlmCommand {
Chat(arc_llm::cli::ChatArgs),
}
fn build_github_app_credentials(app_id: Option<&str>) -> Option<arc_github::GitHubAppCredentials> {
pub(crate) fn build_github_app_credentials(
app_id: Option<&str>,
) -> Option<arc_github::GitHubAppCredentials> {
let app_id = app_id?;
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
let private_key_pem = if raw.starts_with("-----") {

View file

@ -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<bool, String> {
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"
);
}
}