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::{apps, gateway, generator, project, templates};
const FIRST_PORT: u16 = 4000;
pub fn run(args: &MakeAppArgs) -> Result<()> {
let name = args.name.as_str();
slave_gateway::validate_app_name(name).map_err(|err| anyhow!(err))?;
let (host, path) = (args.domain.as_deref(), args.path.as_deref());
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 = project::find_root()?;
let package = project::package_name(&root)?;
if [package.as_str(), "gateway", "migrate"].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 args.stack {
Stack::Minijinja => &templates::MINIJINJA,
Stack::Tera => &templates::TERA,
Stack::Handlebars => &templates::HANDLEBARS,
Stack::Liquid => &templates::LIQUID,
Stack::Dioxus => {
if routed || args.port.is_some() {
bail!(
"a Dioxus app is a client for web, desktop and mobile that slave neither serves \
nor routes; drop --domain, --path and --port"
);
}
return create_dioxus(&root, name, &app_dir);
}
};
let port = match args.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 updated = apps::append_domain(&text, host, path, name, port);
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 vars = [
("app", name),
("title", &title),
("port", &port_text),
("env", &env),
("engine_dependency", engine.dependency),
("engine_name", engine.name),
];
for file in templates::THEME_APP {
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 engine.files {
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()))?;
add_sign_in_settings(&root, name, &env, true)?;
if routed {
println!(
" registered {route} → apps/{name} (port {port}) in {}",
apps::CONFIG
);
gateway::install(&root)?;
} else {
println!(
" registered apps/{name} on port {port} in {} (direct; add --domain or --path to route it through the gateway)",
apps::CONFIG
);
}
println!("App {name} [apps/{name}] created successfully.");
let url = if routed {
apps::gateway_url(&config.gateway.listen, host, path)
} else {
format!("http://127.0.0.1:{port}")
};
println!("Run `slave build`, then `slave serve`, and open {url}");
Ok(())
}
fn create_dioxus(root: &Path, name: &str, app_dir: &Path) -> Result<()> {
let title = title_case(name);
let env = env_prefix(name);
let vars = [
("app", name),
("title", title.as_str()),
("env", env.as_str()),
];
for file in templates::DIOXUS_APP {
let path = app_dir.join(file.path);
generator::write_file(&path, &templates::render(file.contents, &vars), false)?;
println!(" created apps/{name}/{}", file.path);
}
let manifest_path = root.join("Cargo.toml");
let manifest = fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let updated = exclude_from_workspace(&manifest, &format!("apps/{name}"))?;
fs::write(&manifest_path, updated)
.with_context(|| format!("failed to update {}", manifest_path.display()))?;
println!(" excluded apps/{name} from the backend's workspace (it builds on its own)");
add_sign_in_settings(root, name, &env, false)?;
println!("App {name} [apps/{name}] created successfully.");
if !crate::dioxus::dx_installed() {
println!(
"note: Dioxus apps are built with `dx`; {}",
crate::dioxus::INSTALL_HINT
);
}
println!("Start the backend (`slave serve`), then in apps/{name}:");
println!(" dx serve --platform web # or desktop, ios, android");
Ok(())
}
fn exclude_from_workspace(manifest: &str, member: &str) -> Result<String> {
let mut lines: Vec<String> = manifest.lines().map(str::to_owned).collect();
let mut section = String::new();
let (mut members, mut exclude) = (None, None);
for (index, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
section = trimmed.to_owned();
} else if section == "[workspace]" && trimmed.starts_with("members") {
members = Some(index);
} else if section == "[workspace]" && trimmed.starts_with("exclude") {
exclude = Some(index);
}
}
let entry = format!("\"{member}\"");
match (exclude, members) {
(Some(index), _) => {
let line = lines[index].clone();
let Some(close) = line.rfind(']') else {
bail!("cannot parse the workspace `exclude` line: {line}");
};
let (head, tail) = line.split_at(close);
let separator = if head.trim_end().ends_with('[') {
""
} else {
", "
};
lines[index] = format!("{}{separator}{entry}{tail}", head.trim_end());
}
(None, Some(index)) => lines.insert(index + 1, format!("exclude = [{entry}]")),
(None, None) => bail!(
"no [workspace] members in Cargo.toml; add `exclude = [{entry}]` to [workspace] yourself"
),
}
Ok(lines.join("\n") + "\n")
}
pub fn env_prefix(name: &str) -> String {
name.to_ascii_uppercase().replace('-', "_")
}
fn add_sign_in_settings(root: &Path, name: &str, env: &str, secret: bool) -> Result<()> {
let mut block = format!(
"\n# apps/{name}: its client ID at the OpenID Connect provider. Add it to\n\
# OIDC_AUDIENCES too, so the backend accepts its ID tokens.\n{env}_OIDC_CLIENT_ID=\n"
);
if secret {
block.push_str(&format!(
"# Only if the provider issued a secret for it (confidential client).\n\
{env}_OIDC_CLIENT_SECRET=\n"
));
}
for file in [".env.example", ".env"] {
let path = root.join(file);
if let Ok(text) = fs::read_to_string(&path)
&& !text.contains(&format!("{env}_OIDC_CLIENT_ID="))
{
let text = format!("{}\n{block}", text.trim_end());
fs::write(&path, text).with_context(|| format!("failed to update {file}"))?;
}
}
println!(
" added {env}_OIDC_CLIENT_ID to .env and .env.example (also list it in OIDC_AUDIENCES)"
);
Ok(())
}
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))
}
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 excludes_members_from_the_workspace() {
let manifest = "[package]\nname = \"x\"\n\n[workspace]\nmembers = [\"apps/*\"]\n";
let once = exclude_from_workspace(manifest, "apps/dashboard").unwrap();
assert!(
once.contains("members = [\"apps/*\"]\nexclude = [\"apps/dashboard\"]"),
"{once}"
);
let twice = exclude_from_workspace(&once, "apps/mobile").unwrap();
assert!(
twice.contains("exclude = [\"apps/dashboard\", \"apps/mobile\"]"),
"{twice}"
);
assert!(exclude_from_workspace("[package]\n", "apps/x").is_err());
}
#[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);
}
}