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    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
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 (remap stale absolute roots from other machines)
116    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    // Execute and capture result so we can always record the run (success + failures)
133    let exec_result: Result<(), String> = (async {
134        // Get the command to run
135        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        // Handle empty build command
148        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        // For start command, wrap with PM2 if start_wrapper is pm2
154        if command == "start" && service.start_wrapper.as_deref() == Some("pm2") {
155            // Create log directory
156            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            // Build PM2 start command with port argument
161            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        // Run pre-command if it exists and we're not running pre itself
202        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        // Execute the command
212        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    // Always record the run attempt (success or failure)
233    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
248/// Run a shell command in a specific directory.
249///
250/// On pnpm virtual-store layout mismatches (`ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF`),
251/// automatically runs `pnpm install` at the workspace root and retries the original
252/// command once so service builds keep working across WSL/Windows/corepack pnpm versions.
253async 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
318/// Walk up from `start` to find a pnpm workspace root (pnpm-workspace.yaml or
319/// package.json + pnpm-lock.yaml). Prefer the nearest workspace file.
320fn 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    // Capture so we can detect recoverable pnpm errors; still stream live.
379    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(&current_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            if found.kind == "yaml" {
471                format!("Failed to parse YAML config: {}", e)
472            } else {
473                format!("Failed to parse JSON config: {}", 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
493/// Load XBP config from current directory (or parent directories)
494pub async fn load_xbp_config() -> Result<XbpConfig, String> {
495    Ok(load_xbp_config_with_root().await?.1)
496}
497
498/// Check if we're in an xbp project
499pub 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(&current_dir).is_some()
505}
506
507/// Show help for a specific service
508pub 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// -----------------------------------------------------------------------------
547// Service preview, interactive picker, run recording & registry (in ~/.xbp/logs/service/)
548// -----------------------------------------------------------------------------
549
550#[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
596/// Append a run record (jsonl) for history of `xbp service` executions.
597pub 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
609/// Load the services registry (global across projects).
610pub 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
622/// Update (merge) the registry with current project's services.
623pub 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        // replace if same (project_path + name)
645        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(&reg).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
665/// Return list of configured commands that have a non-empty value for a service.
666pub 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
673/// Pretty print preview of available services + commands (used by list and interactive entry).
674pub 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    // sync registry
726    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
737/// Interactive picker: when you run `xbp service` bare, preview then let user choose service then command.
738pub async fn run_service_interactive(debug: bool) -> Result<(), String> {
739    // Always attempt to show preview (also updates registry)
740    if let Err(_e) = print_service_preview().await {
741        // If not in project, show a friendly message + recent registry info
742        println!();
743        println!("{}", "Not inside an XBP project directory.".bright_yellow());
744        println!(
745            "{} {}",
746            "Tip:".bright_cyan(),
747            "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
748        );
749
750        // Show recent known services from registry as a teaser
751        if let Ok(reg) = load_services_registry() {
752            if !reg.services.is_empty() {
753                println!();
754                println!("{}", "Recently known services (from registry):".dimmed());
755                for rs in reg.services.iter().take(8) {
756                    let proj = rs.project_name.as_deref().unwrap_or("?");
757                    println!(
758                        "  {} {}  ({})  cmds: {}",
759                        "•".dimmed(),
760                        rs.name.bright_white(),
761                        proj,
762                        rs.available_commands.join(", ").dimmed()
763                    );
764                }
765                println!();
766                println!(
767                    "{}",
768                    format!(
769                        "Registry: {}",
770                        service_registry_path().unwrap_or_default().display()
771                    )
772                    .dimmed()
773                );
774            }
775        }
776        return Ok(());
777    }
778
779    if !is_interactive_terminal() {
780        ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
781        return Ok(());
782    }
783
784    let (_project_root, config) = load_xbp_config_with_root().await?;
785    let services = get_all_services(&config);
786    if services.is_empty() {
787        return Ok(());
788    }
789
790    // Build labels for service picker
791    let service_labels: Vec<String> = services
792        .iter()
793        .map(|s| {
794            let cmds = available_commands_for_service(s);
795            let cmd_hint = if cmds.is_empty() {
796                "".to_string()
797            } else {
798                format!(" — {}", cmds.join(", "))
799            };
800            format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
801        })
802        .collect();
803
804    let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
805        .with_prompt("Select service to operate on")
806        .items(&service_labels)
807        .default(0)
808        .interact_opt()
809    {
810        Ok(Some(i)) => i,
811        _ => {
812            println!("{}", "Cancelled.".dimmed());
813            return Ok(());
814        }
815    };
816
817    let chosen = &services[service_idx];
818    let avail_cmds = available_commands_for_service(chosen);
819    if avail_cmds.is_empty() {
820        println!(
821            "Service '{}' has no runnable commands configured.",
822            chosen.name
823        );
824        return Ok(());
825    }
826
827    let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
828        .with_prompt(format!("Select command for '{}'", chosen.name))
829        .items(&avail_cmds)
830        .default(0)
831        .interact_opt()
832    {
833        Ok(Some(i)) => i,
834        _ => {
835            println!("{}", "Cancelled.".dimmed());
836            return Ok(());
837        }
838    };
839
840    let chosen_cmd = &avail_cmds[cmd_idx];
841
842    println!();
843    println!(
844        "{} Running {} {} {}",
845        "▶".bright_green().bold(),
846        "xbp service".bright_magenta(),
847        chosen_cmd.bright_cyan().bold(),
848        chosen.name.bright_white().bold()
849    );
850    ui::divider(40);
851
852    run_service_command(chosen_cmd, &chosen.name, debug).await
853}
854
855/// If a command (e.g. "start") is given without service name, offer interactive pick among services that support it.
856pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
857    let (_project_root, config) = load_xbp_config_with_root().await?;
858    let all_services = get_all_services(&config);
859
860    let candidates: Vec<&ServiceConfig> = all_services
861        .iter()
862        .filter(|s| {
863            available_commands_for_service(s)
864                .iter()
865                .any(|c| c == command)
866        })
867        .collect();
868
869    if candidates.is_empty() {
870        return Err(format!(
871            "No services have a '{}' command configured. Check `xbp services`.",
872            command
873        ));
874    }
875
876    if candidates.len() == 1 {
877        let only = candidates[0];
878        println!(
879            "Only one service supports '{}': {} — running it.",
880            command, only.name
881        );
882        return run_service_command(command, &only.name, debug).await;
883    }
884
885    if !is_interactive_terminal() {
886        let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
887        return Err(format!(
888            "Multiple services support '{}'. Specify one of: {}",
889            command,
890            names.join(", ")
891        ));
892    }
893
894    let labels: Vec<String> = candidates
895        .iter()
896        .map(|s| format!("{} (port {})", s.name, s.port))
897        .collect();
898
899    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
900        .with_prompt(format!("Select service for '{}'", command))
901        .items(&labels)
902        .default(0)
903        .interact()
904        .map_err(|e| format!("Selection failed: {}", e))?;
905
906    let chosen = candidates[idx];
907    run_service_command(command, &chosen.name, debug).await
908}
909
910#[cfg(test)]
911mod tests {
912    use super::{find_pnpm_workspace_root, is_pnpm_virtual_store_mismatch};
913    use crate::commands::terminal_table::{render_table, TableStyle};
914    use std::fs;
915    use std::time::{SystemTime, UNIX_EPOCH};
916
917    #[test]
918    fn service_table_renderer_pads_colored_cells() {
919        let rendered = render_table(
920            &["Name", "Port"],
921            &[vec![
922                "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
923                "3000".to_string(),
924            ]],
925            TableStyle::Box,
926            "",
927        );
928
929        assert!(rendered.contains("athena"));
930        assert!(rendered.contains("3000"));
931        assert!(rendered.contains("┌"));
932        assert!(rendered.contains("│"));
933    }
934
935    #[test]
936    fn detects_pnpm_virtual_store_max_length_diff() {
937        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."#;
938        assert!(is_pnpm_virtual_store_mismatch(sample));
939        assert!(!is_pnpm_virtual_store_mismatch("tsc: error TS2307"));
940    }
941
942    #[test]
943    fn finds_workspace_root_from_nested_package() {
944        let nanos = SystemTime::now()
945            .duration_since(UNIX_EPOCH)
946            .unwrap()
947            .as_nanos();
948        let root = std::env::temp_dir().join(format!("xbp-pnpm-ws-{nanos}"));
949        let nested = root.join("apps").join("api");
950        fs::create_dir_all(&nested).unwrap();
951        fs::write(root.join("pnpm-workspace.yaml"), "packages:\n  - apps/*\n").unwrap();
952        fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
953        fs::write(nested.join("package.json"), r#"{"name":"api"}"#).unwrap();
954
955        let found = find_pnpm_workspace_root(&nested).expect("workspace root");
956        assert_eq!(found, root);
957
958        let _ = fs::remove_dir_all(&root);
959    }
960}