Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 97 additions & 101 deletions Xamarin.MacDev/CommandLineTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,132 +3,128 @@

using System;
using System.IO;

using Xamarin.MacDev.Models;

#nullable enable

namespace Xamarin.MacDev {
namespace Xamarin.MacDev;
Comment thread
rmarinho marked this conversation as resolved.
Outdated

/// <summary>
/// Detects and reports on the Xcode Command Line Tools installation.
/// Follows the same instance-based, ICustomLogger pattern as XcodeLocator.
/// </summary>
public class CommandLineTools {
/// <summary>
/// Detects and reports on the Xcode Command Line Tools installation.
/// Follows the same instance-based, ICustomLogger pattern as XcodeLocator.
/// </summary>
public class CommandLineTools {

static readonly string XcodeSelectPath = "/usr/bin/xcode-select";
static readonly string PkgutilPath = "/usr/bin/pkgutil";
static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables";
static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools";
static readonly string XcodeSelectPath = "/usr/bin/xcode-select";
static readonly string PkgutilPath = "/usr/bin/pkgutil";
static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables";
static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools";

readonly ICustomLogger log;

public CommandLineTools (ICustomLogger log)
{
this.log = log ?? throw new ArgumentNullException (nameof (log));
}
readonly ICustomLogger log;

/// <summary>
/// Checks whether the Xcode Command Line Tools are installed and returns their info.
/// </summary>
public CommandLineToolsInfo Check ()
{
var info = new CommandLineToolsInfo ();

// First check if the CLT directory exists
var cltPath = GetCommandLineToolsPath ();
if (cltPath is null) {
log.LogInfo ("Command Line Tools are not installed (path not found).");
return info;
}
public CommandLineTools (ICustomLogger log)
{
this.log = log ?? throw new ArgumentNullException (nameof (log));
}

info.Path = cltPath;

// Get version from pkgutil
var version = GetVersionFromPkgutil ();
if (version is not null) {
info.Version = version;
info.IsInstalled = true;
log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath);
} else {
// Directory exists but pkgutil doesn't report it — partial install
info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin"));
if (info.IsInstalled)
log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath);
else
log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath);
}
/// <summary>
/// Checks whether the Xcode Command Line Tools are installed and returns their info.
/// </summary>
public CommandLineToolsInfo Check ()
{
var info = new CommandLineToolsInfo ();

var cltPath = GetCommandLineToolsPath ();
if (cltPath is null) {
log.LogInfo ("Command Line Tools are not installed (path not found).");
return info;
}

/// <summary>
/// Returns the Command Line Tools install path, or null if not found.
/// Uses xcode-select -p first, falls back to the well-known default path.
/// </summary>
string? GetCommandLineToolsPath ()
{
// Try xcode-select -p — if it returns a CLT path (not Xcode), use it
if (File.Exists (XcodeSelectPath)) {
try {
var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path");
if (exitCode == 0) {
var path = stdout.Trim ();
if (path.Contains ("CommandLineTools") && Directory.Exists (path)) {
// xcode-select points to CLT (e.g. /Library/Developer/CommandLineTools)
return path;
}
}
} catch (System.ComponentModel.Win32Exception ex) {
log.LogInfo ("Could not run xcode-select: {0}", ex.Message);
}
}

// Fall back to the default well-known path
if (Directory.Exists (DefaultCltPath))
return DefaultCltPath;

return null;
info.Path = cltPath;

var version = GetVersionFromPkgutil ();
if (version is not null) {
info.Version = version;
info.IsInstalled = true;
log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath);
} else {
info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin"));
if (info.IsInstalled)
log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath);
else
log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath);
}

/// <summary>
/// Queries pkgutil for the CLT package version.
/// Returns the version string or null if not installed.
/// </summary>
internal string? GetVersionFromPkgutil ()
{
if (!File.Exists (PkgutilPath))
return null;
return info;
}

/// <summary>
/// Returns the Command Line Tools install path, or null if not found.
/// Uses xcode-select -p first, falls back to the well-known default path.
/// </summary>
string? GetCommandLineToolsPath ()
{
if (File.Exists (XcodeSelectPath)) {
try {
var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId);
if (exitCode != 0)
return null;

return ParsePkgutilVersion (stdout);
var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path");
if (exitCode == 0) {
var path = stdout.Trim ();
if (path.Contains ("CommandLineTools") && Directory.Exists (path))
Comment thread
rmarinho marked this conversation as resolved.
Outdated
return path;
}
} catch (System.ComponentModel.Win32Exception ex) {
log.LogInfo ("Could not run pkgutil: {0}", ex.Message);
return null;
log.LogInfo ("Could not run xcode-select: {0}", ex.Message);
} catch (InvalidOperationException ex) {
log.LogInfo ("Could not run xcode-select: {0}", ex.Message);
}
}

/// <summary>
/// Parses the "version: ..." line from pkgutil --pkg-info output.
/// </summary>
public static string? ParsePkgutilVersion (string pkgutilOutput)
{
if (string.IsNullOrEmpty (pkgutilOutput))
if (Directory.Exists (DefaultCltPath))
return DefaultCltPath;

return null;
}

/// <summary>
/// Queries pkgutil for the CLT package version.
/// Returns the version string or null if not installed.
/// </summary>
internal string? GetVersionFromPkgutil ()
{
if (!File.Exists (PkgutilPath))
return null;

try {
var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId);
if (exitCode != 0)
return null;

foreach (var rawLine in pkgutilOutput.Split ('\n')) {
var line = rawLine.Trim ();
if (line.StartsWith ("version:", StringComparison.Ordinal)) {
var version = line.Substring ("version:".Length).Trim ();
return string.IsNullOrEmpty (version) ? null : version;
}
}
return ParsePkgutilVersion (stdout);
} catch (System.ComponentModel.Win32Exception ex) {
log.LogInfo ("Could not run pkgutil: {0}", ex.Message);
return null;
} catch (InvalidOperationException ex) {
log.LogInfo ("Could not run pkgutil: {0}", ex.Message);
return null;
}
}

