claude_code_toolkit/sync/
mod.rs1use crate::config::{ credentials::CredentialsManager, manager::ConfigurationManager };
115use crate::error::Result;
116use crate::providers::registry::ProviderRegistry;
117use crate::traits::config::ConfigManager;
118use crate::traits::{ Credentials, SecretManager, SecretMapping, SyncResult };
119use std::collections::HashMap;
120use std::path::PathBuf;
121use tracing::{ error, info, warn };
122
123pub struct SyncService {
125 credentials_manager: CredentialsManager,
126 config_manager: ConfigurationManager,
127 provider_registry: ProviderRegistry,
128}
129
130impl SyncService {
131 pub fn new() -> Result<Self> {
132 Ok(Self {
133 credentials_manager: CredentialsManager::new()?,
134 config_manager: ConfigurationManager::with_yaml_provider()?,
135 provider_registry: ProviderRegistry::new(),
136 })
137 }
138
139 pub async fn new_with_config() -> Result<Self> {
140 let config_manager = ConfigurationManager::with_yaml_provider()?;
141 let config = config_manager.load().await?;
142
143 let expanded_path = shellexpand::tilde(&config.credentials.file_path);
145 let credentials_path = PathBuf::from(expanded_path.as_ref());
146
147 Ok(Self {
148 credentials_manager: CredentialsManager::with_path(credentials_path),
149 config_manager,
150 provider_registry: ProviderRegistry::new(),
151 })
152 }
153
154 pub async fn initialize(&mut self) -> Result<()> {
156 let config = self.config_manager.load().await?;
157
158 if !config.github.organizations.is_empty() || !config.github.repositories.is_empty() {
160 let github_config = HashMap::new(); match self.provider_registry.initialize_provider("github", github_config).await {
162 Ok(()) => info!("Initialized GitHub provider"),
163 Err(e) => warn!("Failed to initialize GitHub provider: {}", e),
164 }
165 }
166
167 Ok(())
168 }
169
170 async fn get_credentials(&self) -> Result<Credentials> {
172 let claude_creds = self.credentials_manager.read_credentials().await?;
173 let config = self.config_manager.load().await?;
174
175 let cred_value = serde_json::to_value(&claude_creds)?;
177 let oauth_obj = cred_value
178 .get(&config.credentials.json_path)
179 .and_then(|v| v.as_object())
180 .ok_or_else(|| {
181 crate::error::ClaudeCodeError::InvalidCredentials(
182 format!("Could not find '{}' in credentials file", config.credentials.json_path)
183 )
184 })?;
185
186 let mut credential_data = HashMap::new();
188 for field_name in config.credentials.field_mappings.keys() {
189 if let Some(value) = oauth_obj.get(field_name) {
190 let string_value = match value {
192 serde_json::Value::String(s) => s.clone(),
193 serde_json::Value::Number(n) => n.to_string(),
194 serde_json::Value::Bool(b) => b.to_string(),
195 _ => value.to_string(),
196 };
197 credential_data.insert(field_name.clone(), string_value);
198 }
199 }
200
201 let access_token = credential_data
203 .get("access_token")
204 .or_else(|| credential_data.get("accessToken"))
205 .cloned()
206 .unwrap_or_default();
207
208 let refresh_token = credential_data
209 .get("refresh_token")
210 .or_else(|| credential_data.get("refreshToken"))
211 .cloned();
212
213 let expires_at = credential_data
214 .get("expires_at")
215 .or_else(|| credential_data.get("expiresAt"))
216 .and_then(|s| s.parse::<i64>().ok());
217
218 let metadata = credential_data;
220
221 Ok(Credentials {
222 access_token,
223 refresh_token,
224 expires_at,
225 metadata,
226 })
227 }
228
229 async fn get_secret_mapping(&self) -> Result<SecretMapping> {
231 let config = self.config_manager.load().await?;
232
233 let mut mapping = SecretMapping::new("claude");
234
235 for (field, secret_name) in &config.credentials.field_mappings {
237 mapping.add_mapping(field, secret_name);
238 }
239
240 Ok(mapping)
241 }
242
243 async fn get_targets_from_config(&self) -> Result<Vec<crate::traits::Target>> {
245 let config = self.config_manager.load().await?;
246 let mut targets = Vec::new();
247
248 for org in &config.github.organizations {
250 targets.push(crate::traits::Target {
251 provider: "github".to_string(),
252 target_type: "organization".to_string(),
253 name: org.name.clone(),
254 config: HashMap::new(),
255 });
256 }
257
258 for repo in &config.github.repositories {
260 targets.push(crate::traits::Target {
261 provider: "github".to_string(),
262 target_type: "repository".to_string(),
263 name: repo.repo.clone(),
264 config: HashMap::new(),
265 });
266 }
267
268 info!("Found {} targets for sync", targets.len());
269 Ok(targets)
270 }
271
272 pub async fn sync_all(&mut self) -> Result<SyncResult> {
274 info!("Starting credential synchronization");
275
276 self.initialize().await?;
278
279 let credentials = self.get_credentials().await?;
281 let mapping = self.get_secret_mapping().await?;
282
283 let targets = self.get_targets_from_config().await?;
285
286 let result = self.provider_registry.sync_credentials_to_targets(
288 &credentials,
289 &mapping,
290 &targets
291 ).await?;
292
293 info!("Sync completed: {} succeeded, {} failed", result.succeeded, result.failed);
294
295 if !result.errors.is_empty() {
296 for error in &result.errors {
297 error!("Sync error: {}", error);
298 }
299 }
300
301 if result.succeeded > 0 && result.failed == 0 {
303 self.save_sync_state(&credentials).await?;
304 }
305
306 Ok(result)
307 }
308
309 pub async fn force_sync(&mut self) -> Result<SyncResult> {
311 info!("Force syncing credentials");
312 self.sync_all().await
313 }
314
315 pub async fn check_and_sync_if_needed(&mut self) -> Result<()> {
317 if self.is_sync_needed().await? {
318 let result = self.sync_all().await?;
319 if result.failed > 0 {
320 warn!("Sync completed with {} failures", result.failed);
321 } else {
322 info!("Sync completed successfully: {} targets", result.succeeded);
323 }
324 } else {
325 info!("Credentials are already up to date, no sync needed");
326 }
327 Ok(())
328 }
329
330 pub async fn is_sync_needed(&self) -> Result<bool> {
332 let credentials = self.get_credentials().await?;
333 let mapping = self.get_secret_mapping().await?;
334 let secrets = mapping.to_secrets(&credentials);
335
336 let state_path = std::path::Path
338 ::new(&std::env::var("HOME").unwrap_or_default())
339 .join(".goodiebag")
340 .join("sync-state.json");
341
342 let token_changed = if let Ok(state_data) = std::fs::read_to_string(&state_path) {
343 if let Ok(last_state) = serde_json::from_str::<crate::types::SyncState>(&state_data) {
344 last_state.last_token != credentials.access_token
345 } else {
346 true }
348 } else {
349 true };
351
352 if token_changed {
353 info!("Access token has changed, sync needed");
354 return Ok(true);
355 }
356
357 let targets = self.get_targets_from_config().await?;
359 for target in &targets {
360 for secret in &secrets {
361 let args = match target.target_type.as_str() {
362 "repository" => vec!["secret", "list", "--repo", &target.name],
363 "organization" => vec!["secret", "list", "--org", &target.name],
364 _ => {
365 continue;
366 }
367 };
368
369 let check_result = std::process::Command::new("gh").args(&args).output();
370
371 match check_result {
372 Ok(output) if output.status.success() => {
373 let secret_list = String::from_utf8_lossy(&output.stdout);
374 if !secret_list.contains(&secret.name) {
375 info!(
376 "Secret {} missing from {} {}, sync needed",
377 secret.name,
378 target.target_type,
379 target.name
380 );
381 return Ok(true);
382 }
383 }
384 _ => {
385 warn!(
386 "Could not check secrets for {} {}, assuming sync needed",
387 target.target_type,
388 target.name
389 );
390 return Ok(true);
391 }
392 }
393 }
394 }
395
396 info!("Credentials unchanged and all secrets present, no sync needed");
397 Ok(false)
398 }
399
400 async fn save_sync_state(&self, credentials: &Credentials) -> Result<()> {
402 let state_dir = std::path::Path
403 ::new(&std::env::var("HOME").unwrap_or_default())
404 .join(".goodiebag");
405
406 std::fs
408 ::create_dir_all(&state_dir)
409 .map_err(|e| {
410 crate::error::ClaudeCodeError::Generic(format!("Failed to create state directory: {}", e))
411 })?;
412
413 let state_path = state_dir.join("sync-state.json");
414 let sync_state = crate::types::SyncState {
415 last_sync: chrono::Utc::now().timestamp(),
416 last_token: credentials.access_token.clone(),
417 targets: Vec::new(), };
419
420 let state_json = serde_json::to_string_pretty(&sync_state)?;
421 std::fs
422 ::write(&state_path, state_json)
423 .map_err(|e| {
424 crate::error::ClaudeCodeError::Generic(format!("Failed to save sync state: {}", e))
425 })?;
426
427 info!("Saved sync state to {:?}", state_path);
428 Ok(())
429 }
430
431 pub async fn get_sync_status(&self) -> Result<HashMap<String, bool>> {
433 self.provider_registry.validate_targets().await
434 }
435}