github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! Manifest-flow orchestrator.
//!
//! GitHub doesn't expose a pure REST API for creating apps — the only
//! programmatic path is the Manifest flow, which requires exactly one browser
//! confirmation. The flow:
//!
//!   1. Tool starts a one-shot HTTP listener on an ephemeral localhost port.
//!   2. Tool generates an HTML page with a hidden-form POST to GitHub's
//!      manifest endpoint (`https://github.com/.../settings/apps/new`),
//!      auto-submits via JS. Manifest body includes `redirect_url=http://localhost:<port>/callback`.
//!   3. Tool opens the operator's default browser to that page (1 click on the
//!      "Create from manifest" confirmation in GitHub).
//!   4. GitHub redirects the browser to the localhost listener with `?code=<temp>`.
//!   5. Listener captures the code, returns "you can close this tab".
//!   6. Tool exchanges the code via `POST /app-manifests/{code}/conversions`.
//!   7. Tool optionally opens the install URL with target_id pre-filled (one
//!      more click) and polls `GET /app/installations` until the installation
//!      shows up.
//!   8. Tool writes credentials (id, slug, pem, installation_id) to the sink.

use anyhow::{anyhow, bail, Result};
use colored::Colorize;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

use crate::client::{exchange_manifest_code, install_on_repos, lookup_installation_id, AppCredentials};
use crate::manifest::{InstallTarget, ManifestFile, SinkConfig};
use crate::sink;

pub async fn run(
    manifest: &ManifestFile,
    sink_override: Option<&str>,
    no_install: bool,
) -> Result<()> {
    println!("{} {}", ">>".dimmed(), format!("Creating GitHub App: {}", manifest.name).bold());

    // Pick a port + start the listener BEFORE opening the browser, so we don't
    // race the redirect.
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let port = listener.local_addr()?.port();
    let redirect_url = format!("http://localhost:{port}/callback");

    let manifest_json = manifest.manifest_json(&redirect_url)?;
    let manifest_url = manifest.manifest_url();

    // Spawn the local HTML page that auto-submits the manifest to GitHub.
    let bootstrap_url = format!("http://localhost:{port}/start");
    let manifest_json_clone = manifest_json.clone();
    let manifest_url_clone = manifest_url.clone();
    let listener_handle = tokio::spawn(async move {
        serve_flow(listener, &manifest_json_clone, &manifest_url_clone).await
    });

    println!(
        "  {} {}",
        "".dimmed(),
        format!("opening {} in your browser", bootstrap_url).dimmed()
    );
    if let Err(e) = opener::open(&bootstrap_url) {
        eprintln!(
            "  {} couldn't auto-open browser ({e}); open {} manually",
            "warn".yellow(),
            bootstrap_url
        );
    }
    println!(
        "  {} {}",
        "".dimmed(),
        "click 'Create GitHub App for ...' in the browser tab".dimmed()
    );

    let code = listener_handle.await??;
    println!("  {} captured manifest code", "".green());

    let mut creds: AppCredentials = exchange_manifest_code(&code).await?;
    println!(
        "  {} app created — id={} slug={}",
        "".green(),
        creds.id,
        creds.slug.cyan()
    );

    // Step 2 (optional): walk the operator through one install click, then
    // API-add any additional repos declared in the manifest.
    if !no_install {
        prompt_install(&mut creds, manifest).await?;
        if !manifest.install_repos.is_empty() {
            println!(
                "  {} adding {} repo(s) to installation via API",
                "".dimmed(),
                manifest.install_repos.len()
            );
            install_on_repos(
                &creds,
                &manifest.owner,
                &creds.slug.clone(),
                &manifest.install_repos,
            )
            .await?;
        }
    }

    // Resolve sink (CLI override > manifest)
    let sink = if let Some(name) = sink_override {
        match name {
            "stdout" => SinkConfig::Stdout,
            other => bail!("unknown --sink override: {other} (supported: stdout)"),
        }
    } else {
        manifest.sink.clone()
    };

    sink::write(&sink, &creds)?;
    println!("{}", "Done.".green().bold());
    Ok(())
}

