1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub const HQ_URL: &str = "http://fn0-hq.fn0.dev:8080";
6const GITHUB_CLIENT_ID: &str = "Ov23liRuIJf1NSe9ccP8";
7
8#[derive(Serialize, Deserialize)]
9struct Credentials {
10 github_token: String,
11}
12
13#[derive(Deserialize)]
14struct DeviceCodeResponse {
15 device_code: String,
16 user_code: String,
17 verification_uri: String,
18 interval: u64,
19}
20
21#[derive(Deserialize)]
22struct TokenResponse {
23 access_token: Option<String>,
24 error: Option<String>,
25}
26
27#[derive(Deserialize)]
28struct DeployStartResponse {
29 presigned_url: String,
30 deploy_job_id: String,
31 subdomain: String,
32 code_id: u64,
33}
34
35#[derive(Deserialize)]
36struct DeployFinishResponse {
37 generation: u64,
38}
39
40#[derive(Deserialize)]
41struct DeployStatusResponse {
42 delivered: bool,
43 hosts_total: usize,
44 hosts_at_target: usize,
45 hosts_pending: Vec<String>,
46 hosts_quarantined: Vec<String>,
47}
48
49fn credentials_path() -> Result<PathBuf> {
50 let home = std::env::var("HOME").map_err(|_| anyhow!("Cannot find HOME directory"))?;
51 Ok(PathBuf::from(home).join(".fn0").join("credentials"))
52}
53
54fn load_credentials() -> Result<Option<Credentials>> {
55 let path = credentials_path()?;
56 if !path.exists() {
57 return Ok(None);
58 }
59 let content = std::fs::read_to_string(&path)?;
60 let creds: Credentials = serde_json::from_str(&content)?;
61 Ok(Some(creds))
62}
63
64fn save_credentials(creds: &Credentials) -> Result<()> {
65 let path = credentials_path()?;
66 if let Some(parent) = path.parent() {
67 std::fs::create_dir_all(parent)?;
68 }
69 std::fs::write(&path, serde_json::to_string_pretty(creds)?)?;
70 Ok(())
71}
72
73async fn github_device_flow() -> Result<String> {
74 let client = reqwest::Client::new();
75
76 let resp: DeviceCodeResponse = client
77 .post("https://github.com/login/device/code")
78 .header("Accept", "application/json")
79 .form(&[
80 ("client_id", GITHUB_CLIENT_ID),
81 ("scope", "read:user"),
82 ])
83 .send()
84 .await?
85 .json()
86 .await?;
87
88 println!("\nGitHub authentication required.");
89 println!("Open {} in your browser", resp.verification_uri);
90 println!("and enter the code: {}\n", resp.user_code);
91
92 let interval = std::time::Duration::from_secs(resp.interval.max(5));
93
94 loop {
95 tokio::time::sleep(interval).await;
96
97 let token_resp: TokenResponse = client
98 .post("https://github.com/login/oauth/access_token")
99 .header("Accept", "application/json")
100 .form(&[
101 ("client_id", GITHUB_CLIENT_ID),
102 ("device_code", resp.device_code.as_str()),
103 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
104 ])
105 .send()
106 .await?
107 .json()
108 .await?;
109
110 if let Some(token) = token_resp.access_token {
111 return Ok(token);
112 }
113
114 match token_resp.error.as_deref() {
115 Some("authorization_pending") => continue,
116 Some("slow_down") => {
117 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
118 continue;
119 }
120 Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
121 None => continue,
122 }
123 }
124}
125
126pub async fn get_github_token() -> Result<String> {
127 if let Some(creds) = load_credentials()? {
128 return Ok(creds.github_token);
129 }
130
131 let token = github_device_flow().await?;
132 save_credentials(&Credentials {
133 github_token: token.clone(),
134 })?;
135 println!("Authentication complete! Token saved.\n");
136
137 Ok(token)
138}
139
140pub async fn deploy(
141 project_name: &str,
142 bundle_tar_path: &Path,
143 env_content: Option<String>,
144) -> Result<()> {
145 let github_token = get_github_token().await?;
146
147 let client = reqwest::Client::new();
148
149 println!("Requesting deploy start...");
150 let start_resp: DeployStartResponse = client
151 .post(format!("{}/deploy/start", HQ_URL))
152 .json(&serde_json::json!({
153 "github_token": github_token,
154 "project_name": project_name,
155 }))
156 .send()
157 .await?
158 .error_for_status()
159 .map_err(|e| anyhow!("Deploy start failed: {}", e))?
160 .json()
161 .await?;
162
163 println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
164
165 println!("Uploading bundle...");
166 let bundle_bytes = std::fs::read(bundle_tar_path)
167 .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
168
169 client
170 .put(&start_resp.presigned_url)
171 .header("content-type", "application/x-tar")
172 .body(bundle_bytes)
173 .send()
174 .await?
175 .error_for_status()
176 .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
177
178 println!("Requesting deploy finish...");
179 let finish_resp: DeployFinishResponse = client
180 .post(format!("{}/deploy/finish", HQ_URL))
181 .json(&serde_json::json!({
182 "github_token": github_token,
183 "deploy_job_id": start_resp.deploy_job_id,
184 "subdomain": start_resp.subdomain,
185 "code_id": start_resp.code_id,
186 "env": env_content,
187 }))
188 .send()
189 .await?
190 .error_for_status()
191 .map_err(|e| anyhow!("Deploy finish failed: {}", e))?
192 .json()
193 .await?;
194
195 println!(
196 "Waiting for rollout to all workers (generation {})...",
197 finish_resp.generation
198 );
199
200 let poll_interval = std::time::Duration::from_secs(2);
201 let timeout = std::time::Duration::from_secs(300);
202 let start = std::time::Instant::now();
203 let mut last_progress: Option<(usize, usize)> = None;
204
205 loop {
206 let status: DeployStatusResponse = client
207 .get(format!(
208 "{}/deploy/status?generation={}",
209 HQ_URL, finish_resp.generation
210 ))
211 .send()
212 .await?
213 .error_for_status()
214 .map_err(|e| anyhow!("Deploy status failed: {}", e))?
215 .json()
216 .await?;
217
218 let progress = (status.hosts_at_target, status.hosts_total);
219 if last_progress != Some(progress) {
220 println!(" {}/{} hosts ready", progress.0, progress.1);
221 last_progress = Some(progress);
222 }
223
224 if status.delivered {
225 break;
226 }
227
228 if start.elapsed() > timeout {
229 return Err(anyhow!(
230 "Deploy rollout timed out after {}s. pending={:?} quarantined={:?}",
231 timeout.as_secs(),
232 status.hosts_pending,
233 status.hosts_quarantined
234 ));
235 }
236
237 tokio::time::sleep(poll_interval).await;
238 }
239
240 println!("Deploy complete!");
241
242 Ok(())
243}
244
245pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
246 let env_path = project_dir.join(".env");
247 match std::fs::read_to_string(&env_path) {
248 Ok(content) => Ok(Some(content)),
249 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
250 Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
251 }
252}
253
254pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
255 let file = std::fs::File::create(output_path)
256 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
257 let mut builder = tar::Builder::new(file);
258
259 let manifest = br#"{"kind":"wasm"}"#;
260 append_bytes(&mut builder, "manifest.json", manifest)?;
261
262 let wasm_bytes = std::fs::read(wasm_path)
263 .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
264 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
265
266 builder.finish()?;
267 Ok(())
268}
269
270pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
271 let file = std::fs::File::create(output_path)
272 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
273 let mut builder = tar::Builder::new(file);
274
275 let manifest = br#"{"kind":"forte","frontend_script_path":"/frontend.js"}"#;
276 append_bytes(&mut builder, "manifest.json", manifest)?;
277
278 let backend_wasm = dist_dir.join("backend.wasm");
279 let wasm_bytes = std::fs::read(&backend_wasm)
280 .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
281 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
282
283 let server_js = dist_dir.join("server.js");
284 let server_bytes = std::fs::read(&server_js)
285 .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
286 append_bytes(&mut builder, "frontend.js", &server_bytes)?;
287
288 let public_dir = dist_dir.join("public");
289 if public_dir.exists() {
290 for entry in walkdir::WalkDir::new(&public_dir).into_iter().filter_map(|e| e.ok()) {
291 if !entry.file_type().is_file() {
292 continue;
293 }
294 let rel = entry
295 .path()
296 .strip_prefix(&public_dir)
297 .map_err(|e| anyhow!("strip_prefix failed: {}", e))?;
298 let tar_path = format!("public/{}", rel.to_string_lossy().replace('\\', "/"));
299 let bytes = std::fs::read(entry.path())
300 .map_err(|e| anyhow!("Failed to read {}: {}", entry.path().display(), e))?;
301 append_bytes(&mut builder, &tar_path, &bytes)?;
302 }
303 }
304
305 builder.finish()?;
306 Ok(())
307}
308
309fn append_bytes<W: std::io::Write>(
310 builder: &mut tar::Builder<W>,
311 path: &str,
312 data: &[u8],
313) -> Result<()> {
314 let mut header = tar::Header::new_gnu();
315 header.set_size(data.len() as u64);
316 header.set_mode(0o644);
317 header.set_cksum();
318 builder
319 .append_data(&mut header, path, data)
320 .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
321 Ok(())
322}