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
//! `slave make:app <name> --domain <host>`: a web app in `apps/<name>`.
//!
//! The app is a crate in the project's workspace — Axum pages rendered with a
//! template engine — that gets its data from the MCP backend. It is
//! registered in `config/apps.toml` (where it is routed) and mounted in the
//! single runtime (`runtime/`); with the gateway installed, `slave serve`
//! starts it as a process of its own instead.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
use slave_gateway::Config;
use slave_gateway::routes::HostPattern;

use crate::cli::{MakeAppArgs, Stack};
use crate::templates::Engine;
use crate::{apps, generator, project, runtime, session, templates};

/// First port handed out to apps.
const FIRST_PORT: u16 = 4000;

/// What kind of app to create.
#[derive(Clone, Copy)]
pub enum Kind {
    /// A theme: home and sign-in pages in the given template engine.
    Theme(&'static Engine),
    /// The admin app `slave new` creates: sign-in and sign-up, teams and
    /// their members (MiniJinja).
    Admin,
}

/// An app to create in `apps/<name>`.
pub struct Spec<'a> {
    pub name: &'a str,
    pub host: Option<&'a str>,
    pub path: Option<&'a str>,
    /// `None`: the next free port from 4000.
    pub port: Option<u16>,
    pub separate: bool,
    /// `access = "customer"` (see `slave make:app --customer`).
    pub customer: bool,
    /// Slug of the owner team (`team = "..."`).
    pub team: Option<String>,
    pub kind: Kind,
}

pub fn run(args: &MakeAppArgs) -> Result<()> {
    let root = match &args.project {
        Some(path) => project::find_root_from(path)?,
        None => match project::find_root() {
            Ok(root) => root,
            Err(err) => {
                let root = session::remembered_project().ok_or(err)?;
                println!("Project: {}", root.display());
                root
            }
        },
    };
    runtime::require(&root)?;
    let kind = Kind::Theme(match args.stack {
        Stack::Minijinja => &templates::MINIJINJA,
        Stack::Tera => &templates::TERA,
        Stack::Handlebars => &templates::HANDLEBARS,
        Stack::Liquid => &templates::LIQUID,
    });
    let spec = Spec {
        name: &args.name,
        host: args.domain.as_deref(),
        path: args.path.as_deref(),
        port: args.port,
        separate: args.separate,
        customer: args.customer,
        team: Some(choose_team(&root, args.team.as_deref())?),
        kind,
    };
    let url = create(&root, &spec)?;
    println!(
        "App {} [apps/{}] created successfully.",
        spec.name, spec.name
    );
    println!("Run `slave build`, then `slave serve`, and open {url}");
    Ok(())
}

/// Writes `apps/<name>`, routes it in `config/apps.toml`, mounts it in the
/// runtime (unless separate).
/// Returns the URL it will be served at.
pub fn create(root: &Path, spec: &Spec) -> Result<String> {
    let name = spec.name;
    slave_gateway::validate_app_name(name).map_err(|err| anyhow!(err))?;
    let (host, path) = (spec.host, spec.path);
    if let Some(host) = host {
        HostPattern::parse(host).map_err(|err| anyhow!("--domain: {err}"))?;
    }
    if let Some(path) = path {
        slave_gateway::routes::validate_path(path).map_err(|err| anyhow!("--path: {err}"))?;
    }
    let route = slave_gateway::routes::label(host, path);
    let root = root.to_path_buf();
    let package = project::package_name(&root)?;
    if [package.as_str(), "migrate", "runtime"].contains(&name) {
        bail!("`{name}` is already a binary of this project; choose another app name");
    }

    let config_path = root.join(apps::CONFIG);
    let text = fs::read_to_string(&config_path).with_context(|| {
        format!(
            "{} not found; projects created before slave 0.4 need it (copy it from a new project)",
            apps::CONFIG
        )
    })?;
    let config = Config::parse(&text).with_context(|| format!("invalid {}", apps::CONFIG))?;
    let routed = host.is_some() || path.is_some();
    if let Some(domain) = config
        .domains
        .iter()
        .find(|d| routed && d.host.as_deref() == host && d.path.as_deref() == path)
    {
        bail!(
            "`{route}` is already routed to `{}` in {}",
            domain.app,
            apps::CONFIG
        );
    }
    if config.domains.iter().any(|d| d.app == name) {
        bail!(
            "app `{name}` already exists in {}; add another [[domain]] for it there",
            apps::CONFIG
        );
    }
    let app_dir = root.join("apps").join(name);
    if app_dir.exists() {
        bail!("apps/{name} already exists");
    }
    let engine = match spec.kind {
        Kind::Theme(engine) => engine,
        Kind::Admin => &templates::MINIJINJA,
    };

    let port = match spec.port {
        Some(0) => bail!("--port must be between 1 and 65535"),
        Some(port) => port,
        None => next_port(&config),
    };
    if let Some(domain) = config.domains.iter().find(|d| d.port == Some(port)) {
        bail!("port {port} is already used by app `{}`", domain.app);
    }

    let mut updated = apps::append_domain(&text, host, path, name, port, spec.separate);
    if spec.customer {
        updated.push_str("access = \"customer\"\n");
    }
    if let Some(team) = &spec.team {
        updated.push_str(&format!("team = \"{team}\"\n"));
    }
    Config::parse(&updated).with_context(|| format!("cannot route `{route}` to `{name}`"))?;

    let port_text = port.to_string();
    let title = title_case(name);
    let env = env_prefix(name);
    let crate_name = name.replace('-', "_");
    let vars = [
        ("app", name),
        ("crate_name", &crate_name),
        ("title", &title),
        ("port", &port_text),
        ("env", &env),
        ("engine_dependency", engine.dependency),
        ("engine_name", engine.name),
        ("customer", if spec.customer { "true" } else { "false" }),
    ];
    let (rendered, raw) = match spec.kind {
        Kind::Theme(engine) => (templates::THEME_APP, engine.files),
        Kind::Admin => (templates::ADMIN_APP, templates::ADMIN_RAW),
    };
    for file in rendered {
        let path = app_dir.join(file.path);
        generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
        println!("  created apps/{name}/{}", file.path);
    }
    for file in raw {
        generator::write_file(&app_dir.join(file.path), file.contents, false)?;
        println!("  created apps/{name}/{}", file.path);
    }

    fs::write(&config_path, updated)
        .with_context(|| format!("failed to update {}", config_path.display()))?;
    let mode = if spec.separate {
        "a process of its own, proxied by the runtime"
    } else {
        "in the runtime"
    };
    if routed {
        println!(
            "  registered {route} → apps/{name} ({mode}; port {port}) in {}",
            apps::CONFIG
        );
    } else {
        println!(
            "  registered apps/{name} on port {port} ({mode}; direct, add --domain or --path to route it) in {}",
            apps::CONFIG
        );
    }
    if !spec.separate {
        runtime::add_app(&root, name, &crate_name)?;
    }

    Ok(if routed {
        apps::gateway_url(&config.gateway.listen, host, path)
    } else {
        format!("http://127.0.0.1:{port}")
    })
}

