AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

AkurAI-Build / src/main.rs

Latest change 03e70748782395a3d797a3baee6369fb4a46c423 - OLI-276: the akurai-index token is a plain file, not a 32-byte hex secret by Ólafur Búi Ólafsson

use std::{env, net::SocketAddr, path::PathBuf};

use akurai_build::{
    DEFAULT_ADDRESS,
    auth::IdpConfig,
    db::{Database, generate_secret, load_secret},
    git_shell, mcp, oauth,
    server::{self, IndexConfig, ServerOptions},
};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use serde::Serialize;
use serde_json::json;
use tracing_subscriber::EnvFilter;

#[derive(Parser)]
#[command(
    name = "akurai",
    version,
    about = "Lean Git-native CI/CD on the Bunfork foundation"
)]
struct Cli {
    #[arg(long, env = "AKURAI_DATA")]
    data: Option<PathBuf>,
    #[arg(long, env = "AKURAI_KEY_FILE")]
    key_file: Option<PathBuf>,
    #[command(subcommand)]
    command: Action,
}

#[derive(Subcommand)]
enum Action {
    /// Generate a private 256-bit key or token.
    Keygen {
        #[arg(long)]
        out: PathBuf,
    },
    /// Create or update the encrypted database schema.
    Migrate,
    /// Serve the full AkurAI Build Model Context Protocol tool set over stdio.
    Mcp,
    /// Serve the MiniJinja dashboard, API, webhooks, and worker.
    Serve(ServeArgs),
    /// The forced command behind every SSH key: serve one git request from
    /// `SSH_ORIGINAL_COMMAND` on stdin/stdout (OLI-276).
    GitShell(GitShellArgs),
}

#[derive(Args)]
struct GitShellArgs {
    /// The `ssh_keys` row that authenticated this connection.
    #[arg(long)]
    key_id: i64,
    #[arg(long, env = "AKURAI_TOKEN_FILE")]
    token_file: Option<PathBuf>,
    /// Where the hooks reach the forge (branch protection, webhooks).
    #[arg(
        long,
        env = "AKURAI_BUILD_FORGE_URL",
        default_value = "http://127.0.0.1:3210"
    )]
    forge_url: String,
    /// Forge admins (comma-separated e-mails) who hold admin on every repository.
    #[arg(long, env = "AKURAI_BUILD_ADMIN_EMAILS", default_value = "")]
    admins: String,
}

#[derive(Args)]
struct ServeArgs {
    #[arg(long, env = "AKURAI_ADDRESS", default_value = DEFAULT_ADDRESS)]
    address: SocketAddr,
    #[arg(long, env = "AKURAI_TOKEN_FILE")]
    token_file: Option<PathBuf>,
    #[arg(long, env = "AKURAI_WEBHOOK_SECRET_FILE")]
    webhook_secret_file: Option<PathBuf>,
    #[arg(long, env = "AKURAI_PUBLIC_ORIGIN")]
    public_origin: Option<String>,
    #[arg(long, env = "AKURAI_ALLOW_NATIVE", default_value_t = false)]
    allow_native: bool,
    #[arg(long, env = "AKURAI_WORKERS", default_value_t = 2)]
    worker_count: usize,
    /// IdP whose access tokens the HTTP MCP endpoint accepts.
    #[arg(long, env = "AKURAI_BUILD_IDP_ISSUER", default_value = oauth::DEFAULT_ISSUER)]
    oauth_issuer: String,
    /// Audience an IdP access token must contain to reach the MCP endpoint.
    #[arg(long, env = "AKURAI_BUILD_OAUTH_AUDIENCE", default_value = oauth::DEFAULT_AUDIENCE)]
    oauth_audience: String,
    /// authorized_keys file the forge owns for Git over SSH (OLI-276); unset = SSH off.
    #[arg(long, env = "AKURAI_BUILD_AUTHORIZED_KEYS")]
    authorized_keys_file: Option<PathBuf>,
    /// `user@host` shown as the SSH clone address.
    #[arg(long, env = "AKURAI_BUILD_SSH_ADDRESS")]
    ssh_address: Option<String>,
    /// akurai-index base URL for code search (OLI-276); unset = search off.
    #[arg(long, env = "AKURAI_BUILD_INDEX_URL")]
    index_url: Option<String>,
    /// File holding the akurai-index bearer token.
    #[arg(long, env = "AKURAI_BUILD_INDEX_TOKEN_FILE")]
    index_token_file: Option<PathBuf>,
}

#[derive(Serialize)]
struct Envelope<T: Serialize> {
    ok: bool,
    data: T,
}

#[tokio::main]
async fn main() {
    init_logging();
    let cli = Cli::parse();
    if let Err(error) = execute(cli).await {
        let body = json!({"ok":false,"error":{"code":"failed","message":format!("{error:#}")}});
        println!(
            "{}",
            serde_json::to_string(&body).unwrap_or_else(|_| "{\"ok\":false}".into())
        );
        std::process::exit(1);
    }
}

