kegani-cli 0.1.4

CLI tool for Kegani framework
Documentation
//! `keg secret` command — Generate secure secrets and API keys

use anyhow::Result;
use console::{style, Emoji};
use std::io::{self, Write};

/// Generate secure secrets and API keys
pub fn generate_secret(kind: &str, length: Option<usize>) -> Result<()> {
    println!();

    let secret = match kind {
        "secret" | "Secret" | "SESSION_SECRET" => generate_random_secret(length.unwrap_or(32)),
        "token" | "Token" | "ACCESS_TOKEN" => generate_token(length.unwrap_or(32)),
        "jwt" | "JWT_SECRET" => generate_jwt_secret(),
        "apikey" | "api-key" | "API_KEY" => generate_api_key(),
        "password" | "PASSWORD" => generate_password(length.unwrap_or(16)),
        "database" | "DB_PASSWORD" => generate_db_password(),
        _ => {
            println!("  {} Unknown secret type '{}'. Using 'secret'.", style("").yellow(), kind);
            println!("  {} Available types: secret, token, jwt, apikey, password, database", style("").dim());
            println!();
            generate_random_secret(length.unwrap_or(32))
        }
    };

    println!("{} {}", Emoji("🔐", ""), style("Generated Secret").bold());
    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
    println!();

    // Print secret
    println!("  {} {}", style("Type:").dim(), style(kind).cyan());
    println!();

    // Use fancy box for the secret
    let width = secret.len().min(60).max(20);
    let top_bottom = "".to_string() + &"".repeat(width + 2) + "";
    let middle = format!("│ {:width$} │", secret, width = width);

    println!("  {}", top_bottom);
    println!("  {}", middle);
    println!("  {}", top_bottom.replace("", "").replace("", ""));
    println!();

    // Copy to clipboard suggestion
    println!("  {} {}", style("").cyan(), style("Tip: Copy with mouse (click and drag to select)").dim());
    println!();

    // Option to save to .env
    print!("  {} Save to .env? (y/N): ", style("?").cyan());
    io::stdout().flush()?;

    let mut input = String::new();
    if io::stdin().read_line(&mut input).is_ok() {
        if input.trim().to_lowercase() == "y" {
            save_to_env(&secret, kind)?;
        }
    }

    Ok(())
}

fn generate_random_secret(length: usize) -> String {
    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
    let mut rng = Xorshift64::new();
    (0..length)
        .map(|_| {
            let idx = rng.next_u64() as usize % CHARSET.len();
            CHARSET[idx] as char
        })
        .collect()
}

fn generate_token(length: usize) -> String {
    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    let mut rng = Xorshift64::new();
    (0..length)
        .map(|_| {
            let idx = rng.next_u64() as usize % CHARSET.len();
            CHARSET[idx] as char
        })
        .collect()
}

fn generate_jwt_secret() -> String {
    // JWT secrets are typically 256 bits (32 bytes) base64 encoded
    let mut rng = Xorshift64::new();
    let bytes: Vec<u8> = (0..32).map(|_| rng.next_u64() as u8).collect();
    base64_encode(&bytes)
}

fn generate_api_key() -> String {
    // API keys often have prefix_ format
    let prefix = "keg_";
    let mut rng = Xorshift64::new();
    let key: String = (0..24)
        .map(|_| {
            let idx = rng.next_u64() as usize % 36;
            if idx < 10 {
                (b'0' + idx as u8) as char
            } else {
                (b'a' + (idx - 10) as u8) as char
            }
        })
        .collect();
    format!("{}{}", prefix, key)
}

fn generate_password(length: usize) -> String {
    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
    let mut rng = Xorshift64::new();
    (0..length)
        .map(|_| {
            let idx = rng.next_u64() as usize % CHARSET.len();
            CHARSET[idx] as char
        })
        .collect()
}

fn generate_db_password() -> String {
    // PostgreSQL compatible password (max 99 bytes, no backslash or single quote)
    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@$%^&*()-_=+[]{}|;:,.<>?";
    let mut rng = Xorshift64::new();
    (0..20)
        .map(|_| {
            let idx = rng.next_u64() as usize % CHARSET.len();
            CHARSET[idx] as char
        })
        .collect()
}

fn save_to_env(secret: &str, kind: &str) -> Result<()> {
    let env_key = match kind {
        "secret" | "SESSION_SECRET" => "SESSION_SECRET",
        "token" | "ACCESS_TOKEN" => "ACCESS_TOKEN",
        "jwt" | "JWT_SECRET" => "JWT_SECRET",
        "apikey" | "api-key" | "API_KEY" => "API_KEY",
        "password" | "PASSWORD" => "APP_PASSWORD",
        "database" | "DB_PASSWORD" => "DB_PASSWORD",
        _ => "SECRET_KEY",
    };

    let env_file = std::path::Path::new(".env");

    // Read existing .env
    let existing = if env_file.exists() {
        std::fs::read_to_string(env_file)?
    } else {
        String::new()
    };

    // Check if key already exists
    if existing.lines().any(|line| line.starts_with(&format!("{}=", env_key))) {
        println!("  {} {} already exists in .env, skipping", style("").yellow(), env_key);
        return Ok(());
    }

    // Append new secret
    let mut content = existing.trim().to_string();
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str(&format!("{}={}\n", env_key, secret));

    std::fs::write(env_file, &content)?;
    println!("  {} {} saved to .env", style("").green(), env_key);

    Ok(())
}

// Simple random number generator (Xorshift64)
struct Xorshift64 {
    state: u64,
}

impl Xorshift64 {
    fn new() -> Self {
        // Seed with current time
        let seed = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos() as u64)
            .unwrap_or(123456789);
        Self { state: seed }
    }

    fn next_u64(&mut self) -> u64 {
        let mut x = self.state;
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        self.state = x;
        x
    }
}

fn base64_encode(bytes: &[u8]) -> String {
    const BASE64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    bytes
        .chunks(3)
        .flat_map(|chunk| {
            let b = match chunk.len() {
                1 => [chunk[0], 0, 0],
                2 => [chunk[0], chunk[1], 0],
                _ => [chunk[0], chunk[1], chunk[2]],
            };
            [
                BASE64[(b[0] >> 2) as usize],
                BASE64[((b[0] & 0x03) << 4 | b[1] >> 4) as usize],
                if chunk.len() > 1 { BASE64[((b[1] & 0x0f) << 2 | b[2] >> 6) as usize] } else { b'=' },
                if chunk.len() > 2 { BASE64[(b[2] & 0x3f) as usize] } else { b'=' },
            ]
        })
        .map(|c| c as char)
        .collect()
}