1use std::env;
9use std::io::{IsTerminal, Write};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::time::Instant;
13
14use chrono::Local;
15use colored::Colorize;
16use dialoguer::{theme::ColorfulTheme, FuzzySelect, Select};
17use serde::{Deserialize, Serialize};
18use tokio::process::Command;
19use tracing::info;
20
21use crate::cli::ui;
22use crate::commands::pm2::pm2_start_in_dir;
23use crate::commands::terminal_table::{render_table, TableStyle};
24use crate::config::global_xbp_paths;
25use crate::logging::{get_prefix, log_debug, log_info, log_success};
26use crate::strategies::{
27 get_all_services, get_service_by_name, resolve_config_paths_for_runtime, ServiceConfig,
28 XbpConfig,
29};
30use crate::utils::{
31 expand_home_in_string, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
32 parse_config_with_auto_heal, resolve_env_placeholders,
33};
34
35pub async fn list_services(_debug: bool) -> Result<(), String> {
37 let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
38 let services: Vec<ServiceConfig> = get_all_services(&config);
39
40 if services.is_empty() {
41 let _ = log_info("services", "No services configured.", None).await;
42 println!("No services configured.");
43 return Ok(());
44 }
45
46 println!();
47 println!(
48 "{}",
49 format!("Project: {}", config.project_name)
50 .bright_cyan()
51 .bold()
52 );
53 println!("{}", "Configured services:".bright_blue().bold());
54 let rows: Vec<Vec<String>> = services
55 .iter()
56 .map(|service| {
57 let url = service.url.as_deref().unwrap_or("-").to_string();
58 let avail = available_commands_for_service(service).join(" ");
59 let commands = if avail.trim().is_empty() {
60 "-".to_string()
61 } else {
62 avail
63 };
64
65 vec![
66 service.name.clone().bright_white().bold().to_string(),
67 service.target.clone().bright_yellow().to_string(),
68 service.port.to_string(),
69 if commands == "-" {
70 commands.dimmed().to_string()
71 } else {
72 commands.bright_green().to_string()
73 },
74 url,
75 ]
76 })
77 .collect();
78
79 print!(
80 "{}",
81 render_table(
82 &["Name", "Target", "Port", "Available Commands", "URL"],
83 &rows,
84 TableStyle::Pipe,
85 "",
86 )
87 );
88
89 println!("Total: {} service(s)", services.len());
90 println!();
91 ui::tip("Run `xbp service` (no args) for interactive picker, or `xbp service <cmd> <name>`.");
92
93 let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
95
96 Ok(())
97}
98
99pub async fn get_service_config(name: &str) -> Result<ServiceConfig, String> {
101 let config = load_xbp_config().await?;
102 get_service_by_name(&config, name)
103}
104
105pub async fn run_service_command(
107 command: &str,
108 service_name: &str,
109 debug: bool,
110) -> Result<(), String> {
111 let start_time = Instant::now();
112 let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
113 let service: ServiceConfig = get_service_by_name(&config, service_name)?;
114
115 let working_dir = if service.force_run_from_root.unwrap_or(false) {
117 project_root.clone()
119 } else if let Some(root_dir) = &service.root_directory {
120 let expanded = expand_home_in_string(root_dir);
122 let candidate = PathBuf::from(expanded);
123 if candidate.is_absolute() {
124 candidate
125 } else {
126 project_root.clone().join(root_dir)
127 }
128 } else {
129 project_root.clone()
131 };
132
133 let project_name = config.project_name.clone();
134 let working_dir_str = working_dir.display().to_string();
135
136 let _ = log_info(
137 "service",
138 &format!("Running '{}' for service '{}'", command, service_name),
139 Some(&format!("Working directory: {}", working_dir.display())),
140 )
141 .await;
142
143 let exec_result: Result<(), String> = (async {
145 let cmd_str = service
147 .commands
148 .as_ref()
149 .and_then(|commands| commands.get(command));
150
151 let cmd_str = cmd_str.ok_or_else(|| {
152 format!(
153 "Command '{}' not configured for service '{}'",
154 command, service_name
155 )
156 })?;
157
158 if command == "build" && cmd_str.is_empty() {
160 let _ = log_info("service", "Build command is empty, skipping", None).await;
161 return Ok(());
162 }
163
164 if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
166 let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
168 std::fs::create_dir_all(&log_dir)
169 .map_err(|e| format!("Failed to create log directory: {}", e))?;
170
171 let pm2_command: String = format!("{} --port {}", cmd_str, service.port);
173
174 let _ = log_info(
175 "service",
176 &format!("Starting service '{}' with PM2", service_name),
177 Some(&pm2_command),
178 )
179 .await;
180
181 let envs = merge_envs(
182 &project_root,
183 config.environment.as_ref(),
184 service.environment.as_ref(),
185 );
186 pm2_start_in_dir(
187 &service.name,
188 &pm2_command,
189 &working_dir,
190 Some(&log_dir),
191 envs.as_ref(),
192 debug,
193 )
194 .await?;
195
196 let _ = log_success(
197 "service",
198 &format!("Service '{}' started successfully", service_name),
199 None,
200 )
201 .await;
202
203 return Ok(());
204 }
205
206 let envs = merge_envs(
207 &project_root,
208 config.environment.as_ref(),
209 service.environment.as_ref(),
210 );
211
212 if command != "pre" {
214 if let Some(pre_cmd) = service.commands.as_ref().and_then(|c| c.pre.as_ref()) {
215 if !pre_cmd.is_empty() {
216 let _ = log_info("service", "Running pre-command", Some(pre_cmd)).await;
217 run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
218 }
219 }
220 }
221
222 run_command_in_dir(&working_dir, cmd_str, envs.as_ref(), debug).await?;
224
225 let _ = log_success(
226 "service",
227 &format!(
228 "Command '{}' completed for service '{}'",
229 command, service_name
230 ),
231 None,
232 )
233 .await;
234
235 Ok(())
236 })
237 .await;
238
239 let duration_ms = start_time.elapsed().as_millis() as u64;
240 let success = exec_result.is_ok();
241 let exit_code = if success { Some(0) } else { None };
242
243 let _ = record_service_run(ServiceRunRecord {
245 timestamp: Local::now().to_rfc3339(),
246 project_root: project_root.to_string_lossy().to_string(),
247 project_name: Some(project_name),
248 service: service_name.to_string(),
249 command: command.to_string(),
250 working_dir: working_dir_str,
251 success,
252 duration_ms,
253 exit_code,
254 });
255
256 exec_result
257}
258
259async fn run_command_in_dir(
261 dir: &PathBuf,
262 command: &str,
263 envs: Option<&std::collections::HashMap<String, String>>,
264 _debug: bool,
265) -> Result<(), String> {
266 let _ = log_debug(
267 "service",
268 &format!("Executing: {} in {}", command, dir.display()),
269 None,
270 )
271 .await;
272
273 #[cfg(unix)]
275 let mut cmd = Command::new("sh");
276 #[cfg(unix)]
277 cmd.arg("-c");
278
279 #[cfg(windows)]
280 let mut cmd = Command::new("cmd");
281 #[cfg(windows)]
282 cmd.arg("/C");
283
284 cmd.arg(command);
285 cmd.current_dir(dir);
286 if let Some(envs) = envs {
287 cmd.envs(envs);
288 }
289 cmd.stdout(Stdio::inherit());
290 cmd.stderr(Stdio::inherit());
291
292 let status = cmd
293 .status()
294 .await
295 .map_err(|e| format!("Failed to execute command: {}", e))?;
296
297 if !status.success() {
298 return Err(format!(
299 "Command failed with exit code: {}",
300 status.code().unwrap_or(-1)
301 ));
302 }
303
304 Ok(())
305}
306
307fn merge_envs(
308 project_root: &std::path::Path,
309 global: Option<&std::collections::HashMap<String, String>>,
310 service: Option<&std::collections::HashMap<String, String>>,
311) -> Option<std::collections::HashMap<String, String>> {
312 if global.is_none() && service.is_none() {
313 return None;
314 }
315 let mut out = std::collections::HashMap::new();
316 if let Some(g) = global {
317 out.extend(g.clone());
318 }
319 if let Some(s) = service {
320 out.extend(s.clone());
321 }
322 Some(resolve_env_placeholders(project_root, &out))
323}
324
325pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
326 let current_dir: PathBuf =
327 env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
328
329 let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
330 format!(
331 "{}\n\n{}\n{}",
332 "Currently not in an XBP project",
333 "No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
334 "Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
335 )
336 })?;
337
338 let content: String = std::fs::read_to_string(&found.config_path)
339 .map_err(|e| format!("Failed to read config file: {}", e))?;
340
341 let (mut config, healed_content): (XbpConfig, Option<String>) =
342 parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
343 if found.kind == "yaml" {
344 format!("Failed to parse YAML config: {}", e)
345 } else {
346 format!("Failed to parse JSON config: {}", e)
347 }
348 })?;
349
350 if let Some(healed_content) = healed_content {
351 let _ = std::fs::write(&found.config_path, healed_content);
352 }
353
354 resolve_config_paths_for_runtime(&mut config, &found.project_root);
355
356 if found.kind == "json" {
357 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
358 }
359
360 crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
361 .await;
362
363 Ok((found.project_root, config))
364}
365
366pub async fn load_xbp_config() -> Result<XbpConfig, String> {
368 Ok(load_xbp_config_with_root().await?.1)
369}
370
371pub async fn is_xbp_project() -> bool {
373 let current_dir = match env::current_dir() {
374 Ok(dir) => dir,
375 Err(_) => return false,
376 };
377 find_xbp_config_upwards(¤t_dir).is_some()
378}
379
380pub async fn show_service_help(service_name: &str) -> Result<(), String> {
382 let service = get_service_config(service_name).await?;
383 let prefix = get_prefix();
384
385 info!("{}", prefix);
386 info!("{} Service: {}", prefix, service.name);
387 info!("{} {:-<60}", prefix, "");
388 info!("{} Target: {}", prefix, service.target);
389 info!("{} Port: {}", prefix, service.port);
390 info!("{} Branch: {}", prefix, service.branch);
391 if let Some(url) = &service.url {
392 info!(" {}URL: {}", prefix, url);
393 }
394 if let Some(root_dir) = &service.root_directory {
395 info!(" {}Root Directory: {}", prefix, root_dir);
396 }
397 info!(
398 "{} Force Run From Root: {}",
399 prefix,
400 service.force_run_from_root.unwrap_or(false)
401 );
402
403 if let Some(commands) = &service.commands {
404 info!("{}", prefix);
405 info!("{} Available Commands:", prefix);
406 info!("{} {:-<60}", prefix, "");
407 for command in commands.available_names() {
408 info!("{} Service {} {}", prefix, command, service_name);
409 }
410 }
411
412 info!("{}", prefix);
413 info!("{} Redeploy:", prefix);
414 info!("{} Redeploy {}", prefix, service_name);
415
416 Ok(())
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ServiceRunRecord {
425 pub timestamp: String,
426 pub project_root: String,
427 pub project_name: Option<String>,
428 pub service: String,
429 pub command: String,
430 pub working_dir: String,
431 pub success: bool,
432 pub duration_ms: u64,
433 pub exit_code: Option<i32>,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, Default)]
437pub struct ServicesRegistry {
438 pub last_updated: String,
439 pub services: Vec<RegisteredService>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct RegisteredService {
444 pub name: String,
445 pub project_path: String,
446 pub project_name: Option<String>,
447 pub target: String,
448 pub port: u16,
449 pub available_commands: Vec<String>,
450 pub last_seen: String,
451}
452
453fn global_service_dir() -> Result<PathBuf, String> {
454 let paths = global_xbp_paths()?;
455 let dir = paths.logs_dir.join("service");
456 std::fs::create_dir_all(&dir)
457 .map_err(|e| format!("Failed to create service logs dir: {}", e))?;
458 Ok(dir)
459}
460
461pub fn service_runs_path() -> Result<PathBuf, String> {
462 Ok(global_service_dir()?.join("runs.jsonl"))
463}
464
465pub fn service_registry_path() -> Result<PathBuf, String> {
466 Ok(global_service_dir()?.join("registry.json"))
467}
468
469pub fn record_service_run(record: ServiceRunRecord) -> Result<(), String> {
471 let path = service_runs_path()?;
472 let mut file = std::fs::OpenOptions::new()
473 .create(true)
474 .append(true)
475 .open(&path)
476 .map_err(|e| format!("Failed to open runs log {}: {}", path.display(), e))?;
477 let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
478 writeln!(file, "{}", line).map_err(|e| e.to_string())?;
479 Ok(())
480}
481
482pub fn load_services_registry() -> Result<ServicesRegistry, String> {
484 let path = service_registry_path()?;
485 if !path.exists() {
486 return Ok(ServicesRegistry::default());
487 }
488 let content =
489 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read registry: {}", e))?;
490 let reg: ServicesRegistry =
491 serde_json::from_str(&content).map_err(|e| format!("Failed to parse registry: {}", e))?;
492 Ok(reg)
493}
494
495pub async fn update_services_registry(
497 services: &[ServiceConfig],
498 project_root: &Path,
499 project_name: Option<&str>,
500) -> Result<(), String> {
501 let mut reg = load_services_registry().unwrap_or_default();
502 let now = Local::now().to_rfc3339();
503 let root_str = project_root.to_string_lossy().to_string();
504
505 for svc in services {
506 let avail = available_commands_for_service(svc);
507 let reg_svc = RegisteredService {
508 name: svc.name.clone(),
509 project_path: root_str.clone(),
510 project_name: project_name.map(|s| s.to_string()),
511 target: svc.target.clone(),
512 port: svc.port,
513 available_commands: avail,
514 last_seen: now.clone(),
515 };
516
517 if let Some(existing) = reg
519 .services
520 .iter_mut()
521 .find(|r| r.project_path == root_str && r.name == svc.name)
522 {
523 *existing = reg_svc;
524 } else {
525 reg.services.push(reg_svc);
526 }
527 }
528
529 reg.last_updated = now;
530
531 let path = service_registry_path()?;
532 let json = serde_json::to_string_pretty(®).map_err(|e| e.to_string())?;
533 std::fs::write(&path, json).map_err(|e| format!("Failed to write registry: {}", e))?;
534
535 Ok(())
536}
537
538pub fn available_commands_for_service(s: &ServiceConfig) -> Vec<String> {
540 s.commands
541 .as_ref()
542 .map(|commands| commands.available_names())
543 .unwrap_or_default()
544}
545
546pub async fn print_service_preview() -> Result<(), String> {
548 let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
549 let services = get_all_services(&config);
550
551 println!();
552 println!(
553 "{} {}",
554 "◆".bright_blue().bold(),
555 format!("xbp service — {}", config.project_name)
556 .bright_cyan()
557 .bold()
558 );
559 ui::divider(72);
560
561 if services.is_empty() {
562 println!("No services configured in this project.");
563 return Ok(());
564 }
565
566 for svc in &services {
567 let avail = available_commands_for_service(svc);
568 let cmds_disp = if avail.is_empty() {
569 "no commands".dimmed().to_string()
570 } else {
571 avail.join(", ").bright_green().to_string()
572 };
573
574 let root_note = svc
575 .root_directory
576 .as_deref()
577 .map(|r| format!(" root={}", r))
578 .unwrap_or_default();
579
580 println!(
581 " {} {} {} port {} {} {}",
582 "•".bright_magenta(),
583 svc.name.bright_white().bold(),
584 format!("[{}]", svc.target).bright_yellow(),
585 svc.port.to_string().bright_cyan(),
586 cmds_disp,
587 root_note.dimmed()
588 );
589 }
590
591 ui::divider(72);
592 println!(
593 "{} {}",
594 "Hint:".bright_yellow().bold(),
595 "Pick interactively below, or use `xbp service <command> <name>`".dimmed()
596 );
597
598 let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
600
601 Ok(())
602}
603
604fn is_interactive_terminal() -> bool {
605 std::io::stdin().is_terminal()
606 && std::io::stdout().is_terminal()
607 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
608}
609
610pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
612 if let Err(_e) = print_service_preview().await {
614 println!();
616 println!("{}", "Not inside an XBP project directory.".bright_yellow());
617 println!(
618 "{} {}",
619 "Tip:".bright_cyan(),
620 "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
621 );
622
623 if let Ok(reg) = load_services_registry() {
625 if !reg.services.is_empty() {
626 println!();
627 println!("{}", "Recently known services (from registry):".dimmed());
628 for rs in reg.services.iter().take(8) {
629 let proj = rs.project_name.as_deref().unwrap_or("?");
630 println!(
631 " {} {} ({}) cmds: {}",
632 "•".dimmed(),
633 rs.name.bright_white(),
634 proj,
635 rs.available_commands.join(", ").dimmed()
636 );
637 }
638 println!();
639 println!(
640 "{}",
641 format!(
642 "Registry: {}",
643 service_registry_path().unwrap_or_default().display()
644 )
645 .dimmed()
646 );
647 }
648 }
649 return Ok(());
650 }
651
652 if !is_interactive_terminal() {
653 ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
654 return Ok(());
655 }
656
657 let (_project_root, config) = load_xbp_config_with_root().await?;
658 let services = get_all_services(&config);
659 if services.is_empty() {
660 return Ok(());
661 }
662
663 let service_labels: Vec<String> = services
665 .iter()
666 .map(|s| {
667 let cmds = available_commands_for_service(s);
668 let cmd_hint = if cmds.is_empty() {
669 "".to_string()
670 } else {
671 format!(" — {}", cmds.join(", "))
672 };
673 format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
674 })
675 .collect();
676
677 let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
678 .with_prompt("Select service to operate on")
679 .items(&service_labels)
680 .default(0)
681 .interact_opt()
682 {
683 Ok(Some(i)) => i,
684 _ => {
685 println!("{}", "Cancelled.".dimmed());
686 return Ok(());
687 }
688 };
689
690 let chosen = &services[service_idx];
691 let avail_cmds = available_commands_for_service(chosen);
692 if avail_cmds.is_empty() {
693 println!(
694 "Service '{}' has no runnable commands configured.",
695 chosen.name
696 );
697 return Ok(());
698 }
699
700 let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
701 .with_prompt(format!("Select command for '{}'", chosen.name))
702 .items(&avail_cmds)
703 .default(0)
704 .interact_opt()
705 {
706 Ok(Some(i)) => i,
707 _ => {
708 println!("{}", "Cancelled.".dimmed());
709 return Ok(());
710 }
711 };
712
713 let chosen_cmd = &avail_cmds[cmd_idx];
714
715 println!();
716 println!(
717 "{} Running {} {} {}",
718 "▶".bright_green().bold(),
719 "xbp service".bright_magenta(),
720 chosen_cmd.bright_cyan().bold(),
721 chosen.name.bright_white().bold()
722 );
723 ui::divider(40);
724
725 run_service_command(chosen_cmd, &chosen.name, debug).await
726}
727
728pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
730 let (_project_root, config) = load_xbp_config_with_root().await?;
731 let all_services = get_all_services(&config);
732
733 let candidates: Vec<&ServiceConfig> = all_services
734 .iter()
735 .filter(|s| {
736 available_commands_for_service(s)
737 .iter()
738 .any(|c| c == command)
739 })
740 .collect();
741
742 if candidates.is_empty() {
743 return Err(format!(
744 "No services have a '{}' command configured. Check `xbp services`.",
745 command
746 ));
747 }
748
749 if candidates.len() == 1 {
750 let only = candidates[0];
751 println!(
752 "Only one service supports '{}': {} — running it.",
753 command, only.name
754 );
755 return run_service_command(command, &only.name, debug).await;
756 }
757
758 if !is_interactive_terminal() {
759 let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
760 return Err(format!(
761 "Multiple services support '{}'. Specify one of: {}",
762 command,
763 names.join(", ")
764 ));
765 }
766
767 let labels: Vec<String> = candidates
768 .iter()
769 .map(|s| format!("{} (port {})", s.name, s.port))
770 .collect();
771
772 let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
773 .with_prompt(format!("Select service for '{}'", command))
774 .items(&labels)
775 .default(0)
776 .interact()
777 .map_err(|e| format!("Selection failed: {}", e))?;
778
779 let chosen = candidates[idx];
780 run_service_command(command, &chosen.name, debug).await
781}
782
783#[cfg(test)]
784mod tests {
785 use crate::commands::terminal_table::{render_table, TableStyle};
786
787 #[test]
788 fn service_table_renderer_pads_colored_cells() {
789 let rendered = render_table(
790 &["Name", "Port"],
791 &[vec![
792 "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
793 "3000".to_string(),
794 ]],
795 TableStyle::Box,
796 "",
797 );
798
799 assert!(rendered.contains("athena"));
800 assert!(rendered.contains("3000"));
801 assert!(rendered.contains("┌"));
802 assert!(rendered.contains("│"));
803 }
804}