Skip to main content

xbp_cli/commands/
service.rs

1//! service command module
2//!
3//! handles service related commands list build install start dev
4//! manages service configuration loading and command execution
5//! supports root directory and force run from root logic
6//! wraps start commands with pm2 when configured
7
8use 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
35/// List all services from the current XBP project config
36pub 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    // Also sync to registry (best effort)
94    let _ = update_services_registry(&services, &project_root, Some(&config.project_name)).await;
95
96    Ok(())
97}
98
99/// Get service by name from config
100pub 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
105/// Run any command configured for a service.
106pub 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    // Determine working directory
116    let working_dir = if service.force_run_from_root.unwrap_or(false) {
117        // If force_run_from_root is true, use project root
118        project_root.clone()
119    } else if let Some(root_dir) = &service.root_directory {
120        // Use root_directory if specified
121        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        // Default to project root
130        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    // Execute and capture result so we can always record the run (success + failures)
144    let exec_result: Result<(), String> = (async {
145        // Get the command to run
146        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        // Handle empty build command
159        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        // For start command, wrap with PM2 if start_wrapper is pm2
165        if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
166            // Create log directory
167            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            // Build PM2 start command with port argument
172            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        // Run pre-command if it exists and we're not running pre itself
213        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        // Execute the command
223        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    // Always record the run attempt (success or failure)
244    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
259/// Run a shell command in a specific directory.
260///
261/// On pnpm virtual-store layout mismatches (`ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF`),
262/// automatically runs `pnpm install` at the workspace root and retries the original
263/// command once so service builds keep working across WSL/Windows/corepack pnpm versions.
264async 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
329/// Walk up from `start` to find a pnpm workspace root (pnpm-workspace.yaml or
330/// package.json + pnpm-lock.yaml). Prefer the nearest workspace file.
331fn 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    // Capture so we can detect recoverable pnpm errors; still stream live.
390    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(&current_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
504/// Load XBP config from current directory (or parent directories)
505pub async fn load_xbp_config() -> Result<XbpConfig, String> {
506    Ok(load_xbp_config_with_root().await?.1)
507}
508
509/// Check if we're in an xbp project
510pub 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(&current_dir).is_some()
516}
517
518/// Show help for a specific service
519pub 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// -----------------------------------------------------------------------------
558// Service preview, interactive picker, run recording & registry (in ~/.xbp/logs/service/)
559// -----------------------------------------------------------------------------
560
561#[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
607/// Append a run record (jsonl) for history of `xbp service` executions.
608pub 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
620/// Load the services registry (global across projects).
621pub 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
633/// Update (merge) the registry with current project's services.
634pub 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        // replace if same (project_path + name)
656        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(&reg).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
676/// Return list of configured commands that have a non-empty value for a service.
677pub 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
684/// Pretty print preview of available services + commands (used by list and interactive entry).
685pub 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    // sync registry
737    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
748/// Interactive picker: when you run `xbp service` bare, preview then let user choose service then command.
749pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
750    // Always attempt to show preview (also updates registry)
751    if let Err(_e) = print_service_preview().await {
752        // If not in project, show a friendly message + recent registry info
753        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        // Show recent known services from registry as a teaser
762        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    // Build labels for service picker
802    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
866/// If a command (e.g. "start") is given without service name, offer interactive pick among services that support it.
867pub 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}