Skip to main content

github_app_forge/
flow.rs

1//! Manifest-flow orchestrator.
2//!
3//! GitHub doesn't expose a pure REST API for creating apps — the only
4//! programmatic path is the Manifest flow, which requires exactly one browser
5//! confirmation. The flow:
6//!
7//!   1. Tool starts a one-shot HTTP listener on an ephemeral localhost port.
8//!   2. Tool generates an HTML page with a hidden-form POST to GitHub's
9//!      manifest endpoint (`https://github.com/.../settings/apps/new`),
10//!      auto-submits via JS. Manifest body includes `redirect_url=http://localhost:<port>/callback`.
11//!   3. Tool opens the operator's default browser to that page (1 click on the
12//!      "Create from manifest" confirmation in GitHub).
13//!   4. GitHub redirects the browser to the localhost listener with `?code=<temp>`.
14//!   5. Listener captures the code, returns "you can close this tab".
15//!   6. Tool exchanges the code via `POST /app-manifests/{code}/conversions`.
16//!   7. Tool optionally opens the install URL with target_id pre-filled (one
17//!      more click) and polls `GET /app/installations` until the installation
18//!      shows up.
19//!   8. Tool writes credentials (id, slug, pem, installation_id) to the sink.
20
21use anyhow::{anyhow, bail, Result};
22use colored::Colorize;
23use std::time::Duration;
24use tokio::io::{AsyncReadExt, AsyncWriteExt};
25use tokio::net::TcpListener;
26
27use crate::client::{exchange_manifest_code, install_on_repos, lookup_installation_id, AppCredentials};
28use crate::manifest::{InstallTarget, ManifestFile, SinkConfig};
29use crate::sink;
30
31pub async fn run(
32    manifest: &ManifestFile,
33    sink_override: Option<&str>,
34    no_install: bool,
35) -> Result<()> {
36    println!("{} {}", ">>".dimmed(), format!("Creating GitHub App: {}", manifest.name).bold());
37
38    // Pick a port + start the listener BEFORE opening the browser, so we don't
39    // race the redirect.
40    let listener = TcpListener::bind("127.0.0.1:0").await?;
41    let port = listener.local_addr()?.port();
42    let redirect_url = format!("http://localhost:{port}/callback");
43
44    let manifest_json = manifest.manifest_json(&redirect_url)?;
45    let manifest_url = manifest.manifest_url();
46
47    // Spawn the local HTML page that auto-submits the manifest to GitHub.
48    let bootstrap_url = format!("http://localhost:{port}/start");
49    let manifest_json_clone = manifest_json.clone();
50    let manifest_url_clone = manifest_url.clone();
51    let listener_handle = tokio::spawn(async move {
52        serve_flow(listener, &manifest_json_clone, &manifest_url_clone).await
53    });
54
55    println!(
56        "  {} {}",
57        "→".dimmed(),
58        format!("opening {} in your browser", bootstrap_url).dimmed()
59    );
60    if let Err(e) = opener::open(&bootstrap_url) {
61        eprintln!(
62            "  {} couldn't auto-open browser ({e}); open {} manually",
63            "warn".yellow(),
64            bootstrap_url
65        );
66    }
67    println!(
68        "  {} {}",
69        "→".dimmed(),
70        "click 'Create GitHub App for ...' in the browser tab".dimmed()
71    );
72
73    let code = listener_handle.await??;
74    println!("  {} captured manifest code", "✓".green());
75
76    let mut creds: AppCredentials = exchange_manifest_code(&code).await?;
77    println!(
78        "  {} app created — id={} slug={}",
79        "✓".green(),
80        creds.id,
81        creds.slug.cyan()
82    );
83
84    // Step 2 (optional): walk the operator through one install click, then
85    // API-add any additional repos declared in the manifest.
86    if !no_install {
87        prompt_install(&mut creds, manifest).await?;
88        if !manifest.install_repos.is_empty() {
89            println!(
90                "  {} adding {} repo(s) to installation via API",
91                "→".dimmed(),
92                manifest.install_repos.len()
93            );
94            install_on_repos(
95                &creds,
96                &manifest.owner,
97                &creds.slug.clone(),
98                &manifest.install_repos,
99            )
100            .await?;
101        }
102    }
103
104    // Resolve sink (CLI override > manifest)
105    let sink = if let Some(name) = sink_override {
106        match name {
107            "stdout" => SinkConfig::Stdout,
108            other => bail!("unknown --sink override: {other} (supported: stdout)"),
109        }
110    } else {
111        manifest.sink.clone()
112    };
113
114    sink::write(&sink, &creds)?;
115    println!("{}", "Done.".green().bold());
116    Ok(())
117}
118
119/// One-shot HTTP listener:
120///   GET /start    → returns auto-submitting HTML form posting the manifest to GitHub
121///   GET /callback → captures the `code` query param + returns success page
122///
123/// Returns the captured code string. Errors if no callback arrives within 5min.
124async fn serve_flow(
125    listener: TcpListener,
126    manifest_json: &str,
127    manifest_url: &str,
128) -> Result<String> {
129    let timeout = tokio::time::sleep(Duration::from_secs(300));
130    tokio::pin!(timeout);
131
132    loop {
133        tokio::select! {
134            _ = &mut timeout => {
135                bail!("timed out waiting 5min for GitHub redirect — check your browser tab")
136            }
137            accept = listener.accept() => {
138                let (mut stream, _) = accept?;
139                let mut buf = vec![0u8; 8192];
140                let n = stream.read(&mut buf).await?;
141                let req = String::from_utf8_lossy(&buf[..n]);
142                let first_line = req.lines().next().unwrap_or("");
143
144                if first_line.starts_with("GET /start") {
145                    let html = render_start_html(manifest_json, manifest_url);
146                    let resp = format!(
147                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
148                        html.len()
149                    );
150                    stream.write_all(resp.as_bytes()).await?;
151                    stream.flush().await?;
152                    continue;
153                }
154
155                if first_line.starts_with("GET /callback") {
156                    let code = extract_code(first_line)
157                        .ok_or_else(|| anyhow!("no `code` in redirect URL: {first_line}"))?;
158                    let html = render_callback_html();
159                    let resp = format!(
160                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
161                        html.len()
162                    );
163                    stream.write_all(resp.as_bytes()).await?;
164                    stream.flush().await?;
165                    return Ok(code);
166                }
167
168                // Unknown path — 404 + keep listening
169                let body = "not found";
170                let resp = format!(
171                    "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
172                    body.len()
173                );
174                stream.write_all(resp.as_bytes()).await?;
175            }
176        }
177    }
178}
179
180fn render_start_html(manifest_json: &str, manifest_url: &str) -> String {
181    let escaped = manifest_json
182        .replace('&', "&amp;")
183        .replace('<', "&lt;")
184        .replace('>', "&gt;")
185        .replace('"', "&quot;");
186    format!(
187        r#"<!doctype html>
188<html><head><title>github-app-forge — submitting manifest</title></head>
189<body style="font-family: system-ui; padding: 2rem;">
190<p>Submitting manifest to GitHub… (auto-redirect)</p>
191<form id="f" action="{manifest_url}" method="post">
192  <input type="hidden" name="manifest" value="{escaped}" />
193</form>
194<script>document.getElementById('f').submit();</script>
195</body></html>"#
196    )
197}
198
199fn render_callback_html() -> String {
200    "<!doctype html><html><body style=\"font-family: system-ui; padding: 2rem;\">\
201     <h2>App created.</h2><p>You can close this tab; \
202     <code>github-app-forge</code> has captured the credentials.</p></body></html>"
203        .to_string()
204}
205
206fn extract_code(request_line: &str) -> Option<String> {
207    // GET /callback?code=abc123 HTTP/1.1
208    let path = request_line.split_whitespace().nth(1)?;
209    let (_, query) = path.split_once('?')?;
210    for pair in query.split('&') {
211        if let Some(("code", v)) = pair.split_once('=') {
212            return Some(v.to_string());
213        }
214    }
215    None
216}
217
218async fn prompt_install(creds: &mut AppCredentials, manifest: &ManifestFile) -> Result<()> {
219    let install_url = match manifest.install_target {
220        InstallTarget::Org => format!(
221            "https://github.com/apps/{}/installations/new/permissions?target_id={}",
222            creds.slug, creds.owner["id"]
223        ),
224        InstallTarget::User => format!(
225            "https://github.com/apps/{}/installations/new",
226            creds.slug
227        ),
228    };
229    println!(
230        "  {} install the app: {}",
231        "→".dimmed(),
232        install_url.cyan()
233    );
234    if let Err(e) = opener::open(&install_url) {
235        eprintln!(
236            "  {} couldn't auto-open install URL ({e}); open it manually",
237            "warn".yellow()
238        );
239    }
240    println!("  {} waiting for installation to complete (poll every 5s, timeout 5min)…", "→".dimmed());
241
242    let deadline = std::time::Instant::now() + Duration::from_secs(300);
243    while std::time::Instant::now() < deadline {
244        if let Some(id) = lookup_installation_id(creds, &manifest.owner).await? {
245            creds.installation_id = Some(id);
246            println!("  {} installed — installation_id={id}", "✓".green());
247            return Ok(());
248        }
249        tokio::time::sleep(Duration::from_secs(5)).await;
250    }
251    bail!("timed out waiting for app installation — re-run `github-app-forge install` later")
252}