knishio-cli 0.1.4

KnishIO validator orchestration CLI — Docker control, cell management, benchmarks, and health checks
//! `knishio init` — interactive first-time production setup.
//!
//! Generates:
//! - `knishio.toml` with production defaults
//! - `secrets/` directory with jwt_secret, db_password, db_url
//! - `.env.production` from template
//! - Self-signed TLS certificates (optional)

use anyhow::{Context, Result};
use rand::Rng;
use std::fs;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;

use crate::output;

/// Characters used for generating database passwords (no shell-hostile chars).
const PASSWORD_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

/// Run the init workflow in the given project directory.
pub async fn run(project_dir: &Path, generate_tls: bool, cors_origins: Option<&str>) -> Result<()> {
    let validator_dir = find_validator_dir(project_dir)?;

    output::info("Initializing KnishIO production deployment...");

    // 1. Generate secrets
    let secrets_dir = validator_dir.join("secrets");
    generate_secrets(&secrets_dir)?;

    // 2. Generate knishio.toml
    generate_config(&validator_dir)?;

    // 3. Generate .env.production
    generate_env(&validator_dir, cors_origins)?;

    // 4. Optionally generate TLS certs
    if generate_tls {
        generate_tls_certs(&validator_dir).await?;
    } else {
        let certs_dir = validator_dir.join("certs");
        if !certs_dir.join("server.pem").exists() {
            output::warn("No TLS certificates found in certs/");
            output::info("  Run: knishio init --tls   to generate self-signed certs");
            output::info("  Or place your own server.pem + server-key.pem in certs/");
        }
    }

    // 5. Ensure required directories exist
    fs::create_dir_all(validator_dir.join("backups"))
        .context("Failed to create backups directory")?;
    fs::create_dir_all(validator_dir.join("models"))
        .context("Failed to create models directory")?;

    // 6. Print next steps
    println!();
    output::success("Production setup complete!");
    println!();
    output::info("Next steps:");
    output::info("  1. Review .env.production — set CORS_ORIGINS to your domain");
    output::info("  2. knishio start --build -d");
    output::info("  3. knishio cell create YOUR_CELL_NAME");
    output::info("  4. knishio full   (verify everything is healthy)");
    println!();
    output::info("Files created:");
    output::info(&format!("  secrets/          — JWT secret + DB credentials"));
    output::info(&format!("  knishio.toml      — CLI config (points to production compose)"));
    output::info(&format!("  .env.production   — environment config"));
    if generate_tls {
        output::info(&format!("  certs/            — self-signed TLS certificates"));
    }

    Ok(())
}

/// Generate cryptographic secrets in the secrets directory.
fn generate_secrets(secrets_dir: &Path) -> Result<()> {
    if secrets_dir.exists() {
        output::warn("secrets/ already exists — skipping secret generation");
        output::info("  Delete secrets/ and re-run to regenerate");
        return Ok(());
    }

    fs::create_dir_all(secrets_dir).context("Failed to create secrets directory")?;

    // JWT secret: 64-char hex
    let jwt_secret = generate_hex(64);
    fs::write(secrets_dir.join("jwt_secret"), &jwt_secret)
        .context("Failed to write jwt_secret")?;

    // DB password: 32-char alphanumeric
    let db_password = generate_password(32);
    fs::write(secrets_dir.join("db_password"), &db_password)
        .context("Failed to write db_password")?;

    // DB URL: full connection string using the generated password
    let db_url = format!(
        "postgres://knishio:{}@postgres:5432/knishio",
        db_password
    );
    fs::write(secrets_dir.join("db_url"), &db_url)
        .context("Failed to write db_url")?;

    // Restrict permissions (best-effort on non-Unix)
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = fs::Permissions::from_mode(0o600);
        for name in &["jwt_secret", "db_password", "db_url"] {
            let _ = fs::set_permissions(secrets_dir.join(name), perms.clone());
        }
        let dir_perms = fs::Permissions::from_mode(0o700);
        let _ = fs::set_permissions(secrets_dir, dir_perms);
    }

    output::success("Generated secrets in secrets/");
    Ok(())
}

