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}