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 job_id: String,
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 #[serde(default)]
48 job: Option<DeployJobStatus>,
49}
50
51#[derive(Deserialize)]
52struct DeployJobStatus {
53 phase: String,
54 #[serde(default)]
55 #[allow(dead_code)]
56 generation: Option<u64>,
57 #[serde(default)]
58 last_error: Option<String>,
59}
60
61fn credentials_path() -> Result<PathBuf> {
62 let home = std::env::var("HOME").map_err(|_| anyhow!("Cannot find HOME directory"))?;
63 Ok(PathBuf::from(home).join(".fn0").join("credentials"))
64}
65
66fn load_credentials() -> Result<Option<Credentials>> {
67 let path = credentials_path()?;
68 if !path.exists() {
69 return Ok(None);
70 }
71 let content = std::fs::read_to_string(&path)?;
72 let creds: Credentials = serde_json::from_str(&content)?;
73 Ok(Some(creds))
74}
75
76fn save_credentials(creds: &Credentials) -> Result<()> {
77 let path = credentials_path()?;
78 if let Some(parent) = path.parent() {
79 std::fs::create_dir_all(parent)?;
80 }
81 std::fs::write(&path, serde_json::to_string_pretty(creds)?)?;
82 Ok(())
83}
84
85async fn github_device_flow() -> Result<String> {
86 let client = reqwest::Client::new();
87
88 let resp: DeviceCodeResponse = client
89 .post("https://github.com/login/device/code")
90 .header("Accept", "application/json")
91 .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
92 .send()
93 .await?
94 .json()
95 .await?;
96
97 println!("\nGitHub authentication required.");
98 println!("Open {} in your browser", resp.verification_uri);
99 println!("and enter the code: {}\n", resp.user_code);
100
101 let interval = std::time::Duration::from_secs(resp.interval.max(5));
102
103 loop {
104 tokio::time::sleep(interval).await;
105
106 let token_resp: TokenResponse = client
107 .post("https://github.com/login/oauth/access_token")
108 .header("Accept", "application/json")
109 .form(&[
110 ("client_id", GITHUB_CLIENT_ID),
111 ("device_code", resp.device_code.as_str()),
112 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
113 ])
114 .send()
115 .await?
116 .json()
117 .await?;
118
119 if let Some(token) = token_resp.access_token {
120 return Ok(token);
121 }
122
123 match token_resp.error.as_deref() {
124 Some("authorization_pending") => continue,
125 Some("slow_down") => {
126 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
127 continue;
128 }
129 Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
130 None => continue,
131 }
132 }
133}
134
135pub async fn get_github_token() -> Result<String> {
136 if let Some(creds) = load_credentials()? {
137 return Ok(creds.github_token);
138 }
139
140 let token = github_device_flow().await?;
141 save_credentials(&Credentials {
142 github_token: token.clone(),
143 })?;
144 println!("Authentication complete! Token saved.\n");
145
146 Ok(token)
147}
148
149pub async fn deploy(
150 project_name: &str,
151 bundle_tar_path: &Path,
152 env_content: Option<String>,
153) -> Result<()> {
154 let github_token = get_github_token().await?;
155
156 let client = reqwest::Client::new();
157
158 println!("Requesting deploy start...");
159 let start_resp: DeployStartResponse = client
160 .post(format!("{}/deploy/start", HQ_URL))
161 .json(&serde_json::json!({
162 "github_token": github_token,
163 "project_name": project_name,
164 }))
165 .send()
166 .await?
167 .error_for_status()
168 .map_err(|e| anyhow!("Deploy start failed: {}", e))?
169 .json()
170 .await?;
171
172 println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
173
174 println!("Uploading bundle...");
175 let bundle_bytes = std::fs::read(bundle_tar_path)
176 .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
177
178 client
179 .put(&start_resp.presigned_url)
180 .header("content-type", "application/x-tar")
181 .body(bundle_bytes)
182 .send()
183 .await?
184 .error_for_status()
185 .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
186
187 println!("Requesting deploy finish...");
188 let finish_resp: DeployFinishResponse = client
189 .post(format!("{}/deploy/finish", HQ_URL))
190 .json(&serde_json::json!({
191 "github_token": github_token,
192 "deploy_job_id": start_resp.deploy_job_id,
193 "subdomain": start_resp.subdomain,
194 "code_id": start_resp.code_id,
195 "env": env_content,
196 }))
197 .send()
198 .await?
199 .error_for_status()
200 .map_err(|e| anyhow!("Deploy finish failed: {}", e))?
201 .json()
202 .await?;
203
204 println!("Deploy job queued: {}", finish_resp.job_id);
205
206 let poll_interval = std::time::Duration::from_secs(2);
207 let timeout = std::time::Duration::from_secs(600);
208 let start = std::time::Instant::now();
209 let mut last_phase: Option<String> = None;
210 let mut last_progress: Option<(usize, usize)> = None;
211
212 loop {
213 let status: DeployStatusResponse = client
214 .get(format!(
215 "{}/deploy/status?job_id={}",
216 HQ_URL, finish_resp.job_id
217 ))
218 .send()
219 .await?
220 .error_for_status()
221 .map_err(|e| anyhow!("Deploy status failed: {}", e))?
222 .json()
223 .await?;
224
225 if let Some(job) = status.job.as_ref() {
226 if last_phase.as_deref() != Some(job.phase.as_str()) {
227 println!(" phase: {}", job.phase);
228 last_phase = Some(job.phase.clone());
229 }
230 if job.phase == "failed" {
231 let msg = job
232 .last_error
233 .clone()
234 .unwrap_or_else(|| "unknown error".to_string());
235 return Err(anyhow!("Deploy job failed: {}", msg));
236 }
237 }
238
239 let progress = (status.hosts_at_target, status.hosts_total);
240 if last_progress != Some(progress) {
241 println!(" {}/{} hosts ready", progress.0, progress.1);
242 last_progress = Some(progress);
243 }
244
245 let phase_done = status
246 .job
247 .as_ref()
248 .map(|j| j.phase == "done")
249 .unwrap_or(false);
250 if phase_done && status.delivered {
251 break;
252 }
253
254 if start.elapsed() > timeout {
255 return Err(anyhow!(
256 "Deploy timed out after {}s. phase={:?} pending={:?} quarantined={:?}",
257 timeout.as_secs(),
258 status.job.as_ref().map(|j| j.phase.clone()),
259 status.hosts_pending,
260 status.hosts_quarantined
261 ));
262 }
263
264 tokio::time::sleep(poll_interval).await;
265 }
266
267 println!("Deploy complete!");
268
269 Ok(())
270}
271
272#[derive(Deserialize)]
273struct AdminGrantResponse {
274 token: String,
275 subdomain: String,
276 #[allow(dead_code)]
277 expires_at: i64,
278}
279
280pub struct AdminRunOutput {
281 pub status: u16,
282 pub content_type: Option<String>,
283 pub body: Vec<u8>,
284}
285
286#[derive(Deserialize)]
287struct RenameStartResponse {
288 job_id: String,
289 #[serde(default)]
290 already_running: bool,
291}
292
293#[derive(Deserialize)]
294struct RenameStatusResponse {
295 job_id: String,
296 phase: String,
297 attempts: u32,
298 last_error: Option<String>,
299 is_terminal: bool,
300}
301
302pub async fn rename(project_name: &str, new_project_name: &str) -> Result<()> {
303 let github_token = get_github_token().await?;
304
305 let client = reqwest::Client::new();
306
307 println!("Requesting rename start...");
308 let start_resp: RenameStartResponse = client
309 .post(format!("{}/rename/start", HQ_URL))
310 .json(&serde_json::json!({
311 "github_token": github_token,
312 "project_name": project_name,
313 "new_project_name": new_project_name,
314 }))
315 .send()
316 .await?
317 .error_for_status()
318 .map_err(|e| anyhow!("Rename start failed: {}", e))?
319 .json()
320 .await?;
321
322 if start_resp.already_running {
323 println!("Following existing rename job: {}", start_resp.job_id);
324 } else {
325 println!("Rename job queued: {}", start_resp.job_id);
326 }
327
328 let poll_interval = std::time::Duration::from_secs(2);
329 let timeout = std::time::Duration::from_secs(900);
330 let start = std::time::Instant::now();
331 let mut last_phase: Option<String> = None;
332
333 loop {
334 let status: RenameStatusResponse = client
335 .get(format!(
336 "{}/rename/status?job_id={}",
337 HQ_URL, start_resp.job_id
338 ))
339 .send()
340 .await?
341 .error_for_status()
342 .map_err(|e| anyhow!("Rename status failed: {}", e))?
343 .json()
344 .await?;
345
346 if last_phase.as_deref() != Some(status.phase.as_str()) {
347 println!(" phase: {} (attempts={})", status.phase, status.attempts);
348 last_phase = Some(status.phase.clone());
349 }
350
351 if status.phase == "failed" {
352 let msg = status
353 .last_error
354 .unwrap_or_else(|| "unknown error".to_string());
355 return Err(anyhow!("Rename job failed: {}", msg));
356 }
357
358 if status.is_terminal && status.phase == "done" {
359 break;
360 }
361
362 if start.elapsed() > timeout {
363 return Err(anyhow!(
364 "Rename timed out after {}s. phase={:?}",
365 timeout.as_secs(),
366 status.phase
367 ));
368 }
369
370 let _ = status.job_id;
371 tokio::time::sleep(poll_interval).await;
372 }
373
374 println!("Rename complete!");
375 Ok(())
376}
377
378pub async fn admin_run(
379 project_name: &str,
380 task: &str,
381 input_body: Vec<u8>,
382 timeout_secs: u64,
383) -> Result<AdminRunOutput> {
384 let github_token = get_github_token().await?;
385
386 let client = reqwest::Client::builder()
387 .timeout(std::time::Duration::from_secs(timeout_secs))
388 .build()?;
389
390 let grant: AdminGrantResponse = client
391 .post(format!("{}/admin/grant", HQ_URL))
392 .json(&serde_json::json!({
393 "github_token": github_token,
394 "project_name": project_name,
395 "task": task,
396 }))
397 .send()
398 .await?
399 .error_for_status()
400 .map_err(|e| anyhow!("Admin grant request failed: {}", e))?
401 .json()
402 .await?;
403
404 let url = format!("https://{}.fn0.dev/__forte_admin/{}", grant.subdomain, task);
405 let resp = client
406 .post(&url)
407 .header("Authorization", format!("FortoAdmin {}", grant.token))
408 .header("Content-Type", "application/json")
409 .body(input_body)
410 .send()
411 .await?;
412
413 let status = resp.status().as_u16();
414 let content_type = resp
415 .headers()
416 .get("content-type")
417 .and_then(|v| v.to_str().ok())
418 .map(|s| s.to_string());
419 let body = resp.bytes().await?.to_vec();
420
421 Ok(AdminRunOutput {
422 status,
423 content_type,
424 body,
425 })
426}
427
428pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
429 let env_path = project_dir.join(".env");
430 match std::fs::read_to_string(&env_path) {
431 Ok(content) => Ok(Some(content)),
432 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
433 Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
434 }
435}
436
437pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
438 let file = std::fs::File::create(output_path)
439 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
440 let mut builder = tar::Builder::new(file);
441
442 let manifest = br#"{"kind":"wasm"}"#;
443 append_bytes(&mut builder, "manifest.json", manifest)?;
444
445 let wasm_bytes = std::fs::read(wasm_path)
446 .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
447 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
448
449 builder.finish()?;
450 Ok(())
451}
452
453pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
454 let file = std::fs::File::create(output_path)
455 .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
456 let mut builder = tar::Builder::new(file);
457
458 let manifest = br#"{"kind":"wasmjs"}"#;
459 append_bytes(&mut builder, "manifest.json", manifest)?;
460
461 let backend_wasm = dist_dir.join("backend.wasm");
462 let wasm_bytes = std::fs::read(&backend_wasm)
463 .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
464 append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
465
466 let server_js = dist_dir.join("server.js");
467 let server_bytes = std::fs::read(&server_js)
468 .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
469 append_bytes(&mut builder, "entry.js", &server_bytes)?;
470
471 builder.finish()?;
472 Ok(())
473}
474
475fn append_bytes<W: std::io::Write>(
476 builder: &mut tar::Builder<W>,
477 path: &str,
478 data: &[u8],
479) -> Result<()> {
480 let mut header = tar::Header::new_gnu();
481 header.set_size(data.len() as u64);
482 header.set_mode(0o644);
483 header.set_cksum();
484 builder
485 .append_data(&mut header, path, data)
486 .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
487 Ok(())
488}