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}