async fn execute(cli: Cli) -> Result<()> {
    let paths = Paths::new(cli.data, cli.key_file)?;
    match cli.command {
        Action::Keygen { out } => {
            generate_secret(&out)?;
            print(json!({"path":out,"created":true}))?;
        }
        Action::Migrate => {
            let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
            let database = Database::open(&paths.database, &key, true)?;
            let applied = database.migrate()?;
            print(json!({"database":paths.database,"migrations_applied":applied}))?;
        }
        Action::Mcp => mcp::serve(open(&paths)?, paths.root.clone(), native_allowed())?,
        Action::Serve(args) => {
            let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
            let database = Database::open(&paths.database, &key, false)?;
            let token_file = args
                .token_file
                .unwrap_or_else(|| paths.config.join("api.token"));
            let webhook_file = args
                .webhook_secret_file
                .unwrap_or_else(|| paths.config.join("webhook.token"));
            let token = load_secret(&token_file, "AKURAI_API_TOKEN")?;
            let webhook_secret = load_secret(&webhook_file, "AKURAI_WEBHOOK_SECRET")?;
            let public_origin = args
                .public_origin
                .context("AKURAI_PUBLIC_ORIGIN is required")?;
            database.migrate()?;
            let authorized_keys = match args.authorized_keys_file {
                Some(path) => Some(server::sink_for(
                    path,
                    &paths.root,
                    &paths.key_file,
                    &token_file,
                    &format!("http://127.0.0.1:{}", args.address.port()),
                    &IdpConfig::from_env(&public_origin).admin_emails,
                )?),
                None => None,
            };
            let index = match (args.index_url, args.index_token_file) {
                (Some(url), Some(file)) => Some(IndexConfig {
                    url,
                    token: std::fs::read_to_string(&file)
                        .with_context(|| format!("read the index token from {}", file.display()))?
                        .trim()
                        .to_string(),
                }),
                (Some(url), None) => Some(IndexConfig {
                    url,
                    token: env::var("AKURAI_BUILD_INDEX_TOKEN").unwrap_or_default(),
                }),
                (None, _) => None,
            };
            server::serve(
                database,
                ServerOptions {
                    address: args.address,
                    token: token.to_string(),
                    webhook_secret: webhook_secret.to_string(),
                    public_origin,
                    data_root: paths.root,
                    allow_native: args.allow_native,
                    worker_count: args.worker_count,
                    oauth_issuer: args.oauth_issuer,
                    oauth_audience: args.oauth_audience,
                    paperclip: paperclip_link(),
                    mail: notification_mail(),
                    authorized_keys,
                    ssh_address: args.ssh_address,
                    index,
                },
            )
            .await?;
        }
        Action::GitShell(args) => {
            let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
            let database = Database::open(&paths.database, &key, false)?;
            let token_file = args
                .token_file
                .unwrap_or_else(|| paths.config.join("api.token"));
            let token = load_secret(&token_file, "AKURAI_API_TOKEN")?;
            let original = env::var("SSH_ORIGINAL_COMMAND").unwrap_or_default();
            let admins: Vec<String> = args
                .admins
                .split(',')
                .map(str::trim)
                .filter(|a| !a.is_empty())
                .map(str::to_owned)
                .collect();
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map_or(0, |d| d.as_secs()) as i64;
            let status = git_shell::run(
                &database,
                &git_shell::Shell {
                    root: &paths.root,
                    key_id: args.key_id,
                    original_command: &original,
                    forge_url: &args.forge_url,
                    forge_token: &token,
                    admins: &admins,
                    now,
                },
            );
            match status {
                Ok(status) => std::process::exit(status.code().unwrap_or(1)),
                Err(error) => {
                    eprintln!("akurai-build: {error:#}");
                    std::process::exit(1);
                }
            }
        }
    }
    Ok(())
}

struct Paths {
    root: PathBuf,
    database: PathBuf,
    config: PathBuf,
    key_file: PathBuf,
}

impl Paths {
    fn new(data: Option<PathBuf>, key_file: Option<PathBuf>) -> Result<Self> {
        let home = env::var_os("HOME")
            .map(PathBuf::from)
            .context("HOME is not set")?;
        let root = data.unwrap_or_else(|| home.join(".local/share/akurai-build"));
        let config = home.join(".config/akurai-build");
        let database = root.join("akurai.db");
        let key_file = key_file.unwrap_or_else(|| config.join("database.key"));
        Ok(Self {
            root,
            database,
            config,
            key_file,
        })
    }
}

fn open(paths: &Paths) -> Result<Database> {
    let key = load_secret(&paths.key_file, "AKURAI_DB_KEY")?;
    let database = Database::open(&paths.database, &key, false)?;
    database.validate_schema()?;
    Ok(database)
}

fn print<T: Serialize>(data: T) -> Result<()> {
    println!(
        "{}",
        serde_json::to_string_pretty(&Envelope { ok: true, data })?
    );
    Ok(())
}

fn native_allowed() -> bool {
    env::var("AKURAI_ALLOW_NATIVE")
        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
}

fn init_logging() {
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("akurai_build=info,tower_http=info"));
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_writer(std::io::stderr)
        .init();
}

/// `AKURAI_BUILD_PAPERCLIP_URL` and `AKURAI_BUILD_PAPERCLIP_TOKEN_FILE` (a board API key)
/// turn on issue linking; either missing means off.
fn paperclip_link() -> Option<(String, String)> {
    let url = std::env::var("AKURAI_BUILD_PAPERCLIP_URL").ok()?;
    let file = std::env::var("AKURAI_BUILD_PAPERCLIP_TOKEN_FILE").ok()?;
    let token = std::fs::read_to_string(file).ok()?;
    let token = token.trim().to_string();
    (!url.trim().is_empty() && !token.is_empty()).then_some((url.trim().to_string(), token))
}

/// `AKURAI_BUILD_MAIL_FROM` and `AKURAI_BUILD_MAIL_CMD` (a command reading the message on
/// stdin) turn on notification mail; either missing means feed only.
fn notification_mail() -> Option<(String, String)> {
    let from = std::env::var("AKURAI_BUILD_MAIL_FROM").ok()?;
    let command = std::env::var("AKURAI_BUILD_MAIL_CMD").ok()?;
    (!from.trim().is_empty() && !command.trim().is_empty())
        .then_some((from.trim().to_string(), command.trim().to_string()))
}