void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Invite command — create an invite JSON for contributor onboarding.
//!
//! Creates a `void-invite/v1` JSON blob containing an ECIES-wrapped repo key
//! for a specific contributor. The JSON is output for the caller to share
//! out-of-band (e.g. encrypted message, file transfer).

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

use serde::Serialize;
use void_core::collab::invite::{Invite, INVITE_TYPE_V1};
use void_core::collab::manifest::{ecies_wrap_key, load_manifest, load_repo_key};

use crate::context::{find_void_dir, load_identity_cached, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};

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

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteOutput {
    /// The invite JSON.
    pub invite_json: String,
    /// Recipient name or pubkey prefix.
    pub target: String,
}

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

pub struct InviteArgs {
    /// Contributor name or signing pubkey prefix.
    pub target: String,
}

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

pub fn run(cwd: &Path, args: InviteArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("contributors invite", opts, |ctx| {
        let void_dir = find_void_dir(cwd)?;
        let identity = load_identity_cached()?;
        let caller_signing = identity.signing_pubkey();

        ctx.progress("Loading manifest...");

        // Load manifest — must exist and caller must be owner
        let manifest = load_manifest(&void_dir)
            .map_err(void_err_to_cli)?
            .ok_or_else(|| {
                CliError::not_found("No manifest found. Initialize with 'void contributors add'.")
            })?;

        if !manifest.is_owner_or_identity(&caller_signing) {
            return Err(CliError::invalid_args(
                "Only the repository owner can create invites",
            ));
        }

        // Find the target contributor
        let contributor = find_contributor_by_target(&manifest, &args.target)?;
        let recipient_signing = contributor.identity.signing.clone();
        let recipient_pubkey = contributor.identity.recipient.clone();

        ctx.progress("Building invite...");

        // Load repo key (ECIES unwrap via caller's identity)
        let repo_key = load_repo_key(&void_dir, Some(&identity)).map_err(void_err_to_cli)?;

        // Wrap key for recipient
        let wrapped = ecies_wrap_key(&repo_key, &recipient_pubkey)
            .map_err(|e| CliError::internal(format!("ECIES key wrapping failed: {e}")))?;

        // Get HEAD CID
        let head_bytes = resolve_ref(&void_dir, "HEAD")?;
        let head_cid_obj =
            void_core::cid::from_bytes(head_bytes.as_bytes()).map_err(void_err_to_cli)?;
        let head_cid = head_cid_obj.to_string();

        // Build invite
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);

        let mut invite = Invite {
            invite_type: INVITE_TYPE_V1.to_string(),
            repo_name: manifest.repo_name.clone(),
            repo_id: manifest.repo_id.clone(),
            head_cid,
            wrapped_key: wrapped,
            for_recipient: recipient_signing.clone(),
            from_owner: caller_signing,
            created_at: timestamp,
            signature: vec![],
        };

        // Sign with owner's Ed25519 key
        invite.sign(identity.signing_key());

        // Serialize
        let invite_json = serde_json::to_string_pretty(&invite)
            .map_err(|e| CliError::internal(format!("JSON serialization failed: {e}")))?;

        // Human output
        if !ctx.use_json() {
            let hex = recipient_signing.to_hex();
            let recipient_label = contributor
                .name
                .as_deref()
                .unwrap_or(&hex[..16]);

            ctx.info(format!("Invite created for {}", recipient_label));
            ctx.info("");
            ctx.info("Share this invite JSON with the contributor:");
            ctx.info(&invite_json);
        }

        Ok(InviteOutput {
            invite_json,
            target: args.target,
        })
    })
}

/// Find a contributor by name or signing pubkey prefix.
fn find_contributor_by_target<'a>(
    manifest: &'a void_core::collab::Manifest,
    target: &str,
) -> Result<&'a void_core::collab::Contributor, CliError> {
    // Try exact name match first
    if let Some(contrib) = manifest
        .contributors
        .iter()
        .find(|c| c.name.as_deref() == Some(target))
    {
        return Ok(contrib);
    }

    // Try signing pubkey prefix match
    let target_lower = target.to_lowercase();
    let matches: Vec<_> = manifest
        .contributors
        .iter()
        .filter(|c| c.identity.signing.to_hex().starts_with(&target_lower))
        .collect();

    match matches.len() {
        0 => Err(CliError::not_found(format!(
            "No contributor matching '{}'. Use 'void contributors list' to see contributors.",
            target
        ))),
        1 => Ok(matches[0]),
        _ => Err(CliError::invalid_args(format!(
            "Ambiguous target '{}': matches {} contributors. Use a longer prefix.",
            target,
            matches.len()
        ))),
    }
}