codex-sync 0.3.0

Sync and merge Codex conversations across computers, LAN, SSH, and offline storage
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
    fs,
    path::{Path, PathBuf},
};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub node_name: String,
    pub listen: String,
    pub discovery_port: u16,
    pub sync_dir: PathBuf,
    pub shared_key: String,
    pub exclude: Vec<String>,
    pub auto_pull_seconds: u64,
    /// Maximum individual file size accepted from LAN or offline snapshots.
    pub max_file_size_bytes: u64,
    /// Maximum number of files accepted in one manifest.
    pub max_files: usize,
}

impl Default for Config {
    fn default() -> Self {
        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
        Self {
            node_name: hostname::get()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned(),
            listen: "0.0.0.0:8787".into(),
            discovery_port: 8788,
            sync_dir: home.join(".codex"),
            shared_key: "请修改为所有电脑相同的随机密钥".into(),
            exclude: vec![
                "auth.json".into(),
                "credentials.json".into(),
                "logs".into(),
                "cache".into(),
                "tmp".into(),
                "codex-sync-backups".into(),
                ".DS_Store".into(),
            ],
            auto_pull_seconds: 0,
            max_file_size_bytes: 256 * 1024 * 1024,
            max_files: 100_000,
        }
    }
}

impl Config {
    pub fn default_path() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("codex-sync/config.toml")
    }

    pub fn init(path: &Path, force: bool) -> Result<()> {
        if path.exists() && !force {
            bail!("配置已存在;如需覆盖请添加 --force");
        }
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, toml::to_string_pretty(&Self::default())?)?;
        #[cfg(unix)]
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
        Ok(())
    }

    pub fn load(path: &Path) -> Result<Self> {
        let text = fs::read_to_string(path)
            .with_context(|| format!("无法读取 {},请先运行 codex-sync init", path.display()))?;
        let cfg: Self = toml::from_str(&text).context("配置格式错误")?;
        if cfg.shared_key.starts_with("请修改") {
            bail!("请先修改配置中的 shared_key");
        }
        if cfg.shared_key.chars().count() < 32 {
            bail!("shared_key 至少需要 32 个字符,请使用随机长字符串");
        }
        if cfg.max_file_size_bytes == 0 || cfg.max_files == 0 {
            bail!("max_file_size_bytes 和 max_files 必须大于 0");
        }
        Ok(cfg)
    }

    pub fn node_id(&self) -> String {
        blake3::hash(format!("{}:{}", self.node_name, self.shared_key).as_bytes()).to_hex()[..12]
            .to_string()
    }
}

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

    #[test]
    fn rejects_short_shared_key() {
        let root =
            std::env::temp_dir().join(format!("codex-sync-config-test-{}", std::process::id()));
        let _ = fs::remove_file(&root);
        fs::write(&root, "shared_key = 'short'\n").unwrap();
        assert!(Config::load(&root).is_err());
        let _ = fs::remove_file(root);
    }
}