1use aes_gcm::{
2 aead::{Aead, KeyInit},
3 Aes256Gcm, Nonce,
4};
5use base64::{engine::general_purpose, Engine as _};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
11pub enum Environment {
12 Dev,
13 Prod,
14}
15
16impl Environment {
17 pub fn api_base_url(&self) -> &'static str {
18 match self {
19 Environment::Dev => "https://vkyme566n8.execute-api.us-east-1.amazonaws.com",
20 Environment::Prod => "https://o7eumduni6.execute-api.us-east-1.amazonaws.com",
21 }
22 }
23
24 pub fn display_name(&self) -> &'static str {
25 match self {
26 Environment::Dev => "development",
27 Environment::Prod => "production",
28 }
29 }
30}
31
32impl std::str::FromStr for Environment {
33 type Err = String;
34
35 fn from_str(s: &str) -> Result<Self, Self::Err> {
36 match s.to_lowercase().as_str() {
37 "dev" | "development" => Ok(Environment::Dev),
38 "prod" | "production" => Ok(Environment::Prod),
39 _ => Err(format!("Unknown environment: {}. Use 'dev' or 'prod'", s)),
40 }
41 }
42}
43
44pub fn get_current_environment() -> Environment {
46 #[cfg(debug_assertions)]
47 {
48 Environment::Dev
49 }
50 #[cfg(not(debug_assertions))]
51 {
52 Environment::Prod
53 }
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct Config {
58 pub workspace_directory: String,
59 pub api_token: String,
60 pub curriculum_version: String,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
65struct ConfigFile {
66 workspace_directory: String,
67 encrypted_api_token: String,
68 curriculum_version: String,
69}
70
71impl Config {
72 pub fn new(workspace_directory: String, api_token: String, curriculum_version: String) -> Self {
73 Self {
74 workspace_directory,
75 api_token,
76 curriculum_version,
77 }
78 }
79
80 pub fn get_api_base_url(&self) -> String {
81 get_current_environment().api_base_url().to_string()
82 }
83
84 pub fn get_environment(&self) -> Environment {
85 get_current_environment()
86 }
87
88 pub fn get_curriculum_version(&self) -> &str {
89 &self.curriculum_version
90 }
91
92 pub fn get_config_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
93 let home_dir = dirs::home_dir().ok_or("Could not find home directory")?;
94
95 let config_dir = home_dir.join(".dojo-cli");
96
97 if !config_dir.exists() {
99 fs::create_dir_all(&config_dir)?;
100 }
101
102 Ok(config_dir)
103 }
104
105 pub fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
106 let config_dir = Self::get_config_dir()?;
107 Ok(config_dir.join("config.toml"))
108 }
109
110 pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
111 let config_path = Self::get_config_path()?;
112
113 let encrypted_token = Self::encrypt_token(&self.api_token)?;
115
116 let config_file = ConfigFile {
117 workspace_directory: self.workspace_directory.clone(),
118 encrypted_api_token: encrypted_token,
119 curriculum_version: self.curriculum_version.clone(),
120 };
121
122 let toml_string = toml::to_string_pretty(&config_file)?;
123 fs::write(config_path, toml_string)?;
124 Ok(())
125 }
126
127 pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
128 let config_path = Self::get_config_path()?;
129 let content = fs::read_to_string(config_path)?;
130
131 if let Ok(config_file) = toml::from_str::<ConfigFile>(&content) {
133 let decrypted_token = Self::decrypt_token(&config_file.encrypted_api_token)?;
135
136 return Ok(Config {
137 workspace_directory: config_file.workspace_directory,
138 api_token: decrypted_token,
139 curriculum_version: config_file.curriculum_version,
140 });
141 }
142
143 let config: Config = toml::from_str(&content)?;
145 Ok(config)
146 }
147
148 pub fn exists() -> bool {
149 Self::get_config_path()
150 .map(|path| path.exists())
151 .unwrap_or(false)
152 }
153
154 fn derive_key_from_passphrase(passphrase: &str) -> [u8; 32] {
156 use std::collections::hash_map::DefaultHasher;
157 use std::hash::{Hash, Hasher};
158
159 let mut key = [0u8; 32];
160
161 for i in 0..4 {
163 let mut hasher = DefaultHasher::new();
164 let seed = format!("{}{}", passphrase, i);
166 seed.hash(&mut hasher);
167 let hash = hasher.finish();
168
169 for j in 0..8 {
171 let byte_index = i * 8 + j;
172 if byte_index < 32 {
173 key[byte_index] = ((hash >> (j * 8)) & 0xFF) as u8;
174 }
175 }
176 }
177
178 key
179 }
180
181 fn encrypt_token(token: &str) -> Result<String, Box<dyn std::error::Error>> {
183 let home_dir = dirs::home_dir()
185 .ok_or("Could not find home directory")?
186 .to_string_lossy()
187 .to_string();
188
189 let passphrase = format!("bitcoin-dojo-cli-{}", home_dir);
190 let key = Self::derive_key_from_passphrase(&passphrase);
191
192 let cipher = Aes256Gcm::new_from_slice(&key)
193 .map_err(|e| format!("Failed to create cipher: {}", e))?;
194
195 let mut nonce_bytes = [0u8; 12];
197 getrandom::getrandom(&mut nonce_bytes)
198 .map_err(|e| format!("Failed to generate nonce: {}", e))?;
199 let nonce = Nonce::from_slice(&nonce_bytes);
200
201 let ciphertext = cipher
202 .encrypt(nonce, token.as_bytes())
203 .map_err(|e| format!("Failed to encrypt token: {}", e))?;
204
205 let mut combined = Vec::new();
207 combined.extend_from_slice(&nonce_bytes);
208 combined.extend_from_slice(&ciphertext);
209
210 Ok(general_purpose::STANDARD.encode(combined))
211 }
212
213 fn decrypt_token(encrypted_token: &str) -> Result<String, Box<dyn std::error::Error>> {
215 let home_dir = dirs::home_dir()
217 .ok_or("Could not find home directory")?
218 .to_string_lossy()
219 .to_string();
220
221 let passphrase = format!("bitcoin-dojo-cli-{}", home_dir);
222 let key = Self::derive_key_from_passphrase(&passphrase);
223
224 let cipher = Aes256Gcm::new_from_slice(&key)
225 .map_err(|e| format!("Failed to create cipher: {}", e))?;
226
227 let combined = general_purpose::STANDARD
229 .decode(encrypted_token)
230 .map_err(|e| format!("Failed to decode base64: {}", e))?;
231
232 if combined.len() < 12 {
234 return Err("Invalid encrypted token format".into());
235 }
236
237 let nonce = Nonce::from_slice(&combined[..12]);
238 let ciphertext = &combined[12..];
239
240 let plaintext = cipher
241 .decrypt(nonce, ciphertext)
242 .map_err(|e| format!("Failed to decrypt token: {}", e))?;
243 Ok(String::from_utf8(plaintext)
244 .map_err(|e| format!("Failed to convert to string: {}", e))?)
245 }
246
247 pub async fn init_curriculum_version(
249 api_token: &str,
250 ) -> Result<String, Box<dyn std::error::Error>> {
251 let base_url = get_current_environment().api_base_url();
252 let stage = match get_current_environment() {
253 Environment::Dev => "dev",
254 Environment::Prod => "prod",
255 };
256
257 let url = format!("{}/{}/curriculum_version/init", base_url, stage);
258
259 let client = reqwest::Client::new();
260 let response = client
261 .patch(&url)
262 .bearer_auth(api_token)
263 .header("x-bdj-stage", stage)
264 .send()
265 .await?;
266
267 let status = response.status();
269 println!("Response status: {}", status);
270 let response_text = response.text().await?;
271 println!("Response body: {}", response_text);
272
273 if !status.is_success() {
274 return Err(Self::parse_api_error(
275 "Failed to initialize curriculum version",
276 &response_text,
277 )
278 .into());
279 }
280
281 let json_response: serde_json::Value = serde_json::from_str(&response_text)?;
282 let curriculum_version = json_response["curriculum_version"]
283 .as_str()
284 .ok_or("curriculum_version not found in response")?;
285
286 Ok(curriculum_version.to_string())
287 }
288
289 pub async fn update_curriculum_version(
291 api_token: &str,
292 target_version: Option<&str>,
293 ) -> Result<String, Box<dyn std::error::Error>> {
294 let base_url = get_current_environment().api_base_url();
295 let stage = match get_current_environment() {
296 Environment::Dev => "dev",
297 Environment::Prod => "prod",
298 };
299
300 let url = format!("{}/{}/curriculum_version/update", base_url, stage);
301
302 let current_config = Config::load()?;
304 let current_curriculum_version = current_config.get_curriculum_version();
305
306 let client = reqwest::Client::new();
307 let mut request = client
308 .patch(&url)
309 .bearer_auth(api_token)
310 .header("x-bdj-stage", stage)
311 .header("x-bdj-curriculum-version", current_curriculum_version);
312
313 if let Some(version) = target_version {
315 request = request.header("x-bdj-target-version", version);
316 }
317
318 let response = request.send().await?;
319
320 let status = response.status();
322 println!("Response status: {}", status);
323 let response_text = response.text().await?;
324 println!("Response body: {}", response_text);
325
326 if !status.is_success() {
327 return Err(Self::parse_api_error(
328 "Failed to update curriculum version",
329 &response_text,
330 )
331 .into());
332 }
333
334 let json_response: serde_json::Value = serde_json::from_str(&response_text)?;
335 let new_curriculum_version = json_response["curriculum_version"]
336 .as_str()
337 .ok_or("curriculum_version not found in response")?;
338
339 let updated = json_response["updated"].as_bool().unwrap_or(false);
341
342 if updated {
343 let mut updated_config = current_config;
345 updated_config.curriculum_version = new_curriculum_version.to_string();
346 updated_config.save()?;
347 println!("đ Config file updated with new curriculum version");
348 } else if target_version.is_some() {
349 println!("âšī¸ Curriculum version is already at the requested version");
350 } else {
351 println!("âšī¸ Curriculum version is already up to date");
352 }
353
354 Ok(new_curriculum_version.to_string())
355 }
356
357 fn parse_api_error(context: &str, response_text: &str) -> String {
359 if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(response_text) {
361 if let Some(error_msg) = error_json["message"].as_str() {
362 if error_msg == "Forbidden" {
363 return "â Authorization Error: Access forbidden".to_string();
364 } else {
365 return format!("â API Error: {}", error_msg);
366 }
367 }
368 }
369
370 format!("â {}: {}", context, response_text)
372 }
373}