cinchcli-core 0.1.7

Shared client-side primitives for Cinch (cinchcli.com): generated wire DTOs, REST/WebSocket clients, AES-256-GCM + X25519 crypto, credential storage, local SQLite store, and sync helpers.
Documentation
//! On-disk client config: `~/.cinch/config.json`.
//!
//! Wire-compatible with the Go CLI's `cinch/internal/config/config.go` schema.
//! `MultiConfig` is the canonical disk format; `Config` is the legacy single-relay
//! shape kept for backwards compatibility (and as the in-memory shape consumers
//! handle most often via `MultiConfig::to_active_config`).
//!
//! Permissions: 0700 dir, 0600 file on Unix.

use std::sync::{Arc, Mutex};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub token: String,
    #[serde(default)]
    pub user_id: String,
    #[serde(default = "default_relay_url")]
    pub relay_url: String,
    #[serde(default)]
    pub hostname: String,
    #[serde(default)]
    pub active_device_id: String,
    #[serde(default)]
    pub credential_version: u64,
    #[serde(default)]
    pub encryption_key: String,
    #[serde(default)]
    pub device_private_key: String,
    #[serde(default)]
    pub email: String,
    #[serde(default)]
    pub identity_provider: String,
}

pub fn default_relay_url() -> String {
    "http://localhost:8080".to_string()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayProfile {
    pub id: String,
    pub label: String,
    pub relay_url: String,
    pub user_id: String,
    pub device_id: String,
    pub hostname: String,
    #[serde(default)]
    pub encryption_key: String,
    #[serde(default)]
    pub device_private_key: String,
    #[serde(default)]
    pub credential_version: u64,
    #[serde(default)]
    pub token: String,
    /// Stable per-machine identifier (opaque hash). Used by the relay to
    /// recognize repeat sign-ins from the same machine and reuse a single
    /// device row instead of creating duplicates. Empty for legacy configs;
    /// backfilled on next save via `client_core::machine::stable_machine_id`.
    #[serde(default)]
    pub machine_id: String,
    /// Verified email address returned by the OAuth provider at login time.
    /// Empty for legacy configs or when the provider did not return a verified email.
    #[serde(default)]
    pub email: String,
    /// OAuth identity provider used for the most recent login ("google" or "github").
    /// Empty for legacy configs.
    #[serde(default)]
    pub identity_provider: String,
}

impl RelayProfile {
    pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
        use ulid::Ulid;
        let id = Ulid::new().to_string();
        let label = label.unwrap_or_else(|| {
            url::Url::parse(&cfg.relay_url)
                .ok()
                .and_then(|u| u.host_str().map(|h| h.to_string()))
                .unwrap_or_else(|| cfg.relay_url.clone())
        });
        Self {
            id,
            label,
            relay_url: cfg.relay_url.clone(),
            user_id: cfg.user_id.clone(),
            device_id: cfg.active_device_id.clone(),
            hostname: cfg.hostname.clone(),
            encryption_key: cfg.encryption_key.clone(),
            device_private_key: cfg.device_private_key.clone(),
            credential_version: cfg.credential_version,
            token: cfg.token.clone(),
            machine_id: crate::machine::stable_machine_id(),
            email: cfg.email.clone(),
            identity_provider: cfg.identity_provider.clone(),
        }
    }

    pub fn to_config(&self) -> Config {
        Config {
            token: self.token.clone(),
            user_id: self.user_id.clone(),
            relay_url: self.relay_url.clone(),
            hostname: self.hostname.clone(),
            active_device_id: self.device_id.clone(),
            credential_version: self.credential_version,
            encryption_key: self.encryption_key.clone(),
            device_private_key: self.device_private_key.clone(),
            email: self.email.clone(),
            identity_provider: self.identity_provider.clone(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MultiConfig {
    #[serde(default)]
    pub active_relay_id: Option<String>,
    #[serde(default)]
    pub relays: Vec<RelayProfile>,
}

pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;

impl MultiConfig {
    pub fn load() -> Self {
        let Some(home) = dirs::home_dir() else {
            return Self::default();
        };
        let path = home.join(".cinch").join("config.json");
        if !path.exists() {
            return Self::default();
        }
        let Ok(data) = std::fs::read_to_string(&path) else {
            return Self::default();
        };
        let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
            return Self::default();
        };
        if v.get("relays").is_some() {
            serde_json::from_value(v).unwrap_or_default()
        } else {
            let old: Config = match serde_json::from_value(v) {
                Ok(c) => c,
                Err(_) => return Self::default(),
            };
            Self::from_legacy(old)
        }
    }

    pub fn save(&self) -> Result<(), String> {
        let home = dirs::home_dir().ok_or("cannot determine home directory")?;
        let dir = home.join(".cinch");
        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
        let path = dir.join("config.json");
        let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
        std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Ok(meta) = std::fs::metadata(&path) {
                let mut perms = meta.permissions();
                perms.set_mode(0o600);
                let _ = std::fs::set_permissions(&path, perms);
            }
        }
        Ok(())
    }

    pub fn active_profile(&self) -> Option<&RelayProfile> {
        let id = self.active_relay_id.as_deref()?;
        self.relays.iter().find(|r| r.id == id)
    }

    pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
        let id = self.active_relay_id.clone()?;
        self.relays.iter_mut().find(|r| r.id == id)
    }

    pub fn to_active_config(&self) -> Config {
        self.active_profile()
            .map(|p| p.to_config())
            .unwrap_or_default()
    }

    pub fn from_legacy_pub(old: Config) -> Self {
        Self::from_legacy(old)
    }

    fn from_legacy(old: Config) -> Self {
        if old.user_id.is_empty() && old.token.is_empty() {
            return Self::default();
        }
        let profile = RelayProfile::from_config(&old, None);
        let id = profile.id.clone();
        Self {
            active_relay_id: Some(id),
            relays: vec![profile],
        }
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            token: String::new(),
            user_id: String::new(),
            relay_url: default_relay_url(),
            hostname: String::new(),
            active_device_id: String::new(),
            credential_version: 0,
            encryption_key: String::new(),
            device_private_key: String::new(),
            email: String::new(),
            identity_provider: String::new(),
        }
    }
}

impl Config {
    pub fn is_configured(&self) -> bool {
        !self.user_id.is_empty() && !self.active_device_id.is_empty()
    }

    pub fn load() -> Result<Self, String> {
        let mc = MultiConfig::load();
        let cfg = mc.to_active_config();
        if cfg.user_id.is_empty() && cfg.token.is_empty() {
            return Err("no active relay configured — run: cinch auth login".to_string());
        }
        Ok(cfg)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_configured_accepts_keyring_backed_config() {
        let config = Config {
            token: String::new(),
            user_id: "u1".into(),
            relay_url: "https://api.cinchcli.com".into(),
            hostname: "macbook".into(),
            active_device_id: "d1".into(),
            credential_version: 1,
            encryption_key: String::new(),
            device_private_key: String::new(),
            email: String::new(),
            identity_provider: String::new(),
        };
        assert!(config.is_configured());
    }
}