void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Pin-remote command — pin a commit to a remote IPFS node via SSH.
//!
//! Flow: export CAR → SCP to remote → SSH `ipfs dag import` + `ipfs pin add`.

use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::Serialize;
use void_core::config;
use void_core::pipeline::{export_commit_to_car, ExportCarOptions};

use crate::context::open_repo;
use crate::output::{run_command, CliError, CliOptions};

// ============================================================================
// Output types
// ============================================================================

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PinRemoteOutput {
    pub remote: String,
    pub commit: String,
    pub blocks_exported: usize,
    pub car_size: u64,
    pub host: String,
}

// ============================================================================
// Args
// ============================================================================

pub struct PinRemoteArgs {
    pub remote: String,
    pub commit: Option<String>,
}

// ============================================================================
// Implementation
// ============================================================================

fn expand_tilde(path: &str) -> String {
    if path.starts_with('~') {
        if let Some(home) = dirs::home_dir() {
            return path.replacen('~', &home.to_string_lossy(), 1);
        }
    }
    path.to_string()
}

pub fn run(cwd: &Path, args: PinRemoteArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("pin-remote", opts, |ctx| {
        let repo = open_repo(cwd)?;
        let void_dir = repo.void_dir().to_owned();

        // Load config and resolve remote
        let cfg = config::load(void_dir.as_std_path())
            .map_err(|e| CliError::internal(format!("failed to load config: {e}")))?;

        let remote = cfg.remote.get(&args.remote).ok_or_else(|| {
            CliError::not_found(format!(
                "Remote '{}' not found. Use 'void remote add' to configure it.",
                args.remote
            ))
        })?;

        let ssh_host = remote.host.as_deref().ok_or_else(|| {
            CliError::invalid_args(format!("Remote '{}' has no host configured", args.remote))
        })?;
        let ssh_user = remote.user.as_deref().unwrap_or("root");
        let ssh_key = remote
            .key_path
            .as_deref()
            .map(expand_tilde)
            .unwrap_or_else(|| {
                dirs::home_dir()
                    .map(|h| h.join(".ssh/id_rsa").to_string_lossy().to_string())
                    .unwrap_or_else(|| "~/.ssh/id_rsa".to_string())
            });

        let host_display = format!("{}@{}", ssh_user, ssh_host);

        // Step 1: Export CAR
        if !ctx.use_json() {
            ctx.info("Exporting commit to CAR file...");
        }

        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis();
        let temp_car = std::env::temp_dir().join(format!("void-{}.car", ts));

        let export_opts = ExportCarOptions {
            ctx: repo.context().clone(),
            commit_cid: args.commit.clone(),
        };

        let export_result = export_commit_to_car(export_opts, &temp_car)
            .map_err(|e| CliError::internal(format!("failed to export CAR: {e}")))?;

        if !ctx.use_json() {
            ctx.info(format!(
                "Exported {} blocks ({} bytes)",
                export_result.blocks_exported, export_result.car_size
            ));
        }

        // Step 2: SCP to remote
        if !ctx.use_json() {
            ctx.info(format!("Copying to {}...", host_display));
        }

        let remote_car_path = format!("/tmp/void-{}.car", ts);
        let scp_dest = format!("{}@{}:{}", ssh_user, ssh_host, remote_car_path);

        let scp_status = Command::new("scp")
            .args([
                "-i",
                &ssh_key,
                "-o",
                "StrictHostKeyChecking=accept-new",
                &temp_car.to_string_lossy(),
                &scp_dest,
            ])
            .output();

        // Clean up local temp file regardless of SCP result
        let _ = std::fs::remove_file(&temp_car);

        let scp_output =
            scp_status.map_err(|e| CliError::internal(format!("failed to run scp: {e}")))?;

        if !scp_output.status.success() {
            let stderr = String::from_utf8_lossy(&scp_output.stderr);
            return Err(CliError::internal(format!("SCP failed: {}", stderr.trim())));
        }

        // Step 3: SSH import and pin
        if !ctx.use_json() {
            ctx.info("Importing and pinning on remote...");
        }

        let ssh_cmd = format!(
            "sudo IPFS_PATH=/home/ipfs/.ipfs ipfs dag import {} && \
             sudo IPFS_PATH=/home/ipfs/.ipfs ipfs pin add {} && \
             rm {}",
            remote_car_path, export_result.commit_cid, remote_car_path
        );

        let ssh_output = Command::new("ssh")
            .args([
                "-i",
                &ssh_key,
                "-o",
                "StrictHostKeyChecking=accept-new",
                &format!("{}@{}", ssh_user, ssh_host),
                &ssh_cmd,
            ])
            .output()
            .map_err(|e| CliError::internal(format!("failed to run ssh: {e}")))?;

        if !ssh_output.status.success() {
            let stderr = String::from_utf8_lossy(&ssh_output.stderr);
            return Err(CliError::internal(format!(
                "SSH command failed: {}",
                stderr.trim()
            )));
        }

        if !ctx.use_json() {
            ctx.info(format!(
                "Pinned {} on {}",
                export_result.commit_cid, host_display
            ));
        }

        Ok(PinRemoteOutput {
            remote: args.remote,
            commit: export_result.commit_cid,
            blocks_exported: export_result.blocks_exported,
            car_size: export_result.car_size,
            host: host_display,
        })
    })
}