use std::env;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;
use chrono::Local;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect, Select};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::info;
use crate::cli::ui;
use crate::commands::pm2::pm2_start;
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::config::global_xbp_paths;
use crate::logging::{get_prefix, log_debug, log_info, log_success};
use crate::strategies::{
get_all_services, get_service_by_name, resolve_config_paths_for_runtime, ServiceConfig,
XbpConfig,
};
use crate::utils::{
expand_home_in_string, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
parse_config_with_auto_heal, resolve_env_placeholders,
};
pub async fn list_services(_debug: bool) -> Result<(), String> {
let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
let services: Vec<ServiceConfig> = get_all_services(&config);
if services.is_empty() {
let _ = log_info("services", "No services configured.", None).await;
println!("No services configured.");
return Ok(());
}
println!();
println!(
"{}",
format!("Project: {}", config.project_name)
.bright_cyan()
.bold()
);
println!("{}", "Configured services:".bright_blue().bold());
let rows: Vec<Vec<String>> = services
.iter()
.map(|service| {
let url = service.url.as_deref().unwrap_or("-").to_string();
let avail = available_commands_for_service(service).join(" ");
let commands = if avail.trim().is_empty() {
"-".to_string()
} else {
avail
};
vec![
service.name.clone().bright_white().bold().to_string(),
service.target.clone().bright_yellow().to_string(),
service.port.to_string(),
if commands == "-" {
commands.dimmed().to_string()
} else {
commands.bright_green().to_string()
},
url,
]
})
.collect();
print!(
"{}",
render_table(
&["Name", "Target", "Port", "Available Commands", "URL"],
&rows,
TableStyle::Pipe,
"",
)
);
println!("Total: {} service(s)", services.len());
println!();
ui::tip("Run `xbp service` (no args) for interactive picker, or `xbp service <cmd> <name>`.");
let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
Ok(())
}
pub async fn get_service_config(name: &str) -> Result<ServiceConfig, String> {
let config = load_xbp_config().await?;
get_service_by_name(&config, name)
}
pub async fn run_service_command(
command: &str,
service_name: &str,
debug: bool,
) -> Result<(), String> {
let start_time = Instant::now();
let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
let service: ServiceConfig = get_service_by_name(&config, service_name)?;
let working_dir = if service.force_run_from_root.unwrap_or(false) {
project_root.clone()
} else if let Some(root_dir) = &service.root_directory {
let expanded = expand_home_in_string(root_dir);
let candidate = PathBuf::from(expanded);
if candidate.is_absolute() {
candidate
} else {
project_root.clone().join(root_dir)
}
} else {
project_root.clone()
};
let project_name = config.project_name.clone();
let working_dir_str = working_dir.display().to_string();
let _ = log_info(
"service",
&format!("Running '{}' for service '{}'", command, service_name),
Some(&format!("Working directory: {}", working_dir.display())),
)
.await;
let exec_result: Result<(), String> = (async {
let cmd_str: Option<&String> = match command {
"pre" => service.commands.as_ref().and_then(|c| c.pre.as_ref()),
"install" => service.commands.as_ref().and_then(|c| c.install.as_ref()),
"build" => service.commands.as_ref().and_then(|c| c.build.as_ref()),
"start" => service.commands.as_ref().and_then(|c| c.start.as_ref()),
"dev" => service.commands.as_ref().and_then(|c| c.dev.as_ref()),
_ => {
return Err(format!(
"Unknown command: {}. Valid commands: pre, install, build, start, dev",
command
))
}
};
let cmd_str = cmd_str.ok_or_else(|| {
format!(
"Command '{}' not configured for service '{}'",
command, service_name
)
})?;
if command == "build" && cmd_str.is_empty() {
let _ = log_info("service", "Build command is empty, skipping", None).await;
return Ok(());
}
if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
std::fs::create_dir_all(&log_dir)
.map_err(|e| format!("Failed to create log directory: {}", e))?;
let pm2_command: String = format!("{} --port {}", cmd_str, service.port);
let _ = log_info(
"service",
&format!("Starting service '{}' with PM2", service_name),
Some(&pm2_command),
)
.await;
let envs = merge_envs(
&project_root,
config.environment.as_ref(),
service.environment.as_ref(),
);
pm2_start(
&service.name,
&pm2_command,
Some(&log_dir),
envs.as_ref(),
debug,
)
.await?;
let _ = log_success(
"service",
&format!("Service '{}' started successfully", service_name),
None,
)
.await;
return Ok(());
}
let envs = merge_envs(
&project_root,
config.environment.as_ref(),
service.environment.as_ref(),
);
if command != "pre" {
if let Some(pre_cmd) = service.commands.as_ref().and_then(|c| c.pre.as_ref()) {
if !pre_cmd.is_empty() {
let _ = log_info("service", "Running pre-command", Some(pre_cmd)).await;
run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
}
}
}
run_command_in_dir(&working_dir, cmd_str, envs.as_ref(), debug).await?;
let _ = log_success(
"service",
&format!(
"Command '{}' completed for service '{}'",
command, service_name
),
None,
)
.await;
Ok(())
})
.await;
let duration_ms = start_time.elapsed().as_millis() as u64;
let success = exec_result.is_ok();
let exit_code = if success { Some(0) } else { None };
let _ = record_service_run(ServiceRunRecord {
timestamp: Local::now().to_rfc3339(),
project_root: project_root.to_string_lossy().to_string(),
project_name: Some(project_name),
service: service_name.to_string(),
command: command.to_string(),
working_dir: working_dir_str,
success,
duration_ms,
exit_code,
});
exec_result
}
async fn run_command_in_dir(
dir: &PathBuf,
command: &str,
envs: Option<&std::collections::HashMap<String, String>>,
_debug: bool,
) -> Result<(), String> {
let _ = log_debug(
"service",
&format!("Executing: {} in {}", command, dir.display()),
None,
)
.await;
#[cfg(unix)]
let mut cmd = Command::new("sh");
#[cfg(unix)]
cmd.arg("-c");
#[cfg(windows)]
let mut cmd = Command::new("cmd");
#[cfg(windows)]
cmd.arg("/C");
cmd.arg(command);
cmd.current_dir(dir);
if let Some(envs) = envs {
cmd.envs(envs);
}
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let status = cmd
.status()
.await
.map_err(|e| format!("Failed to execute command: {}", e))?;
if !status.success() {
return Err(format!(
"Command failed with exit code: {}",
status.code().unwrap_or(-1)
));
}
Ok(())
}
fn merge_envs(
project_root: &std::path::Path,
global: Option<&std::collections::HashMap<String, String>>,
service: Option<&std::collections::HashMap<String, String>>,
) -> Option<std::collections::HashMap<String, String>> {
if global.is_none() && service.is_none() {
return None;
}
let mut out = std::collections::HashMap::new();
if let Some(g) = global {
out.extend(g.clone());
}
if let Some(s) = service {
out.extend(s.clone());
}
Some(resolve_env_placeholders(project_root, &out))
}
pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
let current_dir: PathBuf =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
format!(
"{}\n\n{}\n{}",
"Currently not in an XBP project",
"No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
"Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
)
})?;
let content: String = std::fs::read_to_string(&found.config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let (mut config, healed_content): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
if found.kind == "yaml" {
format!("Failed to parse YAML config: {}", e)
} else {
format!("Failed to parse JSON config: {}", e)
}
})?;
if let Some(healed_content) = healed_content {
let _ = std::fs::write(&found.config_path, healed_content);
}
resolve_config_paths_for_runtime(&mut config, &found.project_root);
if found.kind == "json" {
let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
}
crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
.await;
Ok((found.project_root, config))
}
pub async fn load_xbp_config() -> Result<XbpConfig, String> {
Ok(load_xbp_config_with_root().await?.1)
}
pub async fn is_xbp_project() -> bool {
let current_dir = match env::current_dir() {
Ok(dir) => dir,
Err(_) => return false,
};
find_xbp_config_upwards(¤t_dir).is_some()
}
pub async fn show_service_help(service_name: &str) -> Result<(), String> {
let service = get_service_config(service_name).await?;
let prefix = get_prefix();
info!("{}", prefix);
info!("{} Service: {}", prefix, service.name);
info!("{} {:-<60}", prefix, "");
info!("{} Target: {}", prefix, service.target);
info!("{} Port: {}", prefix, service.port);
info!("{} Branch: {}", prefix, service.branch);
if let Some(url) = &service.url {
info!(" {}URL: {}", prefix, url);
}
if let Some(root_dir) = &service.root_directory {
info!(" {}Root Directory: {}", prefix, root_dir);
}
info!(
"{} Force Run From Root: {}",
prefix,
service.force_run_from_root.unwrap_or(false)
);
if let Some(commands) = &service.commands {
info!("{}", prefix);
info!("{} Available Commands:", prefix);
info!("{} {:-<60}", prefix, "");
if commands.pre.is_some() {
info!("{} Service pre {}", prefix, service_name);
}
if commands.install.is_some() {
info!("{} Service install {}", prefix, service_name);
}
if commands.build.is_some() {
info!("{} Service build {}", prefix, service_name);
}
if commands.start.is_some() {
info!("{} Service start {}", prefix, service_name);
}
if commands.dev.is_some() {
info!("{} Service dev {}", prefix, service_name);
}
}
info!("{}", prefix);
info!("{} Redeploy:", prefix);
info!("{} Redeploy {}", prefix, service_name);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceRunRecord {
pub timestamp: String,
pub project_root: String,
pub project_name: Option<String>,
pub service: String,
pub command: String,
pub working_dir: String,
pub success: bool,
pub duration_ms: u64,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServicesRegistry {
pub last_updated: String,
pub services: Vec<RegisteredService>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisteredService {
pub name: String,
pub project_path: String,
pub project_name: Option<String>,
pub target: String,
pub port: u16,
pub available_commands: Vec<String>,
pub last_seen: String,
}
fn global_service_dir() -> Result<PathBuf, String> {
let paths = global_xbp_paths()?;
let dir = paths.logs_dir.join("service");
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create service logs dir: {}", e))?;
Ok(dir)
}
pub fn service_runs_path() -> Result<PathBuf, String> {
Ok(global_service_dir()?.join("runs.jsonl"))
}
pub fn service_registry_path() -> Result<PathBuf, String> {
Ok(global_service_dir()?.join("registry.json"))
}
pub fn record_service_run(record: ServiceRunRecord) -> Result<(), String> {
let path = service_runs_path()?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open runs log {}: {}", path.display(), e))?;
let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
writeln!(file, "{}", line).map_err(|e| e.to_string())?;
Ok(())
}
pub fn load_services_registry() -> Result<ServicesRegistry, String> {
let path = service_registry_path()?;
if !path.exists() {
return Ok(ServicesRegistry::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read registry: {}", e))?;
let reg: ServicesRegistry =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse registry: {}", e))?;
Ok(reg)
}
pub async fn update_services_registry(
services: &[ServiceConfig],
project_root: &Path,
project_name: Option<&str>,
) -> Result<(), String> {
let mut reg = load_services_registry().unwrap_or_default();
let now = Local::now().to_rfc3339();
let root_str = project_root.to_string_lossy().to_string();
for svc in services {
let avail = available_commands_for_service(svc);
let reg_svc = RegisteredService {
name: svc.name.clone(),
project_path: root_str.clone(),
project_name: project_name.map(|s| s.to_string()),
target: svc.target.clone(),
port: svc.port,
available_commands: avail,
last_seen: now.clone(),
};
if let Some(existing) = reg
.services
.iter_mut()
.find(|r| r.project_path == root_str && r.name == svc.name)
{
*existing = reg_svc;
} else {
reg.services.push(reg_svc);
}
}
reg.last_updated = now;
let path = service_registry_path()?;
let json = serde_json::to_string_pretty(®).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write registry: {}", e))?;
Ok(())
}
pub fn available_commands_for_service(s: &ServiceConfig) -> Vec<String> {
let mut v = Vec::new();
if let Some(c) = &s.commands {
if c.pre.as_ref().is_some_and(|x| !x.trim().is_empty()) {
v.push("pre".to_string());
}
if c.install.as_ref().is_some_and(|x| !x.trim().is_empty()) {
v.push("install".to_string());
}
if c.build.as_ref().is_some_and(|x| !x.trim().is_empty()) {
v.push("build".to_string());
}
if c.start.as_ref().is_some_and(|x| !x.trim().is_empty()) {
v.push("start".to_string());
}
if c.dev.as_ref().is_some_and(|x| !x.trim().is_empty()) {
v.push("dev".to_string());
}
}
v
}
pub async fn print_service_preview() -> Result<(), String> {
let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
let services = get_all_services(&config);
println!();
println!(
"{} {}",
"◆".bright_blue().bold(),
format!("xbp service — {}", config.project_name)
.bright_cyan()
.bold()
);
ui::divider(72);
if services.is_empty() {
println!("No services configured in this project.");
return Ok(());
}
for svc in &services {
let avail = available_commands_for_service(svc);
let cmds_disp = if avail.is_empty() {
"no commands".dimmed().to_string()
} else {
avail.join(", ").bright_green().to_string()
};
let root_note = svc
.root_directory
.as_deref()
.map(|r| format!(" root={}", r))
.unwrap_or_default();
println!(
" {} {} {} port {} {} {}",
"•".bright_magenta(),
svc.name.bright_white().bold(),
format!("[{}]", svc.target).bright_yellow(),
svc.port.to_string().bright_cyan(),
cmds_disp,
root_note.dimmed()
);
}
ui::divider(72);
println!(
"{} {}",
"Hint:".bright_yellow().bold(),
"Pick interactively below, or use `xbp service <command> <name>`".dimmed()
);
let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
Ok(())
}
fn is_interactive_terminal() -> bool {
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("XBP_NON_INTERACTIVE").is_none()
}
pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
if let Err(_e) = print_service_preview().await {
println!();
println!("{}", "Not inside an XBP project directory.".bright_yellow());
println!(
"{} {}",
"Tip:".bright_cyan(),
"cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
);
if let Ok(reg) = load_services_registry() {
if !reg.services.is_empty() {
println!();
println!("{}", "Recently known services (from registry):".dimmed());
for rs in reg.services.iter().take(8) {
let proj = rs.project_name.as_deref().unwrap_or("?");
println!(
" {} {} ({}) cmds: {}",
"•".dimmed(),
rs.name.bright_white(),
proj,
rs.available_commands.join(", ").dimmed()
);
}
println!();
println!(
"{}",
format!(
"Registry: {}",
service_registry_path().unwrap_or_default().display()
)
.dimmed()
);
}
}
return Ok(());
}
if !is_interactive_terminal() {
ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
return Ok(());
}
let (_project_root, config) = load_xbp_config_with_root().await?;
let services = get_all_services(&config);
if services.is_empty() {
return Ok(());
}
let service_labels: Vec<String> = services
.iter()
.map(|s| {
let cmds = available_commands_for_service(s);
let cmd_hint = if cmds.is_empty() {
"".to_string()
} else {
format!(" — {}", cmds.join(", "))
};
format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
})
.collect();
let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select service to operate on")
.items(&service_labels)
.default(0)
.interact_opt()
{
Ok(Some(i)) => i,
_ => {
println!("{}", "Cancelled.".dimmed());
return Ok(());
}
};
let chosen = &services[service_idx];
let avail_cmds = available_commands_for_service(chosen);
if avail_cmds.is_empty() {
println!(
"Service '{}' has no runnable commands configured.",
chosen.name
);
return Ok(());
}
let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Select command for '{}'", chosen.name))
.items(&avail_cmds)
.default(0)
.interact_opt()
{
Ok(Some(i)) => i,
_ => {
println!("{}", "Cancelled.".dimmed());
return Ok(());
}
};
let chosen_cmd = &avail_cmds[cmd_idx];
println!();
println!(
"{} Running {} {} {}",
"▶".bright_green().bold(),
"xbp service".bright_magenta(),
chosen_cmd.bright_cyan().bold(),
chosen.name.bright_white().bold()
);
ui::divider(40);
run_service_command(chosen_cmd, &chosen.name, debug).await
}
pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
let (_project_root, config) = load_xbp_config_with_root().await?;
let all_services = get_all_services(&config);
let candidates: Vec<&ServiceConfig> = all_services
.iter()
.filter(|s| {
available_commands_for_service(s)
.iter()
.any(|c| c == command)
})
.collect();
if candidates.is_empty() {
return Err(format!(
"No services have a '{}' command configured. Check `xbp services`.",
command
));
}
if candidates.len() == 1 {
let only = candidates[0];
println!(
"Only one service supports '{}': {} — running it.",
command, only.name
);
return run_service_command(command, &only.name, debug).await;
}
if !is_interactive_terminal() {
let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
return Err(format!(
"Multiple services support '{}'. Specify one of: {}",
command,
names.join(", ")
));
}
let labels: Vec<String> = candidates
.iter()
.map(|s| format!("{} (port {})", s.name, s.port))
.collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Select service for '{}'", command))
.items(&labels)
.default(0)
.interact()
.map_err(|e| format!("Selection failed: {}", e))?;
let chosen = candidates[idx];
run_service_command(command, &chosen.name, debug).await
}
#[cfg(test)]
mod tests {
use crate::commands::terminal_table::{render_table, TableStyle};
#[test]
fn service_table_renderer_pads_colored_cells() {
let rendered = render_table(
&["Name", "Port"],
&[vec![
"\u{1b}[1;37mathena\u{1b}[0m".to_string(),
"3000".to_string(),
]],
TableStyle::Box,
"",
);
assert!(rendered.contains("athena"));
assert!(rendered.contains("3000"));
assert!(rendered.contains("┌"));
assert!(rendered.contains("│"));
}
}