/// <summary>
/// Parses the "version: ..." line from pkgutil --pkg-info output.
/// </summary>
public static string? ParsePkgutilVersion (string pkgutilOutput)
{
if (string.IsNullOrEmpty (pkgutilOutput))
return null;

foreach (var rawLine in pkgutilOutput.Split ('\n')) {
var line = rawLine.Trim ();
if (line.StartsWith ("version:", StringComparison.Ordinal)) {
var version = line.Substring ("version:".Length).Trim ();
return string.IsNullOrEmpty (version) ? null : version;
}
}

return null;
}
}
156 changes: 156 additions & 0 deletions Xamarin.MacDev/EnvironmentChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
Comment thread
rmarinho marked this conversation as resolved.
Outdated
using Xamarin.MacDev.Models;

#nullable enable

namespace Xamarin.MacDev;

/// <summary>
/// Performs a comprehensive check of the Apple development environment.
/// Aggregates results from <see cref="CommandLineTools"/>,
/// <see cref="XcodeManager"/>, and <see cref="RuntimeService"/>.
/// </summary>
public class EnvironmentChecker {

static readonly string XcrunPath = "/usr/bin/xcrun";

readonly ICustomLogger log;

public EnvironmentChecker (ICustomLogger log)
{
this.log = log ?? throw new ArgumentNullException (nameof (log));
}

/// <summary>
/// Runs a full environment check and returns the results.
/// </summary>
public EnvironmentCheckResult Check ()
{
var result = new EnvironmentCheckResult ();

var xcodeManager = new XcodeManager (log);
var xcode = xcodeManager.GetBest ();

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only MapPlatformName has tests right now, but this PR introduces additional new behavior in Check() (aggregation + status derivation), GetPlatforms() (platform discovery from Xcode bundle), and the license/first-launch helpers. Please add unit tests for the new logic, or refactor the directory/process-dependent pieces behind small testable helpers so the behavior can be validated without invoking external tools.

Copilot generated this review using guidance from repository custom instructions.
result.Xcode = xcode;

if (xcode is not null) {
log.LogInfo ("Xcode {0} found at '{1}'.", xcode.Version, xcode.Path);

if (IsXcodeLicenseAccepted ())
log.LogInfo ("Xcode license is accepted.");
else
log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'.");

result.Platforms = GetPlatforms (xcode.Path);
} else {
log.LogInfo ("No Xcode installation found.");
}

var clt = new CommandLineTools (log);
result.CommandLineTools = clt.Check ();

var runtimeService = new RuntimeService (log);
result.Runtimes = runtimeService.List (availableOnly: true);

result.DeriveStatus ();

log.LogInfo ("Environment check complete. Status: {0}.", result.Status);
return result;
}

/// <summary>
/// Checks whether the Xcode license has been accepted by running
/// <c>xcrun xcodebuild -license check</c>.
/// </summary>
public bool IsXcodeLicenseAccepted ()
{
try {
var (exitCode, _, _) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-license", "check");
return exitCode == 0;
} catch (System.ComponentModel.Win32Exception) {
return false;
} catch (InvalidOperationException) {
return false;
}
}

/// <summary>
/// Runs <c>xcrun xcodebuild -runFirstLaunch</c> to ensure packages are installed.
/// Returns true if the command succeeded.
/// </summary>
public bool RunFirstLaunch ()
{
try {
log.LogInfo ("Running xcodebuild -runFirstLaunch...");
var (exitCode, _, stderr) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-runFirstLaunch");
if (exitCode != 0) {
log.LogInfo ("xcodebuild -runFirstLaunch failed (exit {0}): {1}", exitCode, stderr.Trim ());
return false;
}

log.LogInfo ("xcodebuild -runFirstLaunch completed successfully.");
return true;
} catch (System.ComponentModel.Win32Exception ex) {
log.LogInfo ("Could not run xcodebuild: {0}", ex.Message);
return false;
} catch (InvalidOperationException ex) {
log.LogInfo ("Could not run xcodebuild: {0}", ex.Message);
return false;
}
}

/// <summary>
/// Gets the list of available platform SDK directories in the Xcode bundle.
/// </summary>
List<string> GetPlatforms (string xcodePath)
{
var platforms = new List<string> ();
var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms");

if (!Directory.Exists (platformsDir))
return platforms;

try {
foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) {
var name = Path.GetFileNameWithoutExtension (dir);
var friendly = MapPlatformName (name);
if (!platforms.Contains (friendly))
platforms.Add (friendly);
}
} catch (UnauthorizedAccessException ex) {
log.LogInfo ("Could not read platforms directory: {0}", ex.Message);
}

return platforms;
}

/// <summary>
/// Maps Apple platform directory names to friendly names.
/// </summary>
public static string MapPlatformName (string sdkName)
{
switch (sdkName) {
case "iPhoneOS":
case "iPhoneSimulator":
return "iOS";
case "AppleTVOS":
case "AppleTVSimulator":
return "tvOS";
case "WatchOS":
case "WatchSimulator":
return "watchOS";
case "XROS":
case "XRSimulator":
return "visionOS";
case "MacOSX":
return "macOS";
default:
return sdkName;
}
}
}
Loading