slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! The single runtime: the MCP backend and every app of `apps/` in one
//! process.
//!
//! Requests on `[gateway] listen` (`GATEWAY_LISTEN`) are routed by
//! `config/apps.toml` (`SLAVE_ROUTES` names another file, e.g.
//! `config/apps.fly.toml` in production), without a
//! proxy hop: apps call
//! MCP tools on the backend's router in-process. The backend also listens on
//! `APP_HOST:APP_PORT` for MCP clients (and separate apps), and apps without
//! `host`/`path` ("direct") on their own `port`. Apps with `embed = false`
//! run as processes of their own; their routes are proxied to them.

mod apps;

use std::process::ExitCode;

use axum::Router;
use slave_gateway::embed::Site;
use tokio::task::JoinSet;

use ::{{ crate_name }}::app;
use ::{{ crate_name }}::base::config::Config;
use ::{{ crate_name }}::base::state::AppState;

/// Where the routing table lives, relative to the project root, unless
/// `SLAVE_ROUTES` says otherwise.
const ROUTES: &str = "config/apps.toml";

/// Answers `ok` on every host, for the platform's health checks (which do
/// not send a routed `Host`).
const HEALTH_PATH: &str = "/_slave/health";

/// Collects the app routers for [`apps::mount`].
pub struct Mount {
    routes: slave_gateway::Config,
    backend: Router,
    site: Option<Site>,
    /// Apps reached on a port of their own: `(app, port, router)`.
    direct: Vec<(String, u16, Router)>,
}

impl Mount {
    /// Mounts app `name`, built by `make` from the backend's router and the
    /// app's base path, wherever `config/apps.toml` lists it.
    pub fn app(
        &mut self,
        name: &str,
        make: fn(Router, &str) -> slave_framework::Result<Router>,
    ) -> slave_framework::Result<()> {
        let listed = self.routes.domains.iter().any(|d| d.app == name);
        let entries: Vec<_> = self
            .routes
            .domains
            .iter()
            .filter(|d| d.app == name && d.is_embedded())
            .collect();
        if entries.is_empty() {
            if listed {
                tracing::info!("app `{name}` runs separately (embed = false); proxied");
            } else {
                tracing::warn!("app `{name}` is not listed in {ROUTES}; not served");
            }
            return Ok(());
        }
        let base = entries
            .iter()
            .find_map(|d| d.path.clone())
            .unwrap_or_default();
        let router = make(self.backend.clone(), &base)?;
        if entries.iter().any(|d| d.is_routed()) {
            let site = self.site.take().expect("site is set while mounting");
            self.site = Some(site.mount(name, router.clone()));
        }
        for entry in entries.iter().filter(|d| !d.is_routed()) {
            if let Some(port) = entry.port {
                self.direct.push((name.to_owned(), port, router.clone()));
            }
        }
        Ok(())
    }
}

#[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<()> {
    slave_framework::env::load_dotenv()?;
    slave_framework::logging::init()?;

    let config = Config::from_env()?;
    let db = ::{{ crate_name }}::database(&config.database).await?;
    tracing::info!("Database: {}", config.database.describe());
    let (host, port) = (config.host.clone(), config.port);
    let backend = app::router(AppState::new(config, db.clone())?);

    let routes_file = std::env::var("SLAVE_ROUTES").unwrap_or_else(|_| ROUTES.to_owned());
    let routes = slave_gateway::Config::load(&routes_file).map_err(startup)?;
    tracing::info!("Routes: {routes_file}");
    // `SLAVE_TRUSTED_PROXY=1` when only a proxy that sets X-Forwarded-Proto
    // reaches this process (Fly.io): apps then build https callback URLs.
    let trusted = matches!(
        std::env::var("SLAVE_TRUSTED_PROXY").as_deref(),
        Ok("1" | "true")
    );
    let listen = std::env::var("GATEWAY_LISTEN").unwrap_or_else(|_| routes.gateway.listen.clone());
    let site = Site::new(&routes)
        .trust_forwarded(trusted)
        .mount(slave_gateway::config::MCP_APP, backend.clone());
    let mut mount = Mount {
        routes,
        backend: backend.clone(),
        site: Some(site),
        direct: Vec::new(),
    };
    apps::mount(&mut mount)?;
    let site = mount.site.take().expect("site is set after mounting");
    // Every app in config/apps.toml gets a row, owned by its `team` (set by
    // `slave make:app`); a super admin can change it (`app_assign`).
    let mut apps: Vec<(&str, Option<&str>)> = mount
        .routes
        .domains
        .iter()
        .filter(|d| !d.is_mcp())
        .map(|d| (d.app.as_str(), d.team.as_deref()))
        .collect();
    apps.sort_unstable();
    apps.dedup_by_key(|(name, _)| *name);
    ::{{ crate_name }}::modules::app::sync(&db, &apps)
        .await
        .map_err(startup)?;
    for app in site.unmounted() {
        tracing::warn!("{ROUTES} routes to `{app}`, which this runtime does not include");
    }

    let mut servers = JoinSet::new();
    let (listen_host, listen_port) = split_address(&listen)?;
    let site = site
        .into_router()
        .route(HEALTH_PATH, axum::routing::get(|| async { "ok" }));
    servers.spawn(serve(site, listen_host, listen_port));
    servers.spawn(serve(backend, host, port));
    for (name, port, router) in mount.direct {
        tracing::info!("{name}: direct on port {port}");
        servers.spawn(serve(router, "127.0.0.1".to_owned(), port));
    }
    // Every server stops on Ctrl+C or SIGTERM; the first error stops the rest.
    while let Some(finished) = servers.join_next().await {
        match finished {
            Ok(Ok(())) => {}
            Ok(Err(err)) => return Err(err),
            Err(err) => return Err(startup(err)),
        }
    }
    Ok(())
}

async fn serve(router: Router, host: String, port: u16) -> slave_framework::Result<()> {
    slave_framework::server::serve(router, &host, port).await
}

fn split_address(address: &str) -> slave_framework::Result<(String, u16)> {
    let parsed = address.rsplit_once(':').and_then(|(host, port)| {
        Some((host.trim_matches(['[', ']']).to_owned(), port.parse().ok()?))
    });
    parsed.ok_or_else(|| startup(format!("`{address}` must be host:port")))
}

fn startup(err: impl std::fmt::Display) -> slave_framework::Error {
    slave_framework::Error::Server(std::io::Error::other(err.to_string()))
}