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