diff --git a/Xamarin.MacDev/CommandLineTools.cs b/Xamarin.MacDev/CommandLineTools.cs new file mode 100644 index 0000000..1e00386 --- /dev/null +++ b/Xamarin.MacDev/CommandLineTools.cs @@ -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 { + + /// + /// Detects and reports on the Xcode Command Line Tools installation. + /// Follows the same instance-based, ICustomLogger pattern as XcodeLocator. + /// + 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)); + } + + /// + /// Checks whether the Xcode Command Line Tools are installed and returns their info. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + + /// + /// Queries pkgutil for the CLT package version. + /// Returns the version string or null if not installed. + /// + 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; + } + } + + /// + /// Parses the "version: ..." line from pkgutil --pkg-info output. + /// + 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; + } + } +} diff --git a/tests/CommandLineToolsTests.cs b/tests/CommandLineToolsTests.cs new file mode 100644 index 0000000..c493b80 --- /dev/null +++ b/tests/CommandLineToolsTests.cs @@ -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")); + } + } +}