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            format!(
471                "Failed to parse {} config: {}",
472                found.kind.to_ascii_uppercase(),
473                e
474            )
475        })?;
476
477    if let Some(healed_content) = healed_content {
478        let _ = std::fs::write(&found.config_path, healed_content);
479    }
480
481    resolve_config_paths_for_runtime(&mut config, &found.project_root);
482
483    if found.kind == "json" {
484        let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path);
485    }
486
487    crate::data::athena::persist_project_snapshot(&found.project_root, &config, Some(found.kind))
488        .await;
489
490    Ok((found.project_root, config))
491}
492
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(error) = print_service_preview().await {
741        println!();
742        let current_dir = env::current_dir().ok();
743        let found = current_dir
744            .as_ref()
745            .and_then(|dir| find_xbp_config_upwards(dir));
746
747        if let Some(found) = found {
748            // Config exists but load/parse failed — do not claim "not in a project".
749            println!(
750                "{}",
751                "Found an XBP project, but the config could not be loaded."
752                    .bright_yellow()
753                    .bold()
754            );
755            println!(
756                "{} {}",
757                "Config:".bright_cyan(),
758                found.config_path.display().to_string().dimmed()
759            );
760            println!("{} {}", "Error:".bright_red(), error);
761            println!(
762                "{} {}",
763                "Tip:".bright_cyan(),
764                "Fix or restore .xbp/xbp.yaml (xbp auto-heals duplicate keys, colon script names, and broken service items on load)."
765                    .dimmed()
766            );
767        } else {
768            println!("{}", "Not inside an XBP project directory.".bright_yellow());
769            println!(
770                "{} {}",
771                "Tip:".bright_cyan(),
772                "cd into a project with xbp.yaml (or .xbp/xbp.yaml) and try again.".dimmed()
773            );
774        }
775
776        // Show recent known services from registry as a teaser
777        if let Ok(reg) = load_services_registry() {
778            if !reg.services.is_empty() {
779                println!();
780                println!("{}", "Recently known services (from registry):".dimmed());
781                for rs in reg.services.iter().take(8) {
782                    let proj = rs.project_name.as_deref().unwrap_or("?");
783                    println!(
784                        "  {} {}  ({})  cmds: {}",
785                        "•".dimmed(),
786                        rs.name.bright_white(),
787                        proj,
788                        rs.available_commands.join(", ").dimmed()
789                    );
790                }
791                println!();
792                println!(
793                    "{}",
794                    format!(
795                        "Registry: {}",
796                        service_registry_path().unwrap_or_default().display()
797                    )
798                    .dimmed()
799                );
800            }
801        }
802        return Ok(());
803    }
804
805    if !is_interactive_terminal() {
806        ui::tip("Non-interactive terminal detected. Use `xbp service <cmd> <name>` explicitly.");
807        return Ok(());
808    }
809
810    let (_project_root, config) = load_xbp_config_with_root().await?;
811    let services = get_all_services(&config);
812    if services.is_empty() {
813        return Ok(());
814    }
815
816    // Build labels for service picker
817    let service_labels: Vec<String> = services
818        .iter()
819        .map(|s| {
820            let cmds = available_commands_for_service(s);
821            let cmd_hint = if cmds.is_empty() {
822                "".to_string()
823            } else {
824                format!(" — {}", cmds.join(", "))
825            };
826            format!("{} ({}) port {} {}", s.name, s.target, s.port, cmd_hint)
827        })
828        .collect();
829
830    let service_idx = match FuzzySelect::with_theme(&ColorfulTheme::default())
831        .with_prompt("Select service to operate on")
832        .items(&service_labels)
833        .default(0)
834        .interact_opt()
835    {
836        Ok(Some(i)) => i,
837        _ => {
838            println!("{}", "Cancelled.".dimmed());
839            return Ok(());
840        }
841    };
842
843    let chosen = &services[service_idx];
844    let avail_cmds = available_commands_for_service(chosen);
845    if avail_cmds.is_empty() {
846        println!(
847            "Service '{}' has no runnable commands configured.",
848            chosen.name
849        );
850        return Ok(());
851    }
852
853    let cmd_idx = match Select::with_theme(&ColorfulTheme::default())
854        .with_prompt(format!("Select command for '{}'", chosen.name))
855        .items(&avail_cmds)
856        .default(0)
857        .interact_opt()
858    {
859        Ok(Some(i)) => i,
860        _ => {
861            println!("{}", "Cancelled.".dimmed());
862            return Ok(());
863        }
864    };
865
866    let chosen_cmd = &avail_cmds[cmd_idx];
867
868    println!();
869    println!(
870        "{} Running {} {} {}",
871        "▶".bright_green().bold(),
872        "xbp service".bright_magenta(),
873        chosen_cmd.bright_cyan().bold(),
874        chosen.name.bright_white().bold()
875    );
876    ui::divider(40);
877
878    run_service_command(chosen_cmd, &chosen.name, debug).await
879}
880
881/// If a command (e.g. "start") is given without service name, offer interactive pick among services that support it.
882pub async fn pick_service_for_command(command: &str, debug: bool) -> Result<(), String> {
883    let (_project_root, config) = load_xbp_config_with_root().await?;
884    let all_services = get_all_services(&config);
885
886    let candidates: Vec<&ServiceConfig> = all_services
887        .iter()
888        .filter(|s| {
889            available_commands_for_service(s)
890                .iter()
891                .any(|c| c == command)
892        })
893        .collect();
894
895    if candidates.is_empty() {
896        return Err(format!(
897            "No services have a '{}' command configured. Check `xbp services`.",
898            command
899        ));
900    }
901
902    if candidates.len() == 1 {
903        let only = candidates[0];
904        println!(
905            "Only one service supports '{}': {} — running it.",
906            command, only.name
907        );
908        return run_service_command(command, &only.name, debug).await;
909    }
910
911    if !is_interactive_terminal() {
912        let names: Vec<_> = candidates.iter().map(|s| s.name.as_str()).collect();
913        return Err(format!(
914            "Multiple services support '{}'. Specify one of: {}",
915            command,
916            names.join(", ")
917        ));
918    }
919
920    let labels: Vec<String> = candidates
921        .iter()
922        .map(|s| format!("{} (port {})", s.name, s.port))
923        .collect();
924
925    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
926        .with_prompt(format!("Select service for '{}'", command))
927        .items(&labels)
928        .default(0)
929        .interact()
930        .map_err(|e| format!("Selection failed: {}", e))?;
931
932    let chosen = candidates[idx];
933    run_service_command(command, &chosen.name, debug).await
934}
935
936#[cfg(test)]
937mod tests {
938    use super::{find_pnpm_workspace_root, is_pnpm_virtual_store_mismatch};
939    use crate::commands::terminal_table::{render_table, TableStyle};
940    use std::fs;
941    use std::time::{SystemTime, UNIX_EPOCH};
942
943    #[test]
944    fn service_table_renderer_pads_colored_cells() {
945        let rendered = render_table(
946            &["Name", "Port"],
947            &[vec![
948                "\u{1b}[1;37mathena\u{1b}[0m".to_string(),
949                "3000".to_string(),
950            ]],
951            TableStyle::Box,
952            "",
953        );
954
955        assert!(rendered.contains("athena"));
956        assert!(rendered.contains("3000"));
957        assert!(rendered.contains("┌"));
958        assert!(rendered.contains("│"));
959    }
960
961    #[test]
962    fn detects_pnpm_virtual_store_max_length_diff() {
963        let sample = r#"[ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF] This modules directory was created using a different virtual-store-dir-max-length value. Run "pnpm install" to recreate the modules directory."#;
964        assert!(is_pnpm_virtual_store_mismatch(sample));
965        assert!(!is_pnpm_virtual_store_mismatch("tsc: error TS2307"));
966    }
967
968    #[test]
969    fn finds_workspace_root_from_nested_package() {
970        let nanos = SystemTime::now()
971            .duration_since(UNIX_EPOCH)
972            .unwrap()
973            .as_nanos();
974        let root = std::env::temp_dir().join(format!("xbp-pnpm-ws-{nanos}"));
975        let nested = root.join("apps").join("api");
976        fs::create_dir_all(&nested).unwrap();
977        fs::write(root.join("pnpm-workspace.yaml"), "packages:\n  - apps/*\n").unwrap();
978        fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
979        fs::write(nested.join("package.json"), r#"{"name":"api"}"#).unwrap();
980
981        let found = find_pnpm_workspace_root(&nested).expect("workspace root");
982        assert_eq!(found, root);
983
984        let _ = fs::remove_dir_all(&root);
985    }
986}