/// Generate knishio.toml pointing to the production compose file.
fn generate_config(validator_dir: &Path) -> Result<()> {
    let config_path = validator_dir.join("knishio.toml");

    if config_path.exists() {
        output::warn("knishio.toml already exists — skipping");
        return Ok(());
    }

    let content = r#"# KnishIO CLI configuration — generated by `knishio init`

[validator]
url = "https://localhost:8080"
insecure_tls = true   # Set to false once you have real TLS certificates

[docker]
compose_file = "docker-compose.production.yml"
postgres_container = "knishio-postgres"
validator_container = "knishio-validator"

[database]
user = "knishio"
name = "knishio"
"#;

    fs::write(&config_path, content).context("Failed to write knishio.toml")?;
    output::success("Generated knishio.toml");
    Ok(())
}

/// Generate .env.production from the example template.
fn generate_env(validator_dir: &Path, cors_origins: Option<&str>) -> Result<()> {
    let env_path = validator_dir.join(".env.production");

    if env_path.exists() {
        output::warn(".env.production already exists — skipping");
        return Ok(());
    }

    let origins = cors_origins.unwrap_or("https://your-app.example.com");

    let content = format!(
        r#"# KnishIO Validator — Production Environment
# Generated by `knishio init`
#
# Secrets are in ./secrets/ (injected via Docker _FILE convention).
# Do NOT put passwords in this file.

# Allowed CORS origins (comma-separated, no wildcard)
CORS_ORIGINS={origins}

# Host port for the validator
VALIDATOR_PORT=8080

# Embedding / Generation (disabled by default)
EMBEDDING_ENABLED=false
GENERATION_ENABLED=false
"#
    );

    fs::write(&env_path, content).context("Failed to write .env.production")?;
    output::success("Generated .env.production");
    Ok(())
}

/// Generate self-signed TLS certificates using openssl.
async fn generate_tls_certs(validator_dir: &Path) -> Result<()> {
    let certs_dir = validator_dir.join("certs");

    if certs_dir.join("server.pem").exists() && certs_dir.join("server-key.pem").exists() {
        output::warn("TLS certificates already exist in certs/ — skipping");
        return Ok(());
    }

    fs::create_dir_all(&certs_dir).context("Failed to create certs directory")?;

    output::info("Generating self-signed TLS certificate (valid 365 days)...");

    let status = Command::new("openssl")
        .args([
            "req",
            "-x509",
            "-newkey",
            "rsa:4096",
            "-keyout",
        ])
        .arg(certs_dir.join("server-key.pem"))
        .args(["-out"])
        .arg(certs_dir.join("server.pem"))
        .args([
            "-days",
            "365",
            "-nodes",
            "-subj",
            "/CN=knishio-validator",
        ])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .await
        .context("Failed to run openssl — is it installed?")?;

    if status.success() {
        output::success("Generated self-signed TLS certificate in certs/");
    } else {
        output::error("openssl certificate generation failed");
        output::info("  Install openssl and re-run, or place certificates manually");
    }

    Ok(())
}

/// Find the validator directory from the current working directory.
fn find_validator_dir(start: &Path) -> Result<std::path::PathBuf> {
    // If we're already in the validator dir
    if start.join("Dockerfile").exists() && start.join("migrations").is_dir() {
        return Ok(start.to_path_buf());
    }

    // Try common relative paths
    let candidates = [
        start.join("knishio-validator-rust"),
        start.join("servers").join("knishio-validator-rust"),
    ];

    for candidate in &candidates {
        if candidate.join("Dockerfile").exists() {
            return Ok(candidate.to_path_buf());
        }
    }

    // Walk up
    let mut dir = start.to_path_buf();
    loop {
        let candidate = dir.join("servers").join("knishio-validator-rust");
        if candidate.join("Dockerfile").exists() {
            return Ok(candidate);
        }
        if !dir.pop() {
            break;
        }
    }

    anyhow::bail!(
        "Could not find the knishio-validator-rust directory.\n\
         Run this command from inside the KnishIO project tree."
    )
}

/// Generate a hex string of the given length.
fn generate_hex(len: usize) -> String {
    let mut rng = rand::thread_rng();
    (0..len)
        .map(|_| format!("{:x}", rng.gen::<u8>() % 16))
        .collect()
}

/// Generate a random password using alphanumeric characters.
fn generate_password(len: usize) -> String {
    let mut rng = rand::thread_rng();
    (0..len)
        .map(|_| {
            let idx = rng.gen_range(0..PASSWORD_CHARS.len());
            PASSWORD_CHARS[idx] as char
        })
        .collect()
}