use crate::cli::WatchArgs;
use crate::dev::run_dev_watch;
use crate::service::{check_service, parse_simple_yaml};
use crate::util::{Result, make_vars, run_cmd, run_make, usage_error};
use std::collections::hash_map::DefaultHasher;
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
pub(crate) fn setup_current_service(args: &[String]) -> Result<()> {
let root = current_service_root()?;
apply_key_value_env(args);
configure_service_dev_environment(&root);
println!("[setup] service: {}", root.display());
check_service(&root)?;
run_generators(&root)?;
install_language_dependencies(&root)?;
start_service_dependencies(&root)?;
println!("[setup] ready. Run `exe dev` from {}", root.display());
Ok(())
}
pub(crate) fn dev_current_service() -> Result<()> {
let root = current_service_root()?;
configure_service_dev_environment(&root);
println!("[dev] service: {}", root.display());
run_generators(&root)?;
start_service_dependencies(&root)?;
let command = service_dev_command(&root)?;
run_dev_watch(WatchArgs {
env_file: root.join("configs/app.env.example").display().to_string(),
watch_root: root.display().to_string(),
ignore: vec![],
poll: 1.0,
command,
})
}
pub(crate) fn configure_current_or_compose_service_dev_environment() {
if let Ok(root) = current_service_root() {
configure_service_dev_environment(&root);
return;
}
let Ok(compose_file) = env::var("DEV_COMPOSE_FILE") else {
return;
};
let compose_path = PathBuf::from(compose_file);
let compose_path = if compose_path.is_absolute() {
compose_path
} else {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(compose_path)
};
if let Some(root) = compose_path.parent().and_then(Path::parent) {
configure_service_dev_environment(root);
}
}
fn current_service_root() -> Result<PathBuf> {
let cwd = env::current_dir()?;
find_service_root_from(&cwd).ok_or_else(|| {
"service.yaml not found. Run this command from a service checkout or service subdirectory."
.into()
})
}
pub(crate) fn find_service_root_from(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
loop {
if dir.join("service.yaml").is_file() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
fn apply_key_value_env(args: &[String]) {
for item in make_vars(args) {
if let Some((key, value)) = item.split_once('=') {
unsafe { env::set_var(key, value) };
}
}
}
fn configure_service_dev_environment(root: &Path) {
let metadata = parse_simple_yaml(&root.join("service.yaml"));
let service_key = format!(
"{}-{}",
metadata
.get("domain")
.map(String::as_str)
.unwrap_or("service"),
metadata
.get("name")
.cloned()
.unwrap_or_else(|| fallback_service_name(root))
);
let slot = stable_port_slot(&service_key);
let postgres_port = set_default_env("POSTGRES_PORT", 15432 + slot);
let redis_port = set_default_env("REDIS_PORT", 16379 + slot);
let nats_port = set_default_env("NATS_PORT", 14222 + slot);
let mongodb_port = set_default_env("MONGODB_PORT", 17017 + slot);
let _ = set_default_env("NATS_MONITOR_PORT", 18222 + slot);
let env_file = root.join("configs/app.env.example");
if let Ok(text) = fs::read_to_string(&env_file) {
set_url_from_env_file("DATABASE_URL", &text, postgres_port);
set_url_from_env_file("TENANT_CONTEXT_DATABASE_URL", &text, postgres_port);
set_url_from_env_file("MONGODB_URI", &text, mongodb_port);
set_url_from_env_file("CACHE_URL", &text, redis_port);
set_url_from_env_file("TENANT_CONTEXT_REDIS_URL", &text, redis_port);
set_url_from_env_file("NATS_URL", &text, nats_port);
}
}
fn fallback_service_name(root: &Path) -> String {
root.file_name()
.and_then(|name| name.to_str())
.unwrap_or("local")
.to_string()
}
pub(crate) fn stable_port_slot(service_key: &str) -> u16 {
let mut hasher = DefaultHasher::new();
service_key.hash(&mut hasher);
(hasher.finish() % 1000) as u16
}
fn set_default_env(name: &str, port: u16) -> u16 {
if let Ok(value) = env::var(name)
&& let Ok(parsed) = value.parse::<u16>()
{
return parsed;
}
unsafe { env::set_var(name, port.to_string()) };
port
}
fn set_url_from_env_file(name: &str, text: &str, port: u16) {
if env::var(name).is_ok() {
return;
}
let Some(value) = env_file_value(name, text) else {
return;
};
let Some(updated) = replace_localhost_port(&value, port) else {
return;
};
unsafe { env::set_var(name, updated) };
}
fn env_file_value(name: &str, text: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
if key.trim() == name {
return Some(value.trim().trim_matches(['"', '\'']).to_string());
}
}
None
}
pub(crate) fn replace_localhost_port(value: &str, port: u16) -> Option<String> {
for host in ["localhost:", "127.0.0.1:"] {
if let Some(index) = value.find(host) {
let port_start = index + host.len();
let port_end = value[port_start..]
.find(|c: char| !c.is_ascii_digit())
.map(|offset| port_start + offset)
.unwrap_or(value.len());
if port_start == port_end {
return None;
}
return Some(format!(
"{}{}{}",
&value[..port_start],
port,
&value[port_end..]
));
}
}
None
}
fn run_generators(root: &Path) -> Result<()> {
if truthy_env("SKIP_GENERATE") || truthy_env("EXE_SETUP_SKIP_GENERATE") {
println!("[setup] skipping generators");
return Ok(());
}
if root.join("Makefile").is_file() {
println!("[setup] running make generate");
return run_make(root, "generate", &[]);
}
let candidates = [
"tools/generate.sh",
"tools/generate-auth-artifacts.sh",
"tools/generate-gateway-artifacts.sh",
];
for candidate in candidates {
let path = root.join(candidate);
if path.is_file() {
println!("[setup] running {candidate}");
return run_cmd(root, "sh", &[candidate.to_string()]);
}
}
println!("[setup] no local generator found");
Ok(())
}
fn install_language_dependencies(root: &Path) -> Result<()> {
if truthy_env("SKIP_DEPS") || truthy_env("EXE_SETUP_SKIP_DEPS") {
println!("[setup] skipping language dependencies");
return Ok(());
}
if root.join("go.mod").is_file() {
println!("[setup] downloading Go modules");
run_cmd(root, "go", &["mod".into(), "download".into()])?;
}
if root.join("Cargo.toml").is_file() {
println!("[setup] fetching Rust crates");
run_cmd(root, "cargo", &["fetch".into()])?;
}
if root.join("package.json").is_file() {
if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
println!("[setup] installing Bun packages");
run_cmd(root, "bun", &["install".into()])?;
} else if root.join("package-lock.json").is_file() {
println!("[setup] installing npm packages");
run_cmd(root, "npm", &["ci".into()])?;
} else {
println!("[setup] package.json found; skipping install because no lockfile exists");
}
}
Ok(())
}
fn start_service_dependencies(root: &Path) -> Result<()> {
if truthy_env("SKIP_DOCKER") || truthy_env("EXE_SETUP_SKIP_DOCKER") {
println!("[setup] skipping Docker dependencies");
return Ok(());
}
let compose = root.join("development/compose.dev.yml");
if !compose.is_file() {
println!("[setup] no development/compose.dev.yml found");
return Ok(());
}
println!("[setup] starting service dependencies");
run_cmd(
root,
"docker",
&[
"compose".into(),
"-f".into(),
compose.display().to_string(),
"up".into(),
"-d".into(),
],
)
}
pub(crate) fn service_dev_command(root: &Path) -> Result<Vec<String>> {
let metadata = parse_simple_yaml(&root.join("service.yaml"));
let runtime = metadata.get("runtime").map(String::as_str).unwrap_or("");
let command = match runtime {
"go" if root.join("cmd/server/main.go").is_file() => vec!["go", "run", "./cmd/server"],
"go" => vec!["go", "run", "./cmd/server"],
"rust" => vec!["cargo", "run"],
"typescript" if has_script(root, "\"dev\"") => {
if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
vec!["bun", "run", "dev"]
} else {
vec!["npm", "run", "dev"]
}
}
"typescript" => vec!["node", "src/server.js"],
"python" => vec!["python", "-m", "src.server"],
_ => {
return usage_error(format!(
"unsupported service runtime `{runtime}` in {}",
root.join("service.yaml").display()
));
}
};
Ok(command.into_iter().map(String::from).collect())
}
fn has_script(root: &Path, script_name: &str) -> bool {
fs::read_to_string(root.join("package.json"))
.map(|text| text.contains(script_name))
.unwrap_or(false)
}
fn truthy_env(name: &str) -> bool {
env::var(name)
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}