Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
134 changes: 134 additions & 0 deletions Xamarin.MacDev/CommandLineTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.IO;

using Xamarin.MacDev.Models;

#nullable enable

namespace Xamarin.MacDev {

/// <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";

readonly ICustomLogger log;

public CommandLineTools (ICustomLogger log)
{
this.log = log ?? throw new ArgumentNullException (nameof (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;
}

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);
}

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;
}
Comment on lines +74 to +80
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

When xcode-select --print-path returns a non-zero exit code, this method silently falls back to the default path without any diagnostic logging. This can make it hard to understand why CLT detection failed (especially if /Library/Developer/CommandLineTools also doesn't exist). Consider logging the exit code and/or stderr when exitCode != 0 (similar to how XcodeLocator.TryGetSystemXcode logs failures).

Suggested change
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;
}
var (exitCode, stdout, stderr) = 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;
}
} else {
if (!string.IsNullOrWhiteSpace (stderr)) {
log.LogInfo ("xcode-select --print-path failed with exit code {0}: {1}", exitCode, stderr.Trim ());
} else {
log.LogInfo ("xcode-select --print-path failed with exit code {0}.", exitCode);
}

Copilot uses AI. Check for mistakes.
}
} 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;
}

/// <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;

return ParsePkgutilVersion (stdout);
} catch (System.ComponentModel.Win32Exception 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)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

ParsePkgutilVersion treats null/empty output as "no version" (and the tests also pass null), but the parameter is declared non-nullable (string). This makes the public API contract inconsistent with the implementation and can cause nullable-analysis warnings for callers. Consider changing the parameter type to string? (or, if null should be invalid, remove the null-handling and throw).

Suggested change
public static string? ParsePkgutilVersion (string pkgutilOutput)
public static string? ParsePkgutilVersion (string? pkgutilOutput)

Copilot uses AI. Check for mistakes.
{
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;
}
}
}
71 changes: 71 additions & 0 deletions tests/CommandLineToolsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using NUnit.Framework;

using Xamarin.MacDev;

namespace Tests {

[TestFixture]
public class CommandLineToolsTests {

[Test]
public void ParsePkgutilVersion_ReturnsVersion ()
{
var output = @"package-id: com.apple.pkg.CLTools_Executables
version: 16.2.0.0.1.1733547573
volume: /
location: /
install-time: 1733547600
";
var version = CommandLineTools.ParsePkgutilVersion (output);
Assert.That (version, Is.EqualTo ("16.2.0.0.1.1733547573"));
}

[Test]
public void ParsePkgutilVersion_HandlesWhitespace ()
{
var output = " version: 26.2.0.0.1.1764812424 \nvolume: /\n";
var version = CommandLineTools.ParsePkgutilVersion (output);
Assert.That (version, Is.EqualTo ("26.2.0.0.1.1764812424"));
}

[Test]
public void ParsePkgutilVersion_ReturnsNullForEmptyInput ()
{
Assert.That (CommandLineTools.ParsePkgutilVersion (""), Is.Null);
Assert.That (CommandLineTools.ParsePkgutilVersion ((string) null), Is.Null);
}

[Test]
public void ParsePkgutilVersion_ReturnsNullWhenNoVersionLine ()
{
var output = "package-id: com.apple.pkg.CLTools_Executables\nvolume: /\n";
Assert.That (CommandLineTools.ParsePkgutilVersion (output), Is.Null);
}

[Test]
public void ParsePkgutilVersion_ReturnsNullForEmptyVersion ()
{
var output = "version: \nvolume: /\n";
Assert.That (CommandLineTools.ParsePkgutilVersion (output), Is.Null);
}

[Test]
public void ParsePkgutilVersion_HandlesWindowsLineEndings ()
{
var output = "package-id: com.apple.pkg.CLTools_Executables\r\nversion: 15.1.0.0.1.1700000000\r\nvolume: /\r\n";
var version = CommandLineTools.ParsePkgutilVersion (output);
Assert.That (version, Is.EqualTo ("15.1.0.0.1.1700000000"));
}

[Test]
public void ParsePkgutilVersion_IgnoresVersionSubstringsInOtherFields ()
{
var output = "package-id: com.apple.pkg.CLTools_Executables\nlocation: /version:/fake\nversion: 16.0.0.0.1\n";
var version = CommandLineTools.ParsePkgutilVersion (output);
Assert.That (version, Is.EqualTo ("16.0.0.0.1"));
}
}
}