Skip to main content

mcp_execution_cli/commands/
setup.rs

1//! Setup command implementation.
2//!
3//! Validates the runtime environment for MCP tool execution:
4//! - Checks Node.js 18+ is installed
5//! - Verifies generated files are executable
6//! - Provides helpful error messages and suggestions
7
8use anyhow::{Context, Result};
9use mcp_execution_core::cli::{ExitCode, OutputFormat};
10#[cfg(unix)]
11use mcp_execution_core::sanitize_path_for_error;
12use serde::Serialize;
13#[cfg(unix)]
14use std::path::Path;
15use std::path::PathBuf;
16use std::process::Stdio;
17use tokio::process::Command;
18
19/// Structured result of the environment setup checks.
20///
21/// Captures every check [`run`] performs so it can be rendered as JSON,
22/// plain text, or the default human-readable pretty summary via
23/// [`crate::formatters::format_output`].
24///
25/// # Examples
26///
27/// ```
28/// use mcp_execution_cli::commands::setup::SetupResult;
29///
30/// let result = SetupResult {
31///     node_version: "20.10.0".to_string(),
32///     mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
33///     mcp_config_found: true,
34///     servers_dir_found: true,
35///     files_made_executable: 3,
36///     skipped_entries: 0,
37/// };
38///
39/// assert_eq!(result.files_made_executable, 3);
40/// ```
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct SetupResult {
43    /// Detected Node.js version (e.g. `"20.10.0"`), without the leading `v`.
44    pub node_version: String,
45    /// Path where `~/.claude/mcp.json` is expected.
46    pub mcp_config_path: String,
47    /// Whether `~/.claude/mcp.json` exists.
48    pub mcp_config_found: bool,
49    /// Whether `~/.claude/servers/` exists. Always `false` on non-Unix
50    /// platforms, since file permissions are not checked there.
51    pub servers_dir_found: bool,
52    /// Number of `.ts` files made executable under `~/.claude/servers/`.
53    /// Always `0` on non-Unix platforms.
54    pub files_made_executable: usize,
55    /// Number of symlinked entries skipped while walking
56    /// `~/.claude/servers/` — any symlinked entry (a server-id directory, an
57    /// intermediate subdirectory, or a file) encountered at any depth.
58    /// Always `0` on non-Unix platforms.
59    pub skipped_entries: usize,
60}
61
62/// Runs the setup command.
63///
64/// Validates that the runtime environment is ready for MCP tool execution
65/// and renders the results according to `output_format`.
66///
67/// # Checks Performed
68///
69/// 1. **Node.js version**: Ensures Node.js 18.0.0 or higher is installed
70/// 2. **File permissions**: Makes TypeScript files executable (Unix only)
71/// 3. **Configuration**: Checks if ~/.claude/mcp.json exists
72///
73/// # Examples
74///
75/// ```bash
76/// # Run setup validation (default pretty output)
77/// mcp-execution-cli setup
78///
79/// # Output:
80/// # ✓ Node.js v20.10.0 detected
81/// # ✓ Runtime setup complete
82/// # Claude Code can now execute MCP tools via:
83/// #   node ~/.claude/servers/<server>/<tool>.ts '{"param":"value"}'
84///
85/// # Structured output for scripting
86/// mcp-execution-cli --format json setup
87/// ```
88///
89/// # Errors
90///
91/// Returns an error if:
92/// - Node.js is not installed
93/// - Node.js version is less than 18.0.0
94/// - Home directory cannot be determined
95/// - Output formatting fails (serialization error)
96pub async fn run(output_format: OutputFormat) -> Result<ExitCode> {
97    if output_format == OutputFormat::Pretty {
98        println!("Checking runtime environment...\n");
99    }
100
101    let node_version = check_node_version().await?;
102
103    let mcp_config_path = get_mcp_config_path()?;
104    let mcp_config_found = mcp_config_path.exists();
105
106    let (servers_dir_found, files_made_executable, skipped_entries) =
107        check_files_executable().await?;
108
109    let result = SetupResult {
110        node_version,
111        mcp_config_path: mcp_config_path.display().to_string(),
112        mcp_config_found,
113        servers_dir_found,
114        files_made_executable,
115        skipped_entries,
116    };
117
118    if output_format == OutputFormat::Pretty {
119        print_pretty_summary(&result);
120        return Ok(ExitCode::SUCCESS);
121    }
122
123    crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
124}
125
126/// Prints the human-readable setup summary (the `Pretty` format rendering).
127fn print_pretty_summary(result: &SetupResult) {
128    println!("✓ Node.js v{} detected", result.node_version);
129
130    if result.mcp_config_found {
131        println!("✓ MCP configuration found: {}", result.mcp_config_path);
132    } else {
133        println!("⚠ MCP configuration not found");
134        println!("  Expected location: {}", result.mcp_config_path);
135        println!("  Create it with your server configurations:");
136        println!();
137        println!("  {{");
138        println!("    \"mcpServers\": {{");
139        println!("      \"github\": {{");
140        println!("        \"command\": \"docker\",");
141        println!("        \"args\": [\"run\", \"-i\", \"--rm\", \"...\"]");
142        println!("      }}");
143        println!("    }}");
144        println!("  }}");
145        println!();
146        println!("  See examples/mcp.json.example for more details.");
147    }
148
149    #[cfg(unix)]
150    {
151        if result.servers_dir_found {
152            if result.files_made_executable > 0 {
153                println!(
154                    "✓ Made {} TypeScript files executable",
155                    result.files_made_executable
156                );
157            }
158            if result.skipped_entries > 0 {
159                println!(
160                    "⚠ Skipped {} symlinked entr{} under the servers directory (see warnings above)",
161                    result.skipped_entries,
162                    if result.skipped_entries == 1 {
163                        "y"
164                    } else {
165                        "ies"
166                    }
167                );
168            }
169        } else {
170            println!("⚠ No servers directory found");
171            println!("  Run 'mcp-execution-cli generate <server>' to create tools");
172        }
173    }
174
175    println!("\n✓ Runtime setup complete");
176    println!("  Claude Code can now execute MCP tools via:");
177    println!("  node ~/.claude/servers/<server>/<tool>.ts '{{\"param\":\"value\"}}'");
178    println!("\nNext steps:");
179    println!("  1. Generate tools: mcp-execution-cli generate <server>");
180    println!("  2. Configure servers in ~/.claude/mcp.json");
181    println!("  3. Execute tools autonomously via Node.js");
182}
183
184/// Checks Node.js version requirement.
185///
186/// Verifies that Node.js 18.0.0 or higher is installed and accessible, and
187/// returns the detected version string (without the leading `v`).
188///
189/// # Errors
190///
191/// Returns error if:
192/// - Node.js command not found in PATH
193/// - Node.js version cannot be determined
194/// - Node.js version is less than 18.0.0
195async fn check_node_version() -> Result<String> {
196    // Check if node command exists
197    let output = Command::new("node")
198        .arg("--version")
199        .stdout(Stdio::piped())
200        .stderr(Stdio::piped())
201        .output()
202        .await
203        .context(
204            "Node.js not found in PATH.\n\
205             \n\
206             Node.js 18+ is required for MCP tool execution.\n\
207             Install from: https://nodejs.org\n\
208             \n\
209             Or use a version manager:\n\
210             - nvm: https://github.com/nvm-sh/nvm\n\
211             - fnm: https://github.com/Schniz/fnm",
212        )?;
213
214    if !output.status.success() {
215        anyhow::bail!("Node.js is installed but not working correctly");
216    }
217
218    // Parse version
219    let version_str = String::from_utf8_lossy(&output.stdout);
220    let version_str = version_str.trim().trim_start_matches('v');
221
222    // Extract major version
223    let major_version = version_str
224        .split('.')
225        .next()
226        .and_then(|s| s.parse::<u32>().ok())
227        .context("Failed to parse Node.js version")?;
228
229    if major_version < 18 {
230        anyhow::bail!(
231            "Node.js version {version_str} is too old.\n\
232             \n\
233             Required: Node.js 18.0.0 or higher\n\
234             Current:  Node.js {version_str}\n\
235             \n\
236             Please upgrade Node.js:\n\
237             - Download: https://nodejs.org\n\
238             - Or use nvm: nvm install 18"
239        );
240    }
241
242    Ok(version_str.to_string())
243}
244
245/// Checks for and makes TypeScript files executable (Unix only).
246///
247/// Sets executable permissions (0755) on all .ts files in ~/.claude/servers/
248/// This allows files to be executed with shebang: `./tool.ts`
249///
250/// # Platform Support
251///
252/// - Unix/Linux/macOS: Sets permissions, returns
253///   `(servers_dir_found, files_made_executable, skipped_entries)`
254/// - Windows: No-op, always returns `(false, 0, 0)`
255///
256/// # Errors
257///
258/// Returns error if:
259/// - Home directory cannot be determined
260/// - Permission changes fail
261#[cfg(unix)]
262async fn check_files_executable() -> Result<(bool, usize, usize)> {
263    let servers_dir = get_servers_dir()?;
264    check_files_executable_in(&servers_dir).await
265}
266
267/// Checks for and makes TypeScript files executable (Unix only).
268///
269/// No-op on non-Unix platforms, since file permissions are not checked there.
270#[cfg(not(unix))]
271async fn check_files_executable() -> Result<(bool, usize, usize)> {
272    Ok((false, 0, 0))
273}
274
275/// Recursively walks `servers_dir` and makes every `.ts` file executable
276/// (0755) at any depth, rejecting symlinked entries at every recursion level
277/// rather than following them.
278///
279/// A symlinked entry — a server-id directory, an intermediate subdirectory,
280/// or a `.ts` file, at any depth — is skipped and counted in
281/// `skipped_entries` rather than chmod'd or descended into, since following
282/// one would let a planted symlink redirect a permission change, or a
283/// directory descent, to anywhere the process can reach outside
284/// `servers_dir`. Entry kind is checked with
285/// [`std::fs::DirEntry::file_type`] (via its `tokio` equivalent), which —
286/// like `symlink_metadata` — does not traverse symlinks, so the check itself
287/// cannot be tricked into following the entry it's inspecting. Because a
288/// symlinked directory is therefore never descended into, the walk cannot
289/// cycle back to an ancestor through a symlink; ordinary (non-symlink)
290/// directories cannot form cycles on their own.
291///
292/// This is a check against pre-existing state, not a concurrency guarantee:
293/// it does not defend against a symlink planted by a racing process between
294/// this function's kind check and the subsequent `set_permissions` call (see
295/// `mcp_execution_core::confinement`'s equivalent TOCTOU note). A hardlink
296/// inside `servers_dir` pointing at a file outside it is also indistinguishable
297/// from a regular file by `file_type` and is not defended against; both are
298/// accepted as out of scope.
299///
300/// # Errors
301///
302/// Returns an error if `servers_dir` cannot be canonicalized, the root
303/// directory itself cannot be opened for reading, an entry's file type
304/// cannot be determined, or a `.ts` file's permissions cannot be read or
305/// changed. A root-open failure is fatal (there are no sibling directories
306/// to protect at the root, so tolerating it would only hide a real error).
307/// Below the root, a directory that fails to open, or a read error
308/// encountered mid-iteration over any directory's entries (root included),
309/// is not propagated: it is logged as a warning and the walk moves on (to
310/// the next sibling entry, or simply stops if the affected directory is the
311/// walk's root) rather than aborting the whole `setup` run.
312#[cfg(unix)]
313async fn check_files_executable_in(servers_dir: &Path) -> Result<(bool, usize, usize)> {
314    use tokio::fs;
315
316    // Check if servers directory exists
317    if !servers_dir.exists() {
318        return Ok((false, 0, 0));
319    }
320
321    let root = fs::canonicalize(servers_dir).await?;
322    // The root's own read_dir failure is fatal: unlike a nested server-id
323    // directory, there are no siblings at the root to protect by tolerating
324    // it, so propagating is the only way the caller learns setup didn't run.
325    let entries = fs::read_dir(&root).await?;
326
327    let mut count = 0;
328    let mut skipped = 0;
329    walk_entries(&root, entries, &mut count, &mut skipped).await?;
330
331    Ok((true, count, skipped))
332}
333
334/// Recursion step for [`check_files_executable_in`]: opens `dir` — skipping
335/// it with a warning rather than propagating if it cannot be opened, since
336/// (unlike the walk's root) it always has siblings whose processing should
337/// continue — then delegates to [`walk_entries`] for the shared per-entry
338/// logic.
339#[cfg(unix)]
340async fn walk_and_chmod(dir: &Path, count: &mut usize, skipped: &mut usize) -> Result<()> {
341    use tokio::fs;
342
343    let entries = match fs::read_dir(dir).await {
344        Ok(entries) => entries,
345        Err(error) => {
346            tracing::warn!(
347                path = %sanitize_path_for_error(dir),
348                %error,
349                "skipping unreadable directory under the servers directory"
350            );
351            return Ok(());
352        }
353    };
354
355    walk_entries(dir, entries, count, skipped).await
356}
357
358/// Shared entry-processing loop for [`check_files_executable_in`]'s root
359/// call and [`walk_and_chmod`]'s recursive calls: chmod's `.ts` files,
360/// skips symlinks, and recurses into subdirectories. See
361/// [`check_files_executable_in`]'s docs for the full symlink-rejection and
362/// error-tolerance rationale, which applies here unchanged at every
363/// recursion depth — including a `next_entry()` read error mid-iteration,
364/// which is always skip-and-warn (only the initial directory open has
365/// different fatality between the root and its descendants).
366#[cfg(unix)]
367async fn walk_entries(
368    dir: &Path,
369    mut entries: tokio::fs::ReadDir,
370    count: &mut usize,
371    skipped: &mut usize,
372) -> Result<()> {
373    use std::os::unix::fs::PermissionsExt;
374    use tokio::fs;
375
376    loop {
377        let entry = match entries.next_entry().await {
378            Ok(Some(entry)) => entry,
379            Ok(None) => break,
380            Err(error) => {
381                tracing::warn!(
382                    path = %sanitize_path_for_error(dir),
383                    %error,
384                    "stopping directory read after error; skipping any remaining entries"
385                );
386                break;
387            }
388        };
389
390        let path = entry.path();
391        let file_type = entry.file_type().await?;
392        if file_type.is_symlink() {
393            tracing::warn!(
394                path = %sanitize_path_for_error(&path),
395                "skipping symlinked entry under the servers directory"
396            );
397            *skipped += 1;
398            continue;
399        }
400
401        if file_type.is_dir() {
402            Box::pin(walk_and_chmod(&path, count, skipped)).await?;
403            continue;
404        }
405
406        if !file_type.is_file() || path.extension().and_then(|s| s.to_str()) != Some("ts") {
407            continue;
408        }
409
410        let metadata = fs::metadata(&path).await?;
411        let mut perms = metadata.permissions();
412        perms.set_mode(0o755); // rwxr-xr-x
413        fs::set_permissions(&path, perms).await?;
414        *count += 1;
415    }
416
417    Ok(())
418}
419
420/// Gets the path to ~/.claude/mcp.json
421fn get_mcp_config_path() -> Result<PathBuf> {
422    let home = dirs::home_dir().context("Failed to get home directory")?;
423    Ok(home.join(".claude").join("mcp.json"))
424}
425
426/// Gets the path to ~/.claude/servers/
427fn get_servers_dir() -> Result<PathBuf> {
428    let home = dirs::home_dir().context("Failed to get home directory")?;
429    Ok(home.join(".claude").join("servers"))
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[tokio::test]
437    async fn test_check_node_version() {
438        // This test will pass if Node.js 18+ is installed
439        // Otherwise it will fail, which is the expected behavior
440        let result = check_node_version().await;
441
442        // We can't assert success because Node.js might not be installed
443        // in CI environment, but we can verify error messages are helpful
444        if let Err(e) = result {
445            let error_msg = e.to_string();
446            assert!(
447                error_msg.contains("Node.js") || error_msg.contains("version"),
448                "Error message should be helpful: {error_msg}"
449            );
450        }
451    }
452
453    #[test]
454    fn test_get_mcp_config_path() {
455        let path = get_mcp_config_path();
456        assert!(path.is_ok());
457
458        let path = path.unwrap();
459        assert!(path.to_string_lossy().contains(".claude"));
460        assert!(path.to_string_lossy().contains("mcp.json"));
461    }
462
463    #[test]
464    fn test_get_servers_dir() {
465        let path = get_servers_dir();
466        assert!(path.is_ok());
467
468        let path = path.unwrap();
469        assert!(path.to_string_lossy().contains(".claude"));
470        assert!(path.to_string_lossy().contains("servers"));
471    }
472
473    #[tokio::test]
474    async fn test_check_files_executable_no_panic() {
475        // Should not panic regardless of whether ~/.claude/servers exists.
476        let result = check_files_executable().await;
477        assert!(result.is_ok());
478    }
479
480    #[cfg(unix)]
481    #[tokio::test]
482    async fn check_files_executable_in_makes_real_ts_files_executable() {
483        use std::os::unix::fs::PermissionsExt;
484
485        let servers_dir = tempfile::TempDir::new().unwrap();
486        let my_server_dir = servers_dir.path().join("my-server");
487        tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
488        let tool_path = my_server_dir.join("tool.ts");
489        tokio::fs::write(&tool_path, "// tool").await.unwrap();
490
491        let (servers_dir_found, files_made_executable, skipped_entries) =
492            check_files_executable_in(servers_dir.path()).await.unwrap();
493
494        assert!(servers_dir_found);
495        assert_eq!(files_made_executable, 1);
496        assert_eq!(skipped_entries, 0);
497        let mode = tokio::fs::metadata(&tool_path)
498            .await
499            .unwrap()
500            .permissions()
501            .mode();
502        assert_eq!(mode & 0o777, 0o755);
503    }
504
505    #[cfg(unix)]
506    #[tokio::test]
507    async fn check_files_executable_in_recurses_into_nested_subdirectories() {
508        use std::os::unix::fs::PermissionsExt;
509
510        let servers_dir = tempfile::TempDir::new().unwrap();
511        let runtime_dir = servers_dir.path().join("my-server").join("_runtime");
512        tokio::fs::create_dir_all(&runtime_dir).await.unwrap();
513        let bridge_path = runtime_dir.join("mcp-bridge.ts");
514        tokio::fs::write(&bridge_path, "// bridge").await.unwrap();
515
516        let (servers_dir_found, files_made_executable, skipped_entries) =
517            check_files_executable_in(servers_dir.path()).await.unwrap();
518
519        assert!(servers_dir_found);
520        assert_eq!(files_made_executable, 1);
521        assert_eq!(skipped_entries, 0);
522        let mode = tokio::fs::metadata(&bridge_path)
523            .await
524            .unwrap()
525            .permissions()
526            .mode();
527        assert_eq!(mode & 0o777, 0o755);
528    }
529
530    // Note: this and the sibling `read_dir`-open-failure tests below do not exercise
531    // `walk_entries`'s mid-iteration `next_entry()` `Err` branch (setup.rs's skip-and-warn
532    // path for a read error *after* a directory has already opened successfully) — that
533    // requires deterministic fault injection with no seam this test suite has today, and is
534    // left untested (see #490's handoff notes).
535    #[cfg(unix)]
536    #[tokio::test]
537    async fn check_files_executable_in_skips_unreadable_nested_dir_processes_siblings() {
538        use std::os::unix::fs::PermissionsExt;
539
540        let servers_dir = tempfile::TempDir::new().unwrap();
541
542        let good_server_dir = servers_dir.path().join("good-server");
543        tokio::fs::create_dir_all(&good_server_dir).await.unwrap();
544        let good_tool_path = good_server_dir.join("tool.ts");
545        tokio::fs::write(&good_tool_path, "// tool").await.unwrap();
546
547        let locked_server_dir = servers_dir.path().join("locked-server");
548        tokio::fs::create_dir_all(&locked_server_dir).await.unwrap();
549        tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o000))
550            .await
551            .unwrap();
552
553        // Root (and some CI runners) bypass directory permission checks, in which case the
554        // property under test does not hold; restore permissions and skip.
555        if tokio::fs::read_dir(&locked_server_dir).await.is_ok() {
556            tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
557                .await
558                .unwrap();
559            return;
560        }
561
562        let result = check_files_executable_in(servers_dir.path()).await;
563
564        // Restore permissions unconditionally so `TempDir`'s drop can clean up.
565        tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
566            .await
567            .unwrap();
568
569        let (servers_dir_found, files_made_executable, skipped_entries) = result.unwrap();
570
571        assert!(servers_dir_found);
572        assert_eq!(
573            files_made_executable, 1,
574            "the healthy sibling directory must still be processed"
575        );
576        assert_eq!(skipped_entries, 0);
577        let mode = tokio::fs::metadata(&good_tool_path)
578            .await
579            .unwrap()
580            .permissions()
581            .mode();
582        assert_eq!(mode & 0o777, 0o755);
583    }
584
585    #[cfg(unix)]
586    #[tokio::test]
587    async fn check_files_executable_in_propagates_root_read_dir_failure() {
588        use std::os::unix::fs::PermissionsExt;
589
590        let servers_dir = tempfile::TempDir::new().unwrap();
591        tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o000))
592            .await
593            .unwrap();
594
595        // Root (and some CI runners) bypass directory permission checks, in which case the
596        // property under test does not hold; restore permissions and skip.
597        if tokio::fs::read_dir(servers_dir.path()).await.is_ok() {
598            tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
599                .await
600                .unwrap();
601            return;
602        }
603
604        let result = check_files_executable_in(servers_dir.path()).await;
605
606        // Restore permissions unconditionally so `TempDir`'s drop can clean up.
607        tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
608            .await
609            .unwrap();
610
611        assert!(
612            result.is_err(),
613            "an unreadable servers directory root must propagate an error, not report success"
614        );
615    }
616
617    #[cfg(unix)]
618    #[tokio::test]
619    async fn check_files_executable_in_skips_symlinked_nested_subdirectory() {
620        let servers_dir = tempfile::TempDir::new().unwrap();
621        let outside = tempfile::TempDir::new().unwrap();
622        let target_path = outside.path().join("target.ts");
623        tokio::fs::write(&target_path, "// outside").await.unwrap();
624
625        let my_server_dir = servers_dir.path().join("my-server");
626        tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
627        std::os::unix::fs::symlink(outside.path(), my_server_dir.join("_runtime")).unwrap();
628
629        let (servers_dir_found, files_made_executable, skipped_entries) =
630            check_files_executable_in(servers_dir.path()).await.unwrap();
631
632        assert!(servers_dir_found);
633        assert_eq!(files_made_executable, 0);
634        assert_eq!(skipped_entries, 1);
635        let mode = std::os::unix::fs::PermissionsExt::mode(
636            &tokio::fs::metadata(&target_path)
637                .await
638                .unwrap()
639                .permissions(),
640        );
641        assert_eq!(
642            mode & 0o111,
643            0,
644            "symlinked nested directory's target must not be descended into"
645        );
646    }
647
648    #[cfg(unix)]
649    #[tokio::test]
650    async fn check_files_executable_in_skips_symlinked_server_dir() {
651        let servers_dir = tempfile::TempDir::new().unwrap();
652        let outside = tempfile::TempDir::new().unwrap();
653        let target_path = outside.path().join("target.ts");
654        tokio::fs::write(&target_path, "// outside").await.unwrap();
655        std::os::unix::fs::symlink(outside.path(), servers_dir.path().join("evil-server")).unwrap();
656
657        let (servers_dir_found, files_made_executable, skipped_entries) =
658            check_files_executable_in(servers_dir.path()).await.unwrap();
659
660        assert!(servers_dir_found);
661        assert_eq!(files_made_executable, 0);
662        assert_eq!(skipped_entries, 1);
663        let mode = std::os::unix::fs::PermissionsExt::mode(
664            &tokio::fs::metadata(&target_path)
665                .await
666                .unwrap()
667                .permissions(),
668        );
669        assert_eq!(
670            mode & 0o111,
671            0,
672            "symlinked target must not become executable"
673        );
674    }
675
676    #[cfg(unix)]
677    #[tokio::test]
678    async fn check_files_executable_in_skips_symlinked_ts_file() {
679        let servers_dir = tempfile::TempDir::new().unwrap();
680        let outside = tempfile::TempDir::new().unwrap();
681        let target_path = outside.path().join("target.ts");
682        tokio::fs::write(&target_path, "// outside").await.unwrap();
683
684        let legit_server_dir = servers_dir.path().join("legit-server");
685        tokio::fs::create_dir_all(&legit_server_dir).await.unwrap();
686        std::os::unix::fs::symlink(&target_path, legit_server_dir.join("link.ts")).unwrap();
687
688        let (servers_dir_found, files_made_executable, skipped_entries) =
689            check_files_executable_in(servers_dir.path()).await.unwrap();
690
691        assert!(servers_dir_found);
692        assert_eq!(files_made_executable, 0);
693        assert_eq!(skipped_entries, 1);
694        let mode = std::os::unix::fs::PermissionsExt::mode(
695            &tokio::fs::metadata(&target_path)
696                .await
697                .unwrap()
698                .permissions(),
699        );
700        assert_eq!(
701            mode & 0o111,
702            0,
703            "symlinked target must not become executable"
704        );
705    }
706
707    #[test]
708    fn test_setup_result_serialization() {
709        let result = SetupResult {
710            node_version: "20.10.0".to_string(),
711            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
712            mcp_config_found: true,
713            servers_dir_found: true,
714            files_made_executable: 3,
715            skipped_entries: 0,
716        };
717
718        let json = serde_json::to_string(&result).unwrap();
719        assert!(json.contains("\"node_version\":\"20.10.0\""));
720        assert!(json.contains("\"mcp_config_found\":true"));
721        assert!(json.contains("\"files_made_executable\":3"));
722    }
723
724    #[test]
725    fn test_setup_result_format_output_json() {
726        let result = SetupResult {
727            node_version: "20.10.0".to_string(),
728            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
729            mcp_config_found: false,
730            servers_dir_found: true,
731            files_made_executable: 7,
732            skipped_entries: 0,
733        };
734
735        let formatted =
736            crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Json)
737                .unwrap();
738        assert!(formatted.contains("\"node_version\": \"20.10.0\""));
739        assert!(formatted.contains("\"mcp_config_path\": \"/home/user/.claude/mcp.json\""));
740        assert!(formatted.contains("\"mcp_config_found\": false"));
741        assert!(formatted.contains("\"servers_dir_found\": true"));
742        assert!(formatted.contains("\"files_made_executable\": 7"));
743    }
744
745    #[test]
746    fn test_setup_result_format_output_text() {
747        let result = SetupResult {
748            node_version: "20.10.0".to_string(),
749            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
750            mcp_config_found: true,
751            servers_dir_found: false,
752            files_made_executable: 0,
753            skipped_entries: 0,
754        };
755
756        let formatted =
757            crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Text)
758                .unwrap();
759        // Text format is compact JSON (no newlines), unlike the pretty-printed
760        // Json format checked above.
761        assert!(!formatted.contains('\n'));
762        assert!(formatted.contains("\"node_version\":\"20.10.0\""));
763        assert!(formatted.contains("\"mcp_config_found\":true"));
764        assert!(formatted.contains("\"servers_dir_found\":false"));
765        assert!(formatted.contains("\"files_made_executable\":0"));
766    }
767}