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(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
80 .send()
81 .await?
82 .json()
83 .await?;
84
85 println!("\nGitHub authentication required.");
86 println!("Open {} in your browser", resp.verification_uri);
87 println!("and enter the code: {}\n", resp.user_code);
88
89 let interval = std::time::Duration::from_secs(resp.interval.max(5));
90
91 loop {
92 tokio::time::sleep(interval).await;
93
94 let token_resp: TokenResponse = client
95 .post("https://github.com/login/oauth/access_token")
96 .header("Accept", "application/json")
97 .form(&[
98 ("client_id", GITHUB_CLIENT_ID),
99 ("device_code", resp.device_code.as_str()),
100 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
101 ])
102 .send()
103 .await?
104 .json()
105 .await?;
106
107 if let Some(token) = token_resp.access_token {
108 return Ok(token);
109 }
110
111 match token_resp.error.as_deref() {
112 Some("authorization_pending") => continue,
113 Some("slow_down") => {
114 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
115 continue;
116 }
117 Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
118 None => continue,
119 }
120 }
121}
122
123pub async fn get_github_token() -> Result<String> {
124 if let Some(creds) = load_credentials()? {
125 return Ok(creds.github_token);
126 }
127
128 let token = github_device_flow().await?;
129 save_credentials(&Credentials {
130 github_token: token.clone(),
131 })?;
132 println!("Authentication complete! Token saved.\n");
133
134 Ok(token)
135}
136
137pub async fn deploy(
138 project_name: &str,
139 bundle_tar_path: &Path,
140 env_content: Option<String>,
141) -> Result<()> {
142 let github_token = get_github_token().await?;
143
144 let client = reqwest::Client::new();
145
146 println!("Requesting deploy start...");
147 let start_resp: DeployStartResponse = client
148 .post(format!("{}/deploy/start", HQ_URL))
149 .json(&serde_json::json!({
150 "github_token": github_token,
151 "project_name": project_name,
152 }))
153 .send()
154 .await?
155 .error_for_status()
156 .map_err(|e| anyhow!("Deploy start failed: {}", e))?
157 .json()
158 .await?;
159
160 println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
161
162 println!("Uploading bundle...");
163 let bundle_bytes = std::fs::read(bundle_tar_path)
164 .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
165
166 client
167 .put(&start_resp.presigned_url)
168 .header("content-type", "application/x-tar")
169 .body(bundle_bytes)
170 .send()
171 .await?
172 .error_for_status()
173 .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
174
175 println!("Requesting deploy finish...");
176 let finish_resp: DeployFinishResponse = client
177 .post(format!("{}/deploy/finish", HQ_URL))
178 .json(&serde_json::json!({
179 "github_token": github_token,
180 "deploy_job_id": start_resp.deploy_job_id,
181 "subdomain": start_resp.subdomain,
182 "code_id": start_resp.code_id,
183 "env": env_content,
184 }))
185 .send()
186 .await?
187 .error_for_status()
188 .map_err(|e| anyhow!("Deploy finish failed: {}", e))?
189 .json()
190 .await?;
191
192 println!(
193 "Waiting for rollout to all workers (generation {})...",
194 finish_resp.generation
195 );
196
197 let poll_interval = std::time::Duration::from_secs(2);
198 let timeout = std::time::Duration::from_secs(300);
199 let start = std::time::Instant::now();
200 let mut last_progress: Option<(usize, usize)> = None;
201
202 loop {
203 let status: DeployStatusResponse = client
204 .get(format!(
205 "{}/deploy/status?generation={}",
206 HQ_URL, finish_resp.generation
207 ))
208 .send()
209 .await?
210 .error_for_status()
211 .map_err(|e| anyhow!("Deploy status failed: {}", e))?
212 .json()
213 .await?;
214
215 let progress = (status.hosts_at_target, status.hosts_total);
216 if last_progress != Some(progress) {
217 println!(" {}/{} hosts ready", progress.0, progress.1);
218 last_progress = Some(progress);
219 }
220
221 if status.delivered {
222 break;
223 }
224
225 if start.elapsed() > timeout {
226 return Err(anyhow!(
227 "Deploy rollout timed out after {}s. pending={:?} quarantined={:?}",
228 timeout.as_secs(),
229 status.hosts_pending,
230 status.hosts_quarantined
231 ));
232 }
233
234 tokio::time::sleep(poll_interval).await;
235 }
236
237 println!("Deploy complete!");
238
239 Ok(())
240}
241
242pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
243 let env_path = project_dir.join(".env");
244 match std::fs::read_to_string(&env_path) {
245 Ok(content) => Ok(Some(content)),
246 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
247 Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
248 }
249}
250
251pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
252 let file = std::fs::File::create(output_path)
253 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
254 let mut builder = tar::Builder::new(file);
255
256 let manifest = br#"{"kind":"wasm"}"#;
257 append_bytes(&mut builder, "manifest.json", manifest)?;
258
259 let wasm_bytes = std::fs::read(wasm_path)
260 .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
261 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
262
263 builder.finish()?;
264 Ok(())
265}
266
267pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
268 let file = std::fs::File::create(output_path)
269 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
270 let mut builder = tar::Builder::new(file);
271
272 let manifest = br#"{"kind":"wasmjs"}"#;
273 append_bytes(&mut builder, "manifest.json", manifest)?;
274
275 let backend_wasm = dist_dir.join("backend.wasm");
276 let wasm_bytes = std::fs::read(&backend_wasm)
277 .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
278 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
279
280 let server_js = dist_dir.join("server.js");
281 let server_bytes = std::fs::read(&server_js)
282 .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
283 append_bytes(&mut builder, "entry.js", &server_bytes)?;
284
285 builder.finish()?;
286 Ok(())
287}
288
289fn append_bytes<W: std::io::Write>(
290 builder: &mut tar::Builder<W>,
291 path: &str,
292 data: &[u8],
293) -> Result<()> {
294 let mut header = tar::Header::new_gnu();
295 header.set_size(data.len() as u64);
296 header.set_mode(0o644);
297 header.set_cksum();
298 builder
299 .append_data(&mut header, path, data)
300 .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
301 Ok(())
302}