-
Notifications
You must be signed in to change notification settings - Fork 28
Add Command Line Tools detection #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||
| } | ||||||
| } | ||||||
| } 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) | ||||||
|
||||||
| public static string? ParsePkgutilVersion (string pkgutilOutput) | |
| public static string? ParsePkgutilVersion (string? pkgutilOutput) |
| 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")); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
xcode-select --print-pathreturns 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/CommandLineToolsalso doesn't exist). Consider logging the exit code and/or stderr whenexitCode != 0(similar to howXcodeLocator.TryGetSystemXcodelogs failures).