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 new <name>`: scaffold a new domain-based application.

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

use anyhow::{Context, Result, bail};

use crate::cli::NewArgs;
use crate::commands::make_app;
use crate::{generator, naming, templates, timestamp};

pub fn run(args: &NewArgs) -> Result<()> {
    let name = args.name.as_str();
    naming::validate_project_name(name)?;
    let framework_dependency = framework_dependency(args.framework_path.as_deref())?;
    let gateway_dependency = gateway_dependency(&framework_dependency)?;

    let target = std::env::current_dir()
        .context("failed to read the current directory")?
        .join(name);
    if target.exists() {
        bail!("destination `{}` already exists", target.display());
    }

    fs::create_dir(&target)
        .with_context(|| format!("failed to create directory {}", target.display()))?;

    let created =
        scaffold(&target, name, &framework_dependency, &gateway_dependency).and_then(|()| {
            // Every project starts with an admin: sign-in, teams and members.
            let admin = make_app::Spec {
                name: "admin",
                host: Some("admin.localhost"),
                path: None,
                port: None,
                separate: false,
                customer: false,
                team: None,
                kind: make_app::Kind::Admin,
            };
            make_app::create(&target, &admin).map(|_| ())
        });
    if let Err(err) = created {
        // Only remove what we just created; the directory did not exist before.
        let _ = fs::remove_dir_all(&target);
        return Err(err);
    }

    println!("\nApplication `{name}` created successfully.\n");
    println!("AI coding agents: see {name}/AGENTS.md (CLAUDE.md imports it).\n");
    println!("Next steps:");
    println!("  cd {name}");
    println!(
        "  surreal start --user root --pass root   # or set SURREAL_* in .env to a hosted SurrealDB"
    );
    println!("  slave migrate      # create the tables");
    println!("  slave build        # the first build takes a few minutes, later ones seconds");
    println!("  slave serve        # sign-in codes are printed in the log until SMTP_URL is set");
    println!("\nThen open http://admin.localhost:8000; the first user becomes super admin.");
    println!("`slave doctor` checks the setup; set SMTP_URL and MAIL_FROM in .env to email codes.");
    println!("\nWeb apps: `slave make:app <name> [--domain <host>] [--path </prefix>]`, served by");
    println!("the runtime (runtime/); add --separate to run one as a process of its own.");
    Ok(())
}

fn scaffold(target: &Path, name: &str, framework: &str, gateway: &str) -> Result<()> {
    let migration_ts = timestamp::migration_prefix_now()?;
    let crate_name = name.replace('-', "_");
    let vars = [
        ("name", name),
        ("crate_name", crate_name.as_str()),
        ("slave_version", env!("CARGO_PKG_VERSION")),
        ("framework_dependency", framework),
        ("gateway_dependency", gateway),
        ("migration_ts", migration_ts.as_str()),
    ];
    for file in templates::PROJECT {
        let path = templates::render(file.path, &vars);
        let contents = templates::render(file.contents, &vars);
        generator::write_file(&target.join(&path), &contents, false)?;
        println!("  created {name}/{path}");
    }

    // Like Laravel, start with a working .env copied from the example.
    let env_example = templates::PROJECT
        .iter()
        .find(|file| file.path == ".env.example")
        .map(|file| templates::render(file.contents, &vars))
        .unwrap_or_default();
    generator::write_file(&target.join(".env"), &env_example, false)?;
    println!("  created {name}/.env");
    Ok(())
}

/// Returns the `slave-framework` line for the generated workspace dependencies.
///
/// Default features are off at the workspace level so web apps stay light;
/// the backend package turns `auth` and `database` back on.
fn framework_dependency(local_path: Option<&Path>) -> Result<String> {
    match local_path {
        None => Ok(format!(
            "slave-framework = {{ version = \"{}\", default-features = false }}",
            templates::FRAMEWORK_VERSION
        )),
        Some(path) => {
            let path = local_crate(path, "slave-framework")?;
            Ok(format!(
                "slave-framework = {{ path = \"{path}\", default-features = false }}"
            ))
        }
    }
}

/// Canonicalizes `path`, checks it holds crate `name`, and escapes it for TOML.
pub(crate) fn local_crate(path: &Path, name: &str) -> Result<String> {
    let path = path
        .canonicalize()
        .with_context(|| format!("path {} does not exist", path.display()))?;
    let manifest = fs::read_to_string(path.join("Cargo.toml")).unwrap_or_default();
    if !manifest.contains(&format!("name = \"{name}\"")) {
        bail!("{} does not contain the {name} crate", path.display());
    }
    Ok(path
        .to_string_lossy()
        .replace('\\', "\\\\")
        .replace('"', "\\\""))
}

/// The `[workspace.dependencies]` line for `slave-gateway` (routing), without
/// default features (the runtime turns `embed` on): a version, or a path next
/// to a local `slave-framework` checkout.
fn gateway_dependency(framework_line: &str) -> Result<String> {
    let local = framework_line
        .split_once("path = \"")
        .and_then(|(_, rest)| rest.split_once('"'))
        .map(|(path, _)| Path::new(path).with_file_name("gateway"))
        .filter(|path| path.join("Cargo.toml").is_file());
    match local {
        Some(path) => {
            let path = local_crate(&path, "slave-gateway")?;
            Ok(format!(
                "slave-gateway = {{ path = \"{path}\", default-features = false }}"
            ))
        }
        None => Ok(format!(
            "slave-gateway = {{ version = \"{}\", default-features = false }}",
            templates::GATEWAY_VERSION
        )),
    }
}