claude_code_toolkit/sync/
mod.rs

1//! Credential synchronization service for automated Claude Code token management.
2//!
3//! This module provides intelligent credential synchronization between Claude Code
4//! and external services like GitHub. It implements smart change detection, multi-target
5//! sync capabilities, and state tracking to ensure efficient and reliable credential
6//! distribution.
7//!
8//! ## Core Functionality
9//!
10//! - **Intelligent Sync**: Only syncs when credentials change or targets are missing secrets
11//! - **Multi-Target Support**: Sync to multiple GitHub organizations and repositories
12//! - **State Tracking**: Maintains sync history and prevents unnecessary operations
13//! - **Error Handling**: Robust error reporting and partial failure recovery
14//! - **Configuration-Driven**: Uses YAML configuration for flexible target management
15//!
16//! ## Architecture
17//!
18//! The sync service follows the Service Pattern and coordinates between:
19//! - [`CredentialsManager`] - Reads Claude Code credentials from file system
20//! - [`ConfigurationManager`] - Manages sync targets and settings
21//! - [`ProviderRegistry`] - Handles external service integrations (GitHub)
22//! - State tracking system - Prevents duplicate syncs and tracks status
23//!
24//! ## Usage Examples
25//!
26//! ### Basic Synchronization
27//!
28//! ```rust,no_run
29//! use claude_code_toolkit::sync::SyncService;
30//!
31//! #[tokio::main]
32//! async fn main() -> claude_code_toolkit::Result<()> {
33//!     let mut sync_service = SyncService::new_with_config().await?;
34//!     
35//!     // Perform smart sync (only if needed)
36//!     sync_service.check_and_sync_if_needed().await?;
37//!     
38//!     Ok(())
39//! }
40//! ```
41//!
42//! ### Force Synchronization
43//!
44//! ```rust,no_run
45//! use claude_code_toolkit::sync::SyncService;
46//!
47//! #[tokio::main]
48//! async fn main() -> claude_code_toolkit::Result<()> {
49//!     let mut sync_service = SyncService::new_with_config().await?;
50//!     
51//!     // Force sync regardless of change detection
52//!     let result = sync_service.force_sync().await?;
53//!     println!("Synced to {} targets, {} failed", result.succeeded, result.failed);
54//!     
55//!     Ok(())
56//! }
57//! ```
58//!
59//! ### Check Sync Status
60//!
61//! ```rust,no_run
62//! use claude_code_toolkit::sync::SyncService;
63//!
64//! #[tokio::main]
65//! async fn main() -> claude_code_toolkit::Result<()> {
66//!     let sync_service = SyncService::new_with_config().await?;
67//!     
68//!     // Check if sync is needed
69//!     if sync_service.is_sync_needed().await? {
70//!         println!("Sync required - credentials changed or secrets missing");
71//!     }
72//!     
73//!     // Get target status
74//!     let status = sync_service.get_sync_status().await?;
75//!     for (target, available) in status {
76//!         println!("Target {}: {}", target, if available { "✓" } else { "✗" });
77//!     }
78//!     
79//!     Ok(())
80//! }
81//! ```
82//!
83//! ## Smart Sync Logic
84//!
85//! The sync service implements intelligent change detection:
86//!
87//! 1. **Token Change Detection**: Compares current access token with last known state
88//! 2. **Secret Validation**: Checks if required secrets exist in target repositories/organizations
89//! 3. **State Persistence**: Saves sync state to `~/.goodiebag/sync-state.json`
90//! 4. **Incremental Updates**: Only syncs when changes are detected
91//!
92//! ## Configuration Integration
93//!
94//! The service reads sync targets from YAML configuration:
95//!
96//! ```yaml
97//! github:
98//!   organizations:
99//!     - name: my-org
100//!       secret_name: CLAUDE_CODE_TOKEN
101//!   repositories:
102//!     - repo: owner/repository
103//!       secret_name: CUSTOM_TOKEN_NAME
104//! ```
105//!
106//! ## Error Handling
107//!
108//! The sync service provides comprehensive error handling:
109//! - Individual target failures don't stop the entire sync process
110//! - Detailed error reporting for debugging
111//! - Graceful degradation when external services are unavailable
112//! - State consistency maintained even during partial failures
113
114use 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
123/// High-level synchronization service
124pub 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    // Expand tilde in path
144    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  /// Initialize providers from configuration
155  pub async fn initialize(&mut self) -> Result<()> {
156    let config = self.config_manager.load().await?;
157
158    // Initialize GitHub provider if we have GitHub targets
159    if !config.github.organizations.is_empty() || !config.github.repositories.is_empty() {
160      let github_config = HashMap::new(); // GitHub provider uses gh CLI, no config needed
161      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  /// Convert Claude credentials to our generic format
171  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    // Convert credential structure to generic map
176    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    // Build credential data dynamically based on configured mappings
187    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        // Convert JSON value to string
191        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    // For backward compatibility, try to extract common fields if they exist
202    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    // Store all discovered fields in metadata for generic access
219    let metadata = credential_data;
220
221    Ok(Credentials {
222      access_token,
223      refresh_token,
224      expires_at,
225      metadata,
226    })
227  }
228
229  /// Create secret mapping from configuration
230  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    // Use the field mappings from credentials configuration
236    for (field, secret_name) in &config.credentials.field_mappings {
237      mapping.add_mapping(field, secret_name);
238    }
239
240    Ok(mapping)
241  }
242
243  /// Get targets from configuration
244  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    // Add organizations
249    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    // Add repositories
259    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  /// Perform complete credential synchronization
273  pub async fn sync_all(&mut self) -> Result<SyncResult> {
274    info!("Starting credential synchronization");
275
276    // Initialize providers
277    self.initialize().await?;
278
279    // Get credentials and mapping
280    let credentials = self.get_credentials().await?;
281    let mapping = self.get_secret_mapping().await?;
282
283    // Get targets from configuration
284    let targets = self.get_targets_from_config().await?;
285
286    // Perform sync
287    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    // Save sync state if successful
302    if result.succeeded > 0 && result.failed == 0 {
303      self.save_sync_state(&credentials).await?;
304    }
305
306    Ok(result)
307  }
308
309  /// Force synchronization (ignore token change checks)
310  pub async fn force_sync(&mut self) -> Result<SyncResult> {
311    info!("Force syncing credentials");
312    self.sync_all().await
313  }
314
315  /// Check if sync is needed and perform sync if required
316  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  /// Check if sync is needed (token has changed or secrets are missing)
331  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    // First check if credentials have changed
337    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 // Invalid state file
347      }
348    } else {
349      true // No state file
350    };
351
352    if token_changed {
353      info!("Access token has changed, sync needed");
354      return Ok(true);
355    }
356
357    // Check if all required secrets exist in GitHub
358    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  /// Save sync state after successful sync
401  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    // Create directory if it doesn't exist
407    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(), // TODO: Add target status tracking
418    };
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  /// Get sync status
432  pub async fn get_sync_status(&self) -> Result<HashMap<String, bool>> {
433    self.provider_registry.validate_targets().await
434  }
435}