1use super::codex;
4use anyhow::{Context, Result, bail};
5use std::{
6 fs,
7 path::PathBuf,
8 process::Command,
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12pub use codex::MergeReport as SshReport;
13
14pub fn push(host: &str, codex_home: Option<PathBuf>, mirror: bool) -> Result<SshReport> {
15 validate_host(host)?;
16 let home = dirs::home_dir().context("无法确定本机主目录")?;
17 let codex_home = codex_home.unwrap_or_else(|| home.join(".codex"));
18 if !codex_home.is_dir() {
19 bail!("本机 Codex 目录不存在:{}", codex_home.display());
20 }
21 ensure_command("ssh")?;
22 ensure_command("scp")?;
23
24 let transfer_id = format!("{}-{}", hostname::get()?.to_string_lossy(), now());
25 let temporary = std::env::temp_dir().join(format!("codex-sync-ssh-{transfer_id}"));
26 let bundle = temporary.join("bundle");
27 fs::create_dir_all(&bundle)?;
28 let result = (|| -> Result<SshReport> {
29 codex::create_bundle(&codex_home, &bundle)?;
30 let remote_relative = format!(".codex-sync/incoming/{transfer_id}");
31 run(
32 Command::new("ssh")
33 .arg(host)
34 .arg("mkdir")
35 .arg("-p")
36 .arg(&remote_relative),
37 "创建远端接收目录",
38 )?;
39 run(
40 Command::new("scp")
41 .arg("-r")
42 .arg(format!("{}/.", bundle.display()))
43 .arg(format!("{host}:{remote_relative}/")),
44 "上传本机会话包",
45 )?;
46
47 let mirror_flag = if mirror { " --mirror" } else { "" };
48 let remote_command = format!(
49 "bin=$(command -v codex-sync || true); if [ -z \"$bin\" ] && [ -x \"$HOME/.cargo/bin/codex-sync\" ]; then bin=\"$HOME/.cargo/bin/codex-sync\"; fi; if [ -z \"$bin\" ]; then echo '远端未安装 codex-sync 0.4.0+' >&2; exit 127; fi; \"$bin\" ssh receive --bundle \"$HOME/{remote_relative}\"{mirror_flag}"
50 );
51 let output = Command::new("ssh")
52 .arg(host)
53 .arg(remote_command)
54 .output()
55 .context("调用远端 codex-sync")?;
56 if !output.status.success() {
57 bail!(
58 "远端合并失败:{}",
59 String::from_utf8_lossy(&output.stderr).trim()
60 );
61 }
62 let stdout = String::from_utf8(output.stdout)?;
63 let serialized = stdout
64 .lines()
65 .find_map(|line| line.strip_prefix("CODEX_SYNC_REPORT="))
66 .context("远端未返回合并报告")?;
67 let report = serde_json::from_str(serialized)?;
68
69 let cleanup = format!("rm -rf \"$HOME/{remote_relative}\"");
70 run(
71 Command::new("ssh").arg(host).arg(cleanup),
72 "清理远端临时接收包",
73 )?;
74 Ok(report)
75 })();
76 let _ = fs::remove_dir_all(temporary);
77 result
78}
79
80pub fn receive(
81 bundle: &std::path::Path,
82 codex_home: Option<PathBuf>,
83 mirror: bool,
84) -> Result<SshReport> {
85 let home = dirs::home_dir().context("无法确定远端主目录")?;
86 let codex_home = codex_home.unwrap_or_else(|| home.join(".codex"));
87 codex::apply_bundle(bundle, &codex_home, mirror)
88}
89
90fn validate_host(host: &str) -> Result<()> {
91 if host.is_empty()
92 || !host
93 .chars()
94 .all(|character| character.is_ascii_alphanumeric() || "@._-".contains(character))
95 {
96 bail!("非法 SSH 主机名");
97 }
98 Ok(())
99}
100
101fn ensure_command(name: &str) -> Result<()> {
102 if Command::new(name).arg("-V").output().is_err() {
103 bail!("系统缺少 {name} 命令");
104 }
105 Ok(())
106}
107
108fn run(command: &mut Command, action: &str) -> Result<()> {
109 let status = command.status().with_context(|| action.to_owned())?;
110 if !status.success() {
111 bail!("{action}失败,退出码:{status}");
112 }
113 Ok(())
114}
115
116fn now() -> u64 {
117 SystemTime::now()
118 .duration_since(UNIX_EPOCH)
119 .unwrap_or_default()
120 .as_secs()
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn host_validation_blocks_shell_syntax() {
129 assert!(validate_host("lty").is_ok());
130 assert!(validate_host("user@example.local").is_ok());
131 assert!(validate_host("lty;rm -rf /").is_err());
132 }
133}