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}