/// One-shot HTTP listener:
///   GET /start    → returns auto-submitting HTML form posting the manifest to GitHub
///   GET /callback → captures the `code` query param + returns success page
///
/// Returns the captured code string. Errors if no callback arrives within 5min.
async fn serve_flow(
    listener: TcpListener,
    manifest_json: &str,
    manifest_url: &str,
) -> Result<String> {
    let timeout = tokio::time::sleep(Duration::from_secs(300));
    tokio::pin!(timeout);

    loop {
        tokio::select! {
            _ = &mut timeout => {
                bail!("timed out waiting 5min for GitHub redirect — check your browser tab")
            }
            accept = listener.accept() => {
                let (mut stream, _) = accept?;
                let mut buf = vec![0u8; 8192];
                let n = stream.read(&mut buf).await?;
                let req = String::from_utf8_lossy(&buf[..n]);
                let first_line = req.lines().next().unwrap_or("");

                if first_line.starts_with("GET /start") {
                    let html = render_start_html(manifest_json, manifest_url);
                    let resp = format!(
                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
                        html.len()
                    );
                    stream.write_all(resp.as_bytes()).await?;
                    stream.flush().await?;
                    continue;
                }

                if first_line.starts_with("GET /callback") {
                    let code = extract_code(first_line)
                        .ok_or_else(|| anyhow!("no `code` in redirect URL: {first_line}"))?;
                    let html = render_callback_html();
                    let resp = format!(
                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
                        html.len()
                    );
                    stream.write_all(resp.as_bytes()).await?;
                    stream.flush().await?;
                    return Ok(code);
                }

                // Unknown path — 404 + keep listening
                let body = "not found";
                let resp = format!(
                    "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                    body.len()
                );
                stream.write_all(resp.as_bytes()).await?;
            }
        }
    }
}

fn render_start_html(manifest_json: &str, manifest_url: &str) -> String {
    let escaped = manifest_json
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;");
    format!(
        r#"<!doctype html>
<html><head><title>github-app-forge — submitting manifest</title></head>
<body style="font-family: system-ui; padding: 2rem;">
<p>Submitting manifest to GitHub… (auto-redirect)</p>
<form id="f" action="{manifest_url}" method="post">
  <input type="hidden" name="manifest" value="{escaped}" />
</form>
<script>document.getElementById('f').submit();</script>
</body></html>"#
    )
}

fn render_callback_html() -> String {
    "<!doctype html><html><body style=\"font-family: system-ui; padding: 2rem;\">\
     <h2>App created.</h2><p>You can close this tab; \
     <code>github-app-forge</code> has captured the credentials.</p></body></html>"
        .to_string()
}

fn extract_code(request_line: &str) -> Option<String> {
    // GET /callback?code=abc123 HTTP/1.1
    let path = request_line.split_whitespace().nth(1)?;
    let (_, query) = path.split_once('?')?;
    for pair in query.split('&') {
        if let Some(("code", v)) = pair.split_once('=') {
            return Some(v.to_string());
        }
    }
    None
}

async fn prompt_install(creds: &mut AppCredentials, manifest: &ManifestFile) -> Result<()> {
    let install_url = match manifest.install_target {
        InstallTarget::Org => format!(
            "https://github.com/apps/{}/installations/new/permissions?target_id={}",
            creds.slug, creds.owner["id"]
        ),
        InstallTarget::User => format!(
            "https://github.com/apps/{}/installations/new",
            creds.slug
        ),
    };
    println!(
        "  {} install the app: {}",
        "".dimmed(),
        install_url.cyan()
    );
    if let Err(e) = opener::open(&install_url) {
        eprintln!(
            "  {} couldn't auto-open install URL ({e}); open it manually",
            "warn".yellow()
        );
    }
    println!("  {} waiting for installation to complete (poll every 5s, timeout 5min)…", "".dimmed());

    let deadline = std::time::Instant::now() + Duration::from_secs(300);
    while std::time::Instant::now() < deadline {
        if let Some(id) = lookup_installation_id(creds, &manifest.owner).await? {
            creds.installation_id = Some(id);
            println!("  {} installed — installation_id={id}", "".green());
            return Ok(());
        }
        tokio::time::sleep(Duration::from_secs(5)).await;
    }
    bail!("timed out waiting for app installation — re-run `github-app-forge install` later")
}