1use 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 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 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 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 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
119async 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 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('&', "&")
183 .replace('<', "<")
184 .replace('>', ">")
185 .replace('"', """);
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 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}