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, wasm_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 WASM...");
148    let wasm_bytes = std::fs::read(wasm_path)
149        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
150
151    client
152        .put(&start_resp.presigned_url)
153        .body(wasm_bytes)
154        .send()
155        .await?
156        .error_for_status()
157        .map_err(|e| anyhow!("WASM upload failed: {}", e))?;
158
159    println!("Requesting deploy finish...");
160    client
161        .post(format!("{}/deploy/finish", HQ_URL))
162        .json(&serde_json::json!({
163            "github_token": github_token,
164            "deploy_job_id": start_resp.deploy_job_id,
165            "subdomain": start_resp.subdomain,
166            "code_id": start_resp.code_id,
167        }))
168        .send()
169        .await?
170        .error_for_status()
171        .map_err(|e| anyhow!("Deploy finish failed: {}", e))?;
172
173    println!("Deploy complete!");
174
175    Ok(())
176}