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;
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: Option<&String> = match command {
147 "pre" => service.commands.as_ref().and_then(|c| c.pre.as_ref()),
148 "install" => service.commands.as_ref().and_then(|c| c.install.as_ref()),
149 "build" => service.commands.as_ref().and_then(|c| c.build.as_ref()),
150 "start" => service.commands.as_ref().and_then(|c| c.start.as_ref()),
151 "dev" => service.commands.as_ref().and_then(|c| c.dev.as_ref()),
152 _ => {
153 return Err(format!(
154 "Unknown command: {}. Valid commands: pre, install, build, start, dev",
155 command
156 ))
157 }
158 };
159
160 let cmd_str = cmd_str.ok_or_else(|| {
161 format!(
162 "Command '{}' not configured for service '{}'",
163 command, service_name
164 )
165 })?;
166
167 if command == "build" && cmd_str.is_empty() {
169 let _ = log_info("service", "Build command is empty, skipping", None).await;
170 return Ok(());
171 }
172
173 if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
175 let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
177 std::fs::create_dir_all(&log_dir)
178 .map_err(|e| format!("Failed to create log directory: {}", e))?;
179
180 let pm2_command: String = format!("{} --port {}", cmd_str, service.port);
182
183 let _ = log_info(
184 "service",
185 &format!("Starting service '{}' with PM2", service_name),
186 Some(&pm2_command),
187 )
188 .await;
189
190 let envs = merge_envs(
191 &project_root,
192 config.environment.as_ref(),
193 service.environment.as_ref(),
194 );
195 pm2_start(
196 &service.name,
197 &pm2_command,
198 Some(&log_dir),
199 envs.as_ref(),
200 debug,
201 )
202 .await?;
203
204 let _ = log_success(
205 "service",
206 &format!("Service '{}' started successfully", service_name),
207 None,
208 )
209 .await;
210
211 return Ok(());
212 }
213
214 let envs = merge_envs(
215 &project_root,
216 config.environment.as_ref(),
217 service.environment.as_ref(),
218 );
219
220 if command != "pre" {
222 if let Some(pre_cmd) = service.commands.as_ref().and_then(|c| c.pre.as_ref()) {
223 if !pre_cmd.is_empty() {
224 let _ = log_info("service", "Running pre-command", Some(pre_cmd)).await;
225 run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
226 }
227 }
228 }
229
230 run_command_in_dir(&working_dir, cmd_str, envs.as_ref(), debug).await?;
232
233 let _ = log_success(
234 "service",
235 &format!(
236 "Command '{}' completed for service '{}'",
237 command, service_name
238 ),
239 None,
240 )
241 .await;
242
243 Ok(())
244 })
245 .await;
246
247 let duration_ms = start_time.elapsed().as_millis() as u64;
248 let success = exec_result.is_ok();
249 let exit_code = if success { Some(0) } else { None };
250
251 let _ = record_service_run(ServiceRunRecord {
253 timestamp: Local::now().to_rfc3339(),
254 project_root: project_root.to_string_lossy().to_string(),
255 project_name: Some(project_name),
256 service: service_name.to_string(),
257 command: command.to_string(),
258 working_dir: working_dir_str,
259 success,
260 duration_ms,
261 exit_code,
262 });
263
264 exec_result
265}
266
267async fn run_command_in_dir(
269 dir: &PathBuf,
270 command: &str,
271 envs: Option<&std::collections::HashMap<String, String>>,
272 _debug: bool,
273) -> Result<(), String> {
274 let _ = log_debug(
275 "service",
276 &format!("Executing: {} in {}", command, dir.display()),
277 None,
278 )
279 .await;
280
281 #[cfg(unix)]
283 let mut cmd = Command::new("sh");
284 #[cfg(unix)]
285 cmd.arg("-c");
286
287 #[cfg(windows)]
288 let mut cmd = Command::new("cmd");
289 #[cfg(windows)]
290 cmd.arg("/C");
291
292 cmd.arg(command);
293 cmd.current_dir(dir);
294 if let Some(envs) = envs {
295 cmd.envs(envs);
296 }
297 cmd.stdout(Stdio::inherit());
298 cmd.stderr(Stdio::inherit());
299
300 let status = cmd
301 .status()
302 .await
303 .map_err(|e| format!("Failed to execute command: {}", e))?;
304
305 if !status.success() {
306 return Err(format!(
307 "Command failed with exit code: {}",
308 status.code().unwrap_or(-1)
309 ));
310 }
311
312 Ok(())
313}
314
315fn merge_envs(
316 project_root: &std::path::Path,
317 global: Option<&std::collections::HashMap<String, String>>,
318 service: Option<&std::collections::HashMap<String, String>>,
319) -> Option<std::collections::HashMap<String, String>> {
320 if global.is_none() && service.is_none() {
321 return None;
322 }
323 let mut out = std::collections::HashMap::new();
324 if let Some(g) = global {
325 out.extend(g.clone());
326 }
327 if let Some(s) = service {
328 out.extend(s.clone());
329 }
330 Some(resolve_env_placeholders(project_root, &out))
331}
332
333pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
334 let current_dir: PathBuf =
335 env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
336
337 let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
338 format!(
339 "{}\n\n{}\n{}",
340 "Currently not in an XBP project",
341 "No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
342 "Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
343 )
344 })?;
345
346 let content: String = std::fs::read_to_string(&found.config_path)
347 .map_err(|e| format!("Failed to read config file: {}", e))?;
348
349 let (mut config, healed_content): (XbpConfig, Option<String>) =
350 parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
351 if found.kind == "yaml" {
352 format!("Failed to parse YAML config: {}", e)
353 } else {
354 format!("Failed to parse JSON config: {}", e)
355 }
356 })?;
357
358 if let Some(healed_content) = healed_content {
359 let _ = std::fs::write(&found.config_path, healed_content);
360 }
361
362 resolve_config_paths_for_runtime(&mut config, &found.project_root);
363
364 if found.kind == "json" {
365 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
366 }
367
368 crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
369 .await;
370
371 Ok((found.project_root, config))
372}
373
374pub async fn load_xbp_config() -> Result<XbpConfig, String> {
376 Ok(load_xbp_config_with_root().await?.1)
377}
378
379pub async fn is_xbp_project() -> bool {
381 let current_dir = match env::current_dir() {
382 Ok(dir) => dir,
383 Err(_) => return false,
384 };
385 find_xbp_config_upwards(¤t_dir).is_some()
386}
387
388pub async fn show_service_help(service_name: &str) -> Result<(), String> {
390 let service = get_service_config(service_name).await?;
391 let prefix = get_prefix();
392
393 info!("{}", prefix);
394 info!("{} Service: {}", prefix, service.name);
395 info!("{} {:-<60}", prefix, "");
396 info!("{} Target: {}", prefix, service.target);
397 info!("{} Port: {}", prefix, service.port);
398 info!("{} Branch: {}", prefix, service.branch);
399 if let Some(url) = &service.url {
400 info!(" {}URL: {}", prefix, url);
401 }
402 if let Some(root_dir) = &service.root_directory {
403 info!(" {}Root Directory: {}", prefix, root_dir);
404 }
405 info!(
406 "{} Force Run From Root: {}",
407 prefix,
408 service.force_run_from_root.unwrap_or(false)
409 );
410
411 if let Some(commands) = &service.commands {
412 info!("{}", prefix);
413 info!("{} Available Commands:", prefix);
414 info!("{} {:-<60}", prefix, "");
415 if commands.pre.is_some() {
416 info!("{} Service pre {}", prefix, service_name);
417 }
418 if commands.install.is_some() {
419 info!("{} Service install {}", prefix, service_name);
420 }
421 if commands.build.is_some() {
422 info!("{} Service build {}", prefix, service_name);
423 }
424 if commands.start.is_some() {
425 info!("{} Service start {}", prefix, service_name);
426 }
427 if commands.dev.is_some() {
428 info!("{} Service dev {}", prefix, service_name);
429 }
430 }
431
432 info!("{}", prefix);
433 info!("{} Redeploy:", prefix);
434 info!("{} Redeploy {}", prefix, service_name);
435
436 Ok(())
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct ServiceRunRecord {
445 pub timestamp: String,
446 pub project_root: String,
447 pub project_name: Option<String>,
448 pub service: String,
449 pub command: String,
450 pub working_dir: String,
451 pub success: bool,
452 pub duration_ms: u64,
453 pub exit_code: Option<i32>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize, Default)]
457pub struct ServicesRegistry {
458 pub last_updated: String,
459 pub services: Vec<RegisteredService>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct RegisteredService {
464 pub name: String,
465 pub project_path: String,
466 pub project_name: Option<String>,
467 pub target: String,
468 pub port: u16,
469 pub available_commands: Vec<String>,
470 pub last_seen: String,
471}
472
473fn global_service_dir() -> Result<PathBuf, String> {
474 let paths = global_xbp_paths()?;
475 let dir = paths.logs_dir.join("service");
476 std::fs::create_dir_all(&dir)
477 .map_err(|e| format!("Failed to create service logs dir: {}", e))?;
478 Ok(dir)
479}
480
481pub fn service_runs_path() -> Result<PathBuf, String> {
482 Ok(global_service_dir()?.join("runs.jsonl"))
483}
484
485pub fn service_registry_path() -> Result<PathBuf, String> {
486 Ok(global_service_dir()?.join("registry.json"))
487}
488
489pub fn record_service_run(record: ServiceRunRecord) -> Result<(), String> {
491 let path = service_runs_path()?;
492 let mut file = std::fs::OpenOptions::new()
493 .create(true)
494 .append(true)
495 .open(&path)
496 .map_err(|e| format!("Failed to open runs log {}: {}", path.display(), e))?;
497 let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
498 writeln!(file, "{}", line).map_err(|e| e.to_string())?;
499 Ok(())
500}
501
502pub fn load_services_registry() -> Result<ServicesRegistry, String> {
504 let path = service_registry_path()?;
505 if !path.exists() {
506 return Ok(ServicesRegistry::default());
507 }
508 let content =
509 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read registry: {}", e))?;
510 let reg: ServicesRegistry =
511 serde_json::from_str(&content).map_err(|e| format!("Failed to parse registry: {}", e))?;
512 Ok(reg)
513}
514
515pub async fn update_services_registry(
517 services: &[ServiceConfig],
518 project_root: &Path,
519 project_name: Option<&str>,
520) -> Result<(), String> {
521 let mut reg = load_services_registry().unwrap_or_default();
522 let now = Local::now().to_rfc3339();
523 let root_str = project_root.to_string_lossy().to_string();
524
525 for svc in services {
526 let avail = available_commands_for_service(svc);
527 let reg_svc = RegisteredService {
528 name: svc.name.clone(),
529 project_path: root_str.clone(),
530 project_name: project_name.map(|s| s.to_string()),
531 target: svc.target.clone(),
532 port: svc.port,
533 available_commands: avail,
534 last_seen: now.clone(),
535 };
536
537 if let Some(existing) = reg
539 .services
540 .iter_mut()
541 .find(|r| r.project_path == root_str && r.name == svc.name)
542 {
543 *existing = reg_svc;
544 } else {
545 reg.services.push(reg_svc);
546 }
547 }
548
549 reg.last_updated = now;
550
551 let path = service_registry_path()?;
552 let json = serde_json::to_string_pretty(®).map_err(|e| e.to_string())?;
553 std::fs::write(&path, json).map_err(|e| format!("Failed to write registry: {}", e))?;
554
555 Ok(())
556}
557
558pub fn available_commands_for_service(s: &ServiceConfig) -> Vec<String> {
560 let mut v = Vec::new();
561 if let Some(c) = &s.commands {
562 if c.pre.as_ref().is_some_and(|x| !x.trim().is_empty()) {
563 v.push("pre".to_string());
564 }
565 if c.install.as_ref().is_some_and(|x| !x.trim().is_empty()) {
566 v.push("install".to_string());
567 }
568 if c.build.as_ref().is_some_and(|x| !x.trim().is_empty()) {
569 v.push("build".to_string());
570 }
571 if c.start.as_ref().is_some_and(|x| !x.trim().is_empty()) {
572 v.push("start".to_string());
573 }
574 if c.dev.as_ref().is_some_and(|x| !x.trim().is_empty()) {
575 v.push("dev".to_string());
576 }
577 }
578 v
579}
580
581pub async fn print_service_preview() -> Result<(), String> {
583 let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
584 let services = get_all_services(&config);
585
586 println!();
587 println!(
588 "{} {}",
589 "◆".bright_blue().bold(),
590 format!("xbp service — {}", config.project_name)
591 .bright_cyan()
592 .bold()
593 );
594 ui::divider(72);
595
596 if services.is_empty() {
597 println!("No services configured in this project.");
598 return Ok(());
599 }
600
601 for svc in &services {
602 let avail = available_commands_for_service(svc);
603 let cmds_disp = if avail.is_empty() {
604 "no commands".dimmed().to_string()
605 } else {
606 avail.join(", ").bright_green().to_string()
607 };
608
609 let root_note = svc
610 .root_directory
611 .as_deref()
612 .map(|r| format!(" root={}", r))
613 .unwrap_or_default();
614
615 println!(
616 " {} {} {} port {} {} {}",
617 "•".bright_magenta(),
618 svc.name.bright_white().bold(),
619 format!("[{}]", svc.target).bright_yellow(),
620 svc.port.to_string().bright_cyan(),
621 cmds_disp,
622 root_note.dimmed()
623 );
624 }
625
626 ui::divider(72);
627 println!(
628 "{} {}",
629 "Hint:".bright_yellow().bold(),
630 "Pick interactively below, or use `xbp service <command> <name>`".dimmed()
631 );
632
633 let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
635
636 Ok(())
637}
638
639fn is_interactive_terminal() -> bool {
640 std::io::stdin().is_terminal()
641 && std::io::stdout().is_terminal()
642 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
643}
644
645pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
647 if let Err(_e) = print_service_preview().await {
649 println!();
651 println!("{}", "Not inside an XBP project directory.".bright_yellow());
652 println!(
653 "{} {}",
654 "Tip:".bright_cyan(),
655 "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
656 );
657
658 if let Ok(reg) = load_services_registry() {
660 if !reg.services.is_empty() {
661 println!();
662 println!("{}", "Recently known services (from registry):".dimmed());
663 for rs in reg.services.iter().take(8) {
664 let proj = rs.project_name.as_deref().unwrap_or("?");
665 println!(
666 " {} {} ({}) cmds: {}",
667 "•".dimmed(),
668 rs.name.bright_white(),
669 proj,
670 rs.available_commands.join(", ").dimmed()
671 );
672 }
673 println!();
674 println!(
675 "{}",
676 format!(
677 "Registry: {}",
678 service_registry_path().unwrap_or_default().display()
679 )
680 .dimmed()
681 );
682 }
683 }
684 return Ok(());
685 }
686
687 if !is_interactive_terminal() {
688 ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
689 return Ok(());
690 }
691
692 let (_project_root, config) = load_xbp_config_with_root().await?;
693 let services = get_all_services(&config);
694 if services.is_empty() {
695 return Ok(());
696 }
697
698 let service_labels: Vec<String> = services
700 .iter()
701 .map(|s| {
702 let cmds = available_commands_for_service(s);
703 let cmd_hint = if cmds.is_empty() {
704 "".to_string()
705 } else {
706 format!(" — {}", cmds.join(", "))
707 };
708 format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
709 })
710 .collect();
711
712 let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
713 .with_prompt("Select service to operate on")
714 .items(&service_labels)
715 .default(0)
716 .interact_opt()
717 {
718 Ok(Some(i)) => i,
719 _ => {
720 println!("{}", "Cancelled.".dimmed());
721 return Ok(());
722 }
723 };
724
725 let chosen = &services[service_idx];
726 let avail_cmds = available_commands_for_service(chosen);
727 if avail_cmds.is_empty() {
728 println!(
729 "Service '{}' has no runnable commands configured.",
730 chosen.name
731 );
732 return Ok(());
733 }
734
735 let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
736 .with_prompt(format!("Select command for '{}'", chosen.name))
737 .items(&avail_cmds)
738 .default(0)
739 .interact_opt()
740 {
741 Ok(Some(i)) => i,
742 _ => {
743 println!("{}", "Cancelled.".dimmed());
744 return Ok(());
745 }
746 };
747
748 let chosen_cmd = &avail_cmds[cmd_idx];
749
750 println!();
751 println!(
752 "{} Running {} {} {}",
753 "▶".bright_green().bold(),
754 "xbp service".bright_magenta(),
755 chosen_cmd.bright_cyan().bold(),
756 chosen.name.bright_white().bold()
757 );
758 ui::divider(40);
759
760 run_service_command(chosen_cmd, &chosen.name, debug).await
761}
762
763pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
765 let (_project_root, config) = load_xbp_config_with_root().await?;
766 let all_services = get_all_services(&config);
767
768 let candidates: Vec<&ServiceConfig> = all_services
769 .iter()
770 .filter(|s| {
771 available_commands_for_service(s)
772 .iter()
773 .any(|c| c == command)
774 })
775 .collect();
776
777 if candidates.is_empty() {
778 return Err(format!(
779 "No services have a '{}' command configured. Check `xbp services`.",
780 command
781 ));
782 }
783
784 if candidates.len() == 1 {
785 let only = candidates[0];
786 println!(
787 "Only one service supports '{}': {} — running it.",
788 command, only.name
789 );
790 return run_service_command(command, &only.name, debug).await;
791 }
792
793 if !is_interactive_terminal() {
794 let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
795 return Err(format!(
796 "Multiple services support '{}'. Specify one of: {}",
797 command,
798 names.join(", ")
799 ));
800 }
801
802 let labels: Vec<String> = candidates
803 .iter()
804 .map(|s| format!("{} (port {})", s.name, s.port))
805 .collect();
806
807 let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
808 .with_prompt(format!("Select service for '{}'", command))
809 .items(&labels)
810 .default(0)
811 .interact()
812 .map_err(|e| format!("Selection failed: {}", e))?;
813
814 let chosen = candidates[idx];
815 run_service_command(command, &chosen.name, debug).await
816}
817
818#[cfg(test)]
819mod tests {
820 use crate::commands::terminal_table::{render_table, TableStyle};
821
822 #[test]
823 fn service_table_renderer_pads_colored_cells() {
824 let rendered = render_table(
825 &["Name", "Port"],
826 &[vec![
827 "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
828 "3000".to_string(),
829 ]],
830 TableStyle::Box,
831 "",
832 );
833
834 assert!(rendered.contains("athena"));
835 assert!(rendered.contains("3000"));
836 assert!(rendered.contains("┌"));
837 assert!(rendered.contains("│"));
838 }
839}