slave 0.7.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Turso migrations, add server-rendered web apps, and route them by domain or path
//! The `{{ app }}` app: pages rendered on the server from the templates in
//! `templates/` ({{ engine_name }}, see [`views`]), served with Axum.
//!
//! It is part of the project's workspace and can be routed by the gateway
//! (see `config/apps.toml`). Business data comes from the project's MCP
//! backend through [`mcp::McpClient`]; the app never touches the database.
//! Users sign in at the OpenID Connect provider (see `pages.rs`).

mod mcp;
mod pages;
mod views;

use std::process::ExitCode;

use std::path::PathBuf;
use std::sync::Arc;

use slave_framework::env;
use slave_framework::oidc::OidcClient;
use tokio::sync::OnceCell;

/// The app's name, shown by the templates as `app`.
pub const TITLE: &str = "{{ title }}";

/// Port used when `PORT` is not set; `slave serve` sets it from `config/apps.toml`.
const DEFAULT_PORT: u16 = {{ port }};

/// State shared by every page.
#[derive(Clone)]
pub struct AppState {
    pub mcp: mcp::McpClient,
    /// Path prefix the app is served under, e.g. `/admin`, or `""` at the root.
    pub base: String,
    pub sign_in: Arc<SignInSettings>,
    /// The templates, loaded at start-up (and reloaded per request in debug
    /// builds, so template edits show without restarting).
    pub views: Arc<views::Views>,
    pub templates_dir: Arc<PathBuf>,
    /// The OpenID Connect client, discovered on first sign-in.
    oidc: Arc<OnceCell<OidcClient>>,
}

/// How this app signs users in at the OpenID Connect provider.
pub struct SignInSettings {
    /// `OIDC_ISSUER_URL`, shared with the backend.
    pub issuer: Option<String>,
    /// `{{ env }}_OIDC_CLIENT_ID`: this app's client ID at the provider (also
    /// listed in the backend's `OIDC_AUDIENCES`).
    pub client_id: Option<String>,
    /// `{{ env }}_OIDC_CLIENT_SECRET`, if the provider gave this app one.
    pub client_secret: Option<String>,
    /// `{{ env }}_PUBLIC_URL`, e.g. `https://shop.example.com`; otherwise the
    /// callback URL is derived from the request (and the gateway's headers).
    pub public_url: Option<String>,
}

impl SignInSettings {
    fn from_env() -> Self {
        let var = |key: &str| {
            std::env::var(key)
                .ok()
                .filter(|value| !value.trim().is_empty())
        };
        Self {
            issuer: var("OIDC_ISSUER_URL"),
            client_id: var("{{ env }}_OIDC_CLIENT_ID"),
            client_secret: var("{{ env }}_OIDC_CLIENT_SECRET"),
            public_url: var("{{ env }}_PUBLIC_URL"),
        }
    }
}

impl AppState {
    /// The OpenID Connect client, or why sign-in is unavailable.
    pub async fn oidc(&self) -> Result<&OidcClient, String> {
        let settings = &self.sign_in;
        let (Some(issuer), Some(client_id)) = (&settings.issuer, &settings.client_id) else {
            return Err(
                "Sign-in is not configured: set OIDC_ISSUER_URL and {{ env }}_OIDC_CLIENT_ID in .env."
                    .to_owned(),
            );
        };
        self.oidc
            .get_or_try_init(|| {
                OidcClient::discover(issuer, client_id, settings.client_secret.as_deref())
            })
            .await
            .map_err(|err| {
                tracing::warn!("{err}");
                "The sign-in provider is not reachable right now.".to_owned()
            })
    }

    /// `path` (starting with `/`) under the app's base path.
    pub fn url(&self, path: &str) -> String {
        match (self.base.as_str(), path) {
            ("", path) => path.to_owned(),
            (base, "/") => base.to_owned(),
            (base, path) => format!("{base}{path}"),
        }
    }
}

#[tokio::main]
async fn main() -> ExitCode {
    match run().await {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("error: {err}");
            ExitCode::FAILURE
        }
    }
}

async fn run() -> slave_framework::Result<()> {
    env::load_dotenv()?;
    slave_framework::logging::init()?;

    let host = env::var_or("HOST", "127.0.0.1");
    let port = env::parse_or("PORT", DEFAULT_PORT)?;
    let mcp = mcp::McpClient::new(mcp_url());
    tracing::info!("{{ app }} uses the MCP backend at {}", mcp.url());
    let templates_dir = templates_dir();
    let views = views::Views::load(&templates_dir).map_err(|err| {
        slave_framework::Error::Server(std::io::Error::other(format!(
            "templates in {}: {err}",
            templates_dir.display()
        )))
    })?;
    let state = AppState {
        mcp,
        views: Arc::new(views),
        templates_dir: Arc::new(templates_dir),
        base: base_path(),
        sign_in: Arc::new(SignInSettings::from_env()),
        oidc: Arc::new(OnceCell::new()),
    };

    slave_framework::server::serve(pages::router(state), &host, port).await
}

/// `{{ env }}_TEMPLATES_DIR`, or this app's `templates/` directory.
fn templates_dir() -> PathBuf {
    std::env::var("{{ env }}_TEMPLATES_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")))
}

/// `SLAVE_BASE_PATH` (set when the gateway routes a path prefix such as
/// `/admin` to this app), normalized to `/admin`, or `""` for the root.
fn base_path() -> String {
    let base = env::var_or("SLAVE_BASE_PATH", "");
    let base = base.trim_matches('/');
    if base.is_empty() {
        String::new()
    } else {
        format!("/{base}")
    }
}

/// `SLAVE_MCP_URL`, or the backend's `APP_HOST:APP_PORT` from `.env`.
fn mcp_url() -> String {
    std::env::var("SLAVE_MCP_URL").unwrap_or_else(|_| {
        let host = match env::var_or("APP_HOST", "127.0.0.1").as_str() {
            "0.0.0.0" | "" => "127.0.0.1".to_owned(),
            host => host.to_owned(),
        };
        let port = env::var_or("APP_PORT", "3000");
        format!("http://{host}:{port}/mcp")
    })
}