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