/// `storefront` → `STOREFRONT`, `admin-panel` → `ADMIN_PANEL`: the prefix of
/// the app's own settings in `.env`.
pub fn env_prefix(name: &str) -> String {
    name.to_ascii_uppercase().replace('-', "_")
}

/// The owner team for a new app: `wanted`, or the only one, or the one the
/// user picks, among the teams they own or administer (any for super admins).
fn choose_team(root: &Path, wanted: Option<&str>) -> Result<String> {
    // Debug builds only: the CLI's own tests run without a backend.
    if cfg!(debug_assertions)
        && let Ok(team) = std::env::var("SLAVE_TEST_TEAM")
    {
        return Ok(team);
    }
    let url = session::project_url(root)?;
    let token = session::token(&url)?;
    let teams = session::call(&url, Some(&token), "team_list", serde_json::json!({}))?;
    let teams: Vec<(String, String)> = teams["teams"]
        .as_array()
        .into_iter()
        .flatten()
        .filter(|team| team["manages_members"] == true)
        .filter_map(|team| {
            Some((
                team["slug"].as_str()?.to_owned(),
                team["name"].as_str()?.to_owned(),
            ))
        })
        .collect();
    if let Some(wanted) = wanted {
        return teams
            .iter()
            .any(|(slug, _)| slug == wanted)
            .then(|| wanted.to_owned())
            .ok_or_else(|| anyhow!("you are not an owner or admin of team `{wanted}`"));
    }
    match teams.as_slice() {
        [] => bail!("you own or administer no team yet; create one in the admin app first"),
        [(slug, _)] => Ok(slug.clone()),
        teams => {
            for (i, (slug, name)) in teams.iter().enumerate() {
                println!("  {}. {name} ({slug})", i + 1);
            }
            let choice = session::prompt("Team that owns the app (number): ")?;
            let index = choice
                .parse::<usize>()
                .ok()
                .filter(|n| (1..=teams.len()).contains(n))
                .ok_or_else(|| anyhow!("`{choice}` is not one of the numbers above"))?;
            Ok(teams[index - 1].0.clone())
        }
    }
}

fn next_port(config: &Config) -> u16 {
    config
        .domains
        .iter()
        .filter_map(|domain| domain.port)
        .filter(|&port| port >= FIRST_PORT)
        .max()
        .map_or(FIRST_PORT, |port| port.saturating_add(1))
}

/// `admin-panel` → `Admin Panel`.
fn title_case(name: &str) -> String {
    name.split(['-', '_'])
        .filter(|word| !word.is_empty())
        .map(|word| {
            let mut chars = word.chars();
            chars
                .next()
                .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
                .unwrap_or_default()
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn titles() {
        assert_eq!(title_case("dashboard"), "Dashboard");
        assert_eq!(title_case("admin-panel"), "Admin Panel");
        assert_eq!(title_case("shop_front2"), "Shop Front2");
    }

    #[test]
    fn ports_follow_the_highest_app_port() {
        let config = |text: &str| Config::parse(text).unwrap();
        assert_eq!(next_port(&config("")), 4000);
        let two = "[[domain]]\nhost = \"a.localhost\"\napp = \"a\"\nport = 4000\n\
                   [[domain]]\nhost = \"b.localhost\"\napp = \"b\"\nport = 4007\n";
        assert_eq!(next_port(&config(two)), 4008);
    }
}