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());
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();
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()
);
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?;
}
}
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(())
}
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);
}
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('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """);
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> {
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")
}