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(
265 dir: &PathBuf,
266 command: &str,
267 envs: Option<&std::collections::HashMap<String, String>>,
268 debug: bool,
269) -> Result<(), String> {
270 let (exit_code, combined_output) =
271 run_shell_command_streaming(dir, command, envs, debug).await?;
272
273 if exit_code == 0 {
274 return Ok(());
275 }
276
277 if !is_pnpm_virtual_store_mismatch(&combined_output) {
278 return Err(format!("Command failed with exit code: {exit_code}"));
279 }
280
281 let install_dir = find_pnpm_workspace_root(dir).unwrap_or_else(|| dir.to_path_buf());
282 let _ = log_info(
283 "service",
284 "Detected pnpm virtual-store layout mismatch; running `pnpm install` to recreate node_modules",
285 Some(&format!("Workspace: {}", install_dir.display())),
286 )
287 .await;
288 eprintln!(
289 "{} pnpm virtual-store mismatch — running `pnpm install` in {} then retrying",
290 get_prefix(),
291 install_dir.display()
292 );
293
294 let (install_code, install_output) =
295 run_shell_command_streaming(&install_dir, "pnpm install", envs, debug).await?;
296 if install_code != 0 {
297 return Err(format!(
298 "Command failed with exit code: {exit_code}. Auto-heal `pnpm install` also failed with exit code: {install_code}.\n{install_output}"
299 ));
300 }
301
302 let _ = log_info(
303 "service",
304 "pnpm install finished; retrying original service command",
305 Some(command),
306 )
307 .await;
308
309 let (retry_code, _) = run_shell_command_streaming(dir, command, envs, debug).await?;
310 if retry_code == 0 {
311 return Ok(());
312 }
313 Err(format!(
314 "Command failed with exit code: {retry_code} after pnpm install auto-heal"
315 ))
316}
317
318fn is_pnpm_virtual_store_mismatch(output: &str) -> bool {
319 let lower = output.to_ascii_lowercase();
320 lower.contains("err_pnpm_virtual_store_dir_max_length_diff")
321 || (lower.contains("virtual-store-dir-max-length")
322 && (lower.contains("recreate the modules directory")
323 || lower.contains("run \"pnpm install\"")
324 || lower.contains("run 'pnpm install'")
325 || lower.contains("run `pnpm install`")
326 || lower.contains("pnpm install")))
327}
328
329fn find_pnpm_workspace_root(start: &Path) -> Option<PathBuf> {
332 let mut current = if start.is_file() {
333 start.parent().map(Path::to_path_buf)?
334 } else {
335 start.to_path_buf()
336 };
337
338 let mut package_with_lock: Option<PathBuf> = None;
339 loop {
340 if current.join("pnpm-workspace.yaml").is_file()
341 || current.join("pnpm-workspace.yml").is_file()
342 {
343 return Some(current);
344 }
345 if package_with_lock.is_none()
346 && current.join("package.json").is_file()
347 && (current.join("pnpm-lock.yaml").is_file() || current.join("pnpm-lock.yml").is_file())
348 {
349 package_with_lock = Some(current.clone());
350 }
351 if !current.pop() {
352 break;
353 }
354 }
355 package_with_lock
356}
357
358async fn run_shell_command_streaming(
359 dir: &Path,
360 command: &str,
361 envs: Option<&std::collections::HashMap<String, String>>,
362 _debug: bool,
363) -> Result<(i32, String), String> {
364 let _ = log_debug(
365 "service",
366 &format!("Executing: {} in {}", command, dir.display()),
367 None,
368 )
369 .await;
370
371 #[cfg(unix)]
372 let mut cmd = Command::new("sh");
373 #[cfg(unix)]
374 {
375 cmd.arg("-c").arg(command);
376 }
377
378 #[cfg(windows)]
379 let mut cmd = Command::new("cmd");
380 #[cfg(windows)]
381 {
382 cmd.arg("/C").arg(command);
383 }
384
385 cmd.current_dir(dir);
386 if let Some(envs) = envs {
387 cmd.envs(envs);
388 }
389 cmd.stdout(Stdio::piped());
391 cmd.stderr(Stdio::piped());
392
393 let mut child = cmd
394 .spawn()
395 .map_err(|e| format!("Failed to execute command: {e}"))?;
396
397 let stdout = child
398 .stdout
399 .take()
400 .ok_or_else(|| "Failed to capture command stdout".to_string())?;
401 let stderr = child
402 .stderr
403 .take()
404 .ok_or_else(|| "Failed to capture command stderr".to_string())?;
405
406 let stdout_task = tokio::spawn(async move {
407 use tokio::io::{AsyncBufReadExt, BufReader};
408 let mut lines = BufReader::new(stdout).lines();
409 let mut buffer = String::new();
410 while let Ok(Some(line)) = lines.next_line().await {
411 println!("{line}");
412 buffer.push_str(&line);
413 buffer.push('\n');
414 }
415 buffer
416 });
417 let stderr_task = tokio::spawn(async move {
418 use tokio::io::{AsyncBufReadExt, BufReader};
419 let mut lines = BufReader::new(stderr).lines();
420 let mut buffer = String::new();
421 while let Ok(Some(line)) = lines.next_line().await {
422 eprintln!("{line}");
423 buffer.push_str(&line);
424 buffer.push('\n');
425 }
426 buffer
427 });
428
429 let status = child
430 .wait()
431 .await
432 .map_err(|e| format!("Failed to wait for command: {e}"))?;
433 let stdout_text = stdout_task
434 .await
435 .map_err(|e| format!("Failed to read stdout: {e}"))?;
436 let stderr_text = stderr_task
437 .await
438 .map_err(|e| format!("Failed to read stderr: {e}"))?;
439
440 let mut combined = stdout_text;
441 combined.push_str(&stderr_text);
442 Ok((status.code().unwrap_or(-1), combined))
443}
444
445fn merge_envs(
446 project_root: &std::path::Path,
447 global: Option<&std::collections::HashMap<String, String>>,
448 service: Option<&std::collections::HashMap<String, String>>,
449) -> Option<std::collections::HashMap<String, String>> {
450 if global.is_none() && service.is_none() {
451 return None;
452 }
453 let mut out = std::collections::HashMap::new();
454 if let Some(g) = global {
455 out.extend(g.clone());
456 }
457 if let Some(s) = service {
458 out.extend(s.clone());
459 }
460 Some(resolve_env_placeholders(project_root, &out))
461}
462
463pub async fn load_xbp_config_with_root() -> Result<(PathBuf, XbpConfig), String> {
464 let current_dir: PathBuf =
465 env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
466
467 let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
468 format!(
469 "{}\n\n{}\n{}",
470 "Currently not in an XBP project",
471 "No xbp.yaml/xbp.yml/xbp.json found in current directory or .xbp/",
472 "Run 'xbp' to select a project or 'xbp setup' to initialize a new project."
473 )
474 })?;
475
476 let content: String = std::fs::read_to_string(&found.config_path)
477 .map_err(|e| format!("Failed to read config file: {}", e))?;
478
479 let (mut config, healed_content): (XbpConfig, Option<String>) =
480 parse_config_with_auto_heal(&content, found.kind).map_err(|e| {
481 if found.kind == "yaml" {
482 format!("Failed to parse YAML config: {}", e)
483 } else {
484 format!("Failed to parse JSON config: {}", e)
485 }
486 })?;
487
488 if let Some(healed_content) = healed_content {
489 let _ = std::fs::write(&found.config_path, healed_content);
490 }
491
492 resolve_config_paths_for_runtime(&mut config, &found.project_root);
493
494 if found.kind == "json" {
495 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
496 }
497
498 crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
499 .await;
500
501 Ok((found.project_root, config))
502}
503
504pub async fn load_xbp_config() -> Result<XbpConfig, String> {
506 Ok(load_xbp_config_with_root().await?.1)
507}
508
509pub async fn is_xbp_project() -> bool {
511 let current_dir = match env::current_dir() {
512 Ok(dir) => dir,
513 Err(_) => return false,
514 };
515 find_xbp_config_upwards(¤t_dir).is_some()
516}
517
518pub async fn show_service_help(service_name: &str) -> Result<(), String> {
520 let service = get_service_config(service_name).await?;
521 let prefix = get_prefix();
522
523 info!("{}", prefix);
524 info!("{} Service: {}", prefix, service.name);
525 info!("{} {:-<60}", prefix, "");
526 info!("{} Target: {}", prefix, service.target);
527 info!("{} Port: {}", prefix, service.port);
528 info!("{} Branch: {}", prefix, service.branch);
529 if let Some(url) = &service.url {
530 info!(" {}URL: {}", prefix, url);
531 }
532 if let Some(root_dir) = &service.root_directory {
533 info!(" {}Root Directory: {}", prefix, root_dir);
534 }
535 info!(
536 "{} Force Run From Root: {}",
537 prefix,
538 service.force_run_from_root.unwrap_or(false)
539 );
540
541 if let Some(commands) = &service.commands {
542 info!("{}", prefix);
543 info!("{} Available Commands:", prefix);
544 info!("{} {:-<60}", prefix, "");
545 for command in commands.available_names() {
546 info!("{} Service {} {}", prefix, command, service_name);
547 }
548 }
549
550 info!("{}", prefix);
551 info!("{} Redeploy:", prefix);
552 info!("{} Redeploy {}", prefix, service_name);
553
554 Ok(())
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct ServiceRunRecord {
563 pub timestamp: String,
564 pub project_root: String,
565 pub project_name: Option<String>,
566 pub service: String,
567 pub command: String,
568 pub working_dir: String,
569 pub success: bool,
570 pub duration_ms: u64,
571 pub exit_code: Option<i32>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, Default)]
575pub struct ServicesRegistry {
576 pub last_updated: String,
577 pub services: Vec<RegisteredService>,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct RegisteredService {
582 pub name: String,
583 pub project_path: String,
584 pub project_name: Option<String>,
585 pub target: String,
586 pub port: u16,
587 pub available_commands: Vec<String>,
588 pub last_seen: String,
589}
590
591fn global_service_dir() -> Result<PathBuf, String> {
592 let paths = global_xbp_paths()?;
593 let dir = paths.logs_dir.join("service");
594 std::fs::create_dir_all(&dir)
595 .map_err(|e| format!("Failed to create service logs dir: {}", e))?;
596 Ok(dir)
597}
598
599pub fn service_runs_path() -> Result<PathBuf, String> {
600 Ok(global_service_dir()?.join("runs.jsonl"))
601}
602
603pub fn service_registry_path() -> Result<PathBuf, String> {
604 Ok(global_service_dir()?.join("registry.json"))
605}
606
607pub fn record_service_run(record: ServiceRunRecord) -> Result<(), String> {
609 let path = service_runs_path()?;
610 let mut file = std::fs::OpenOptions::new()
611 .create(true)
612 .append(true)
613 .open(&path)
614 .map_err(|e| format!("Failed to open runs log {}: {}", path.display(), e))?;
615 let line = serde_json::to_string(&record).map_err(|e| e.to_string())?;
616 writeln!(file, "{}", line).map_err(|e| e.to_string())?;
617 Ok(())
618}
619
620pub fn load_services_registry() -> Result<ServicesRegistry, String> {
622 let path = service_registry_path()?;
623 if !path.exists() {
624 return Ok(ServicesRegistry::default());
625 }
626 let content =
627 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read registry: {}", e))?;
628 let reg: ServicesRegistry =
629 serde_json::from_str(&content).map_err(|e| format!("Failed to parse registry: {}", e))?;
630 Ok(reg)
631}
632
633pub async fn update_services_registry(
635 services: &[ServiceConfig],
636 project_root: &Path,
637 project_name: Option<&str>,
638) -> Result<(), String> {
639 let mut reg = load_services_registry().unwrap_or_default();
640 let now = Local::now().to_rfc3339();
641 let root_str = project_root.to_string_lossy().to_string();
642
643 for svc in services {
644 let avail = available_commands_for_service(svc);
645 let reg_svc = RegisteredService {
646 name: svc.name.clone(),
647 project_path: root_str.clone(),
648 project_name: project_name.map(|s| s.to_string()),
649 target: svc.target.clone(),
650 port: svc.port,
651 available_commands: avail,
652 last_seen: now.clone(),
653 };
654
655 if let Some(existing) = reg
657 .services
658 .iter_mut()
659 .find(|r| r.project_path == root_str && r.name == svc.name)
660 {
661 *existing = reg_svc;
662 } else {
663 reg.services.push(reg_svc);
664 }
665 }
666
667 reg.last_updated = now;
668
669 let path = service_registry_path()?;
670 let json = serde_json::to_string_pretty(®).map_err(|e| e.to_string())?;
671 std::fs::write(&path, json).map_err(|e| format!("Failed to write registry: {}", e))?;
672
673 Ok(())
674}
675
676pub fn available_commands_for_service(s: &ServiceConfig) -> Vec<String> {
678 s.commands
679 .as_ref()
680 .map(|commands| commands.available_names())
681 .unwrap_or_default()
682}
683
684pub async fn print_service_preview() -> Result<(), String> {
686 let (project_root, config): (PathBuf, XbpConfig) = load_xbp_config_with_root().await?;
687 let services = get_all_services(&config);
688
689 println!();
690 println!(
691 "{} {}",
692 "◆".bright_blue().bold(),
693 format!("xbp service — {}", config.project_name)
694 .bright_cyan()
695 .bold()
696 );
697 ui::divider(72);
698
699 if services.is_empty() {
700 println!("No services configured in this project.");
701 return Ok(());
702 }
703
704 for svc in &services {
705 let avail = available_commands_for_service(svc);
706 let cmds_disp = if avail.is_empty() {
707 "no commands".dimmed().to_string()
708 } else {
709 avail.join(", ").bright_green().to_string()
710 };
711
712 let root_note = svc
713 .root_directory
714 .as_deref()
715 .map(|r| format!(" root={}", r))
716 .unwrap_or_default();
717
718 println!(
719 " {} {} {} port {} {} {}",
720 "•".bright_magenta(),
721 svc.name.bright_white().bold(),
722 format!("[{}]", svc.target).bright_yellow(),
723 svc.port.to_string().bright_cyan(),
724 cmds_disp,
725 root_note.dimmed()
726 );
727 }
728
729 ui::divider(72);
730 println!(
731 "{} {}",
732 "Hint:".bright_yellow().bold(),
733 "Pick interactively below, or use `xbp service <command> <name>`".dimmed()
734 );
735
736 let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
738
739 Ok(())
740}
741
742fn is_interactive_terminal() -> bool {
743 std::io::stdin().is_terminal()
744 && std::io::stdout().is_terminal()
745 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
746}
747
748pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
750 if let Err(_e) = print_service_preview().await {
752 println!();
754 println!("{}", "Not inside an XBP project directory.".bright_yellow());
755 println!(
756 "{} {}",
757 "Tip:".bright_cyan(),
758 "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
759 );
760
761 if let Ok(reg) = load_services_registry() {
763 if !reg.services.is_empty() {
764 println!();
765 println!("{}", "Recently known services (from registry):".dimmed());
766 for rs in reg.services.iter().take(8) {
767 let proj = rs.project_name.as_deref().unwrap_or("?");
768 println!(
769 " {} {} ({}) cmds: {}",
770 "•".dimmed(),
771 rs.name.bright_white(),
772 proj,
773 rs.available_commands.join(", ").dimmed()
774 );
775 }
776 println!();
777 println!(
778 "{}",
779 format!(
780 "Registry: {}",
781 service_registry_path().unwrap_or_default().display()
782 )
783 .dimmed()
784 );
785 }
786 }
787 return Ok(());
788 }
789
790 if !is_interactive_terminal() {
791 ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
792 return Ok(());
793 }
794
795 let (_project_root, config) = load_xbp_config_with_root().await?;
796 let services = get_all_services(&config);
797 if services.is_empty() {
798 return Ok(());
799 }
800
801 let service_labels: Vec<String> = services
803 .iter()
804 .map(|s| {
805 let cmds = available_commands_for_service(s);
806 let cmd_hint = if cmds.is_empty() {
807 "".to_string()
808 } else {
809 format!(" — {}", cmds.join(", "))
810 };
811 format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
812 })
813 .collect();
814
815 let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
816 .with_prompt("Select service to operate on")
817 .items(&service_labels)
818 .default(0)
819 .interact_opt()
820 {
821 Ok(Some(i)) => i,
822 _ => {
823 println!("{}", "Cancelled.".dimmed());
824 return Ok(());
825 }
826 };
827
828 let chosen = &services[service_idx];
829 let avail_cmds = available_commands_for_service(chosen);
830 if avail_cmds.is_empty() {
831 println!(
832 "Service '{}' has no runnable commands configured.",
833 chosen.name
834 );
835 return Ok(());
836 }
837
838 let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
839 .with_prompt(format!("Select command for '{}'", chosen.name))
840 .items(&avail_cmds)
841 .default(0)
842 .interact_opt()
843 {
844 Ok(Some(i)) => i,
845 _ => {
846 println!("{}", "Cancelled.".dimmed());
847 return Ok(());
848 }
849 };
850
851 let chosen_cmd = &avail_cmds[cmd_idx];
852
853 println!();
854 println!(
855 "{} Running {} {} {}",
856 "▶".bright_green().bold(),
857 "xbp service".bright_magenta(),
858 chosen_cmd.bright_cyan().bold(),
859 chosen.name.bright_white().bold()
860 );
861 ui::divider(40);
862
863 run_service_command(chosen_cmd, &chosen.name, debug).await
864}
865
866pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
868 let (_project_root, config) = load_xbp_config_with_root().await?;
869 let all_services = get_all_services(&config);
870
871 let candidates: Vec<&ServiceConfig> = all_services
872 .iter()
873 .filter(|s| {
874 available_commands_for_service(s)
875 .iter()
876 .any(|c| c == command)
877 })
878 .collect();
879
880 if candidates.is_empty() {
881 return Err(format!(
882 "No services have a '{}' command configured. Check `xbp services`.",
883 command
884 ));
885 }
886
887 if candidates.len() == 1 {
888 let only = candidates[0];
889 println!(
890 "Only one service supports '{}': {} — running it.",
891 command, only.name
892 );
893 return run_service_command(command, &only.name, debug).await;
894 }
895
896 if !is_interactive_terminal() {
897 let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
898 return Err(format!(
899 "Multiple services support '{}'. Specify one of: {}",
900 command,
901 names.join(", ")
902 ));
903 }
904
905 let labels: Vec<String> = candidates
906 .iter()
907 .map(|s| format!("{} (port {})", s.name, s.port))
908 .collect();
909
910 let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
911 .with_prompt(format!("Select service for '{}'", command))
912 .items(&labels)
913 .default(0)
914 .interact()
915 .map_err(|e| format!("Selection failed: {}", e))?;
916
917 let chosen = candidates[idx];
918 run_service_command(command, &chosen.name, debug).await
919}
920
921#[cfg(test)]
922mod tests {
923 use super::{find_pnpm_workspace_root, is_pnpm_virtual_store_mismatch};
924 use crate::commands::terminal_table::{render_table, TableStyle};
925 use std::fs;
926 use std::time::{SystemTime, UNIX_EPOCH};
927
928 #[test]
929 fn service_table_renderer_pads_colored_cells() {
930 let rendered = render_table(
931 &["Name", "Port"],
932 &[vec![
933 "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
934 "3000".to_string(),
935 ]],
936 TableStyle::Box,
937 "",
938 );
939
940 assert!(rendered.contains("athena"));
941 assert!(rendered.contains("3000"));
942 assert!(rendered.contains("┌"));
943 assert!(rendered.contains("│"));
944 }
945
946 #[test]
947 fn detects_pnpm_virtual_store_max_length_diff() {
948 let sample = r#"[ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF] This modules directory was created using a different virtual-store-dir-max-length value. Run "pnpm install" to recreate the modules directory."#;
949 assert!(is_pnpm_virtual_store_mismatch(sample));
950 assert!(!is_pnpm_virtual_store_mismatch("tsc: error TS2307"));
951 }
952
953 #[test]
954 fn finds_workspace_root_from_nested_package() {
955 let nanos = SystemTime::now()
956 .duration_since(UNIX_EPOCH)
957 .unwrap()
958 .as_nanos();
959 let root = std::env::temp_dir().join(format!("xbp-pnpm-ws-{nanos}"));
960 let nested = root.join("apps").join("api");
961 fs::create_dir_all(&nested).unwrap();
962 fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - apps/*\n").unwrap();
963 fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
964 fs::write(nested.join("package.json"), r#"{"name":"api"}"#).unwrap();
965
966 let found = find_pnpm_workspace_root(&nested).expect("workspace root");
967 assert_eq!(found, root);
968
969 let _ = fs::remove_dir_all(&root);
970 }
971}