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