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(project_name: &str, bundle_tar_path: &Path) -> Result<()> {
127    let github_token = get_github_token().await?;
128
129    let client = reqwest::Client::new();
130
131    println!("Requesting deploy start...");
132    let start_resp: DeployStartResponse = client
133        .post(format!("{}/deploy/start", HQ_URL))
134        .json(&serde_json::json!({
135            "github_token": github_token,
136            "project_name": project_name,
137        }))
138        .send()
139        .await?
140        .error_for_status()
141        .map_err(|e| anyhow!("Deploy start failed: {}", e))?
142        .json()
143        .await?;
144
145    println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
146
147    println!("Uploading bundle...");
148    let bundle_bytes = std::fs::read(bundle_tar_path)
149        .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
150
151    client
152        .put(&start_resp.presigned_url)
153        .header("content-type", "application/x-tar")
154        .body(bundle_bytes)
155        .send()
156        .await?
157        .error_for_status()
158        .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
159
160    println!("Requesting deploy finish...");
161    client
162        .post(format!("{}/deploy/finish", HQ_URL))
163        .json(&serde_json::json!({
164            "github_token": github_token,
165            "deploy_job_id": start_resp.deploy_job_id,
166            "subdomain": start_resp.subdomain,
167            "code_id": start_resp.code_id,
168        }))
169        .send()
170        .await?
171        .error_for_status()
172        .map_err(|e| anyhow!("Deploy finish failed: {}", e))?;
173
174    println!("Deploy complete!");
175
176    Ok(())
177}
178
179pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
180    let file = std::fs::File::create(output_path)
181        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
182    let mut builder = tar::Builder::new(file);
183
184    let manifest = br#"{"kind":"wasm"}"#;
185    append_bytes(&mut builder, "manifest.json", manifest)?;
186
187    let wasm_bytes = std::fs::read(wasm_path)
188        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
189    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
190
191    builder.finish()?;
192    Ok(())
193}
194
195pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
196    let file = std::fs::File::create(output_path)
197        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
198    let mut builder = tar::Builder::new(file);
199
200    let manifest = br#"{"kind":"forte","frontend_script_path":"/frontend.js"}"#;
201    append_bytes(&mut builder, "manifest.json", manifest)?;
202
203    let backend_wasm = dist_dir.join("backend.wasm");
204    let wasm_bytes = std::fs::read(&backend_wasm)
205        .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
206    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
207
208    let server_js = dist_dir.join("server.js");
209    let server_bytes = std::fs::read(&server_js)
210        .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
211    append_bytes(&mut builder, "frontend.js", &server_bytes)?;
212
213    let public_dir = dist_dir.join("public");
214    if public_dir.exists() {
215        for entry in walkdir::WalkDir::new(&public_dir).into_iter().filter_map(|e| e.ok()) {
216            if !entry.file_type().is_file() {
217                continue;
218            }
219            let rel = entry
220                .path()
221                .strip_prefix(&public_dir)
222                .map_err(|e| anyhow!("strip_prefix failed: {}", e))?;
223            let tar_path = format!("public/{}", rel.to_string_lossy().replace('\\', "/"));
224            let bytes = std::fs::read(entry.path())
225                .map_err(|e| anyhow!("Failed to read {}: {}", entry.path().display(), e))?;
226            append_bytes(&mut builder, &tar_path, &bytes)?;
227        }
228    }
229
230    builder.finish()?;
231    Ok(())
232}
233
234fn append_bytes<W: std::io::Write>(
235    builder: &mut tar::Builder<W>,
236    path: &str,
237    data: &[u8],
238) -> Result<()> {
239    let mut header = tar::Header::new_gnu();
240    header.set_size(data.len() as u64);
241    header.set_mode(0o644);
242    header.set_cksum();
243    builder
244        .append_data(&mut header, path, data)
245        .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
246    Ok(())
247}