Skip to main content

fn0_deploy/
lib.rs

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
35fn credentials_path() -> Result<PathBuf> {
36    let home = std::env::var("HOME").map_err(|_| anyhow!("Cannot find HOME directory"))?;
37    Ok(PathBuf::from(home).join(".fn0").join("credentials"))
38}
39
40fn load_credentials() -> Result<Option<Credentials>> {
41    let path = credentials_path()?;
42    if !path.exists() {
43        return Ok(None);
44    }
45    let content = std::fs::read_to_string(&path)?;
46    let creds: Credentials = serde_json::from_str(&content)?;
47    Ok(Some(creds))
48}
49
50fn save_credentials(creds: &Credentials) -> Result<()> {
51    let path = credentials_path()?;
52    if let Some(parent) = path.parent() {
53        std::fs::create_dir_all(parent)?;
54    }
55    std::fs::write(&path, serde_json::to_string_pretty(creds)?)?;
56    Ok(())
57}
58
59async fn github_device_flow() -> Result<String> {
60    let client = reqwest::Client::new();
61
62    let resp: DeviceCodeResponse = client
63        .post("https://github.com/login/device/code")
64        .header("Accept", "application/json")
65        .form(&[
66            ("client_id", GITHUB_CLIENT_ID),
67            ("scope", "read:user"),
68        ])
69        .send()
70        .await?
71        .json()
72        .await?;
73
74    println!("\nGitHub authentication required.");
75    println!("Open {} in your browser", resp.verification_uri);
76    println!("and enter the code: {}\n", resp.user_code);
77
78    let interval = std::time::Duration::from_secs(resp.interval.max(5));
79
80    loop {
81        tokio::time::sleep(interval).await;
82
83        let token_resp: TokenResponse = client
84            .post("https://github.com/login/oauth/access_token")
85            .header("Accept", "application/json")
86            .form(&[
87                ("client_id", GITHUB_CLIENT_ID),
88                ("device_code", resp.device_code.as_str()),
89                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
90            ])
91            .send()
92            .await?
93            .json()
94            .await?;
95
96        if let Some(token) = token_resp.access_token {
97            return Ok(token);
98        }
99
100        match token_resp.error.as_deref() {
101            Some("authorization_pending") => continue,
102            Some("slow_down") => {
103                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
104                continue;
105            }
106            Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
107            None => continue,
108        }
109    }
110}
111
112pub async fn get_github_token() -> Result<String> {
113    if let Some(creds) = load_credentials()? {
114        return Ok(creds.github_token);
115    }
116
117    let token = github_device_flow().await?;
118    save_credentials(&Credentials {
119        github_token: token.clone(),
120    })?;
121    println!("Authentication complete! Token saved.\n");
122
123    Ok(token)
124}
125
126pub async fn deploy(
127    project_name: &str,
128    bundle_tar_path: &Path,
129    env_content: Option<String>,
130) -> Result<()> {
131    let github_token = get_github_token().await?;
132
133    let client = reqwest::Client::new();
134
135    println!("Requesting deploy start...");
136    let start_resp: DeployStartResponse = client
137        .post(format!("{}/deploy/start", HQ_URL))
138        .json(&serde_json::json!({
139            "github_token": github_token,
140            "project_name": project_name,
141        }))
142        .send()
143        .await?
144        .error_for_status()
145        .map_err(|e| anyhow!("Deploy start failed: {}", e))?
146        .json()
147        .await?;
148
149    println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
150
151    println!("Uploading bundle...");
152    let bundle_bytes = std::fs::read(bundle_tar_path)
153        .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
154
155    client
156        .put(&start_resp.presigned_url)
157        .header("content-type", "application/x-tar")
158        .body(bundle_bytes)
159        .send()
160        .await?
161        .error_for_status()
162        .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
163
164    println!("Requesting deploy finish...");
165    client
166        .post(format!("{}/deploy/finish", HQ_URL))
167        .json(&serde_json::json!({
168            "github_token": github_token,
169            "deploy_job_id": start_resp.deploy_job_id,
170            "subdomain": start_resp.subdomain,
171            "code_id": start_resp.code_id,
172            "env": env_content,
173        }))
174        .send()
175        .await?
176        .error_for_status()
177        .map_err(|e| anyhow!("Deploy finish failed: {}", e))?;
178
179    println!("Deploy complete!");
180
181    Ok(())
182}
183
184pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
185    let env_path = project_dir.join(".env");
186    match std::fs::read_to_string(&env_path) {
187        Ok(content) => Ok(Some(content)),
188        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
189        Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
190    }
191}
192
193pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
194    let file = std::fs::File::create(output_path)
195        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
196    let mut builder = tar::Builder::new(file);
197
198    let manifest = br#"{"kind":"wasm"}"#;
199    append_bytes(&mut builder, "manifest.json", manifest)?;
200
201    let wasm_bytes = std::fs::read(wasm_path)
202        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
203    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
204
205    builder.finish()?;
206    Ok(())
207}
208
209pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
210    let file = std::fs::File::create(output_path)
211        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
212    let mut builder = tar::Builder::new(file);
213
214    let manifest = br#"{"kind":"forte","frontend_script_path":"/frontend.js"}"#;
215    append_bytes(&mut builder, "manifest.json", manifest)?;
216
217    let backend_wasm = dist_dir.join("backend.wasm");
218    let wasm_bytes = std::fs::read(&backend_wasm)
219        .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
220    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
221
222    let server_js = dist_dir.join("server.js");
223    let server_bytes = std::fs::read(&server_js)
224        .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
225    append_bytes(&mut builder, "frontend.js", &server_bytes)?;
226
227    let public_dir = dist_dir.join("public");
228    if public_dir.exists() {
229        for entry in walkdir::WalkDir::new(&public_dir).into_iter().filter_map(|e| e.ok()) {
230            if !entry.file_type().is_file() {
231                continue;
232            }
233            let rel = entry
234                .path()
235                .strip_prefix(&public_dir)
236                .map_err(|e| anyhow!("strip_prefix failed: {}", e))?;
237            let tar_path = format!("public/{}", rel.to_string_lossy().replace('\\', "/"));
238            let bytes = std::fs::read(entry.path())
239                .map_err(|e| anyhow!("Failed to read {}: {}", entry.path().display(), e))?;
240            append_bytes(&mut builder, &tar_path, &bytes)?;
241        }
242    }
243
244    builder.finish()?;
245    Ok(())
246}
247
248fn append_bytes<W: std::io::Write>(
249    builder: &mut tar::Builder<W>,
250    path: &str,
251    data: &[u8],
252) -> Result<()> {
253    let mut header = tar::Header::new_gnu();
254    header.set_size(data.len() as u64);
255    header.set_mode(0o644);
256    header.set_cksum();
257    builder
258        .append_data(&mut header, path, data)
259        .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
260    Ok(())
261}