fix: make copilot CLI path resolution cross-platform

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 23:44:49 +01:00
co-authored by Copilot
parent f94c3232b4
commit d88d00df0d
2 changed files with 62 additions and 6 deletions
@@ -8,6 +8,10 @@ internal static class CopilotCliPathResolver
private const string CopilotCommandName = "copilot"; private const string CopilotCommandName = "copilot";
private const string DefaultWindowsCommandProcessor = "cmd.exe"; private const string DefaultWindowsCommandProcessor = "cmd.exe";
private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD";
private const char WindowsSearchPathSeparator = ';';
private const char UnixSearchPathSeparator = ':';
private const char WindowsDirectorySeparator = '\\';
private const char UnixDirectorySeparator = '/';
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"]; private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
@@ -125,7 +129,7 @@ internal static class CopilotCliPathResolver
{ {
foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows)) foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows))
{ {
string candidatePath = Path.Combine(directory, candidateName); string candidatePath = CombineSearchPath(directory, candidateName, isWindows);
if (fileExists(candidatePath)) if (fileExists(candidatePath))
{ {
return candidatePath; return candidatePath;
@@ -145,7 +149,7 @@ internal static class CopilotCliPathResolver
StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
foreach (string directory in pathValue foreach (string directory in pathValue
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(GetSearchPathSeparator(isWindows), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(segment => segment.Trim('"')) .Select(segment => segment.Trim('"'))
.Where(segment => !string.IsNullOrWhiteSpace(segment)) .Where(segment => !string.IsNullOrWhiteSpace(segment))
.Distinct(comparer)) .Distinct(comparer))
@@ -184,6 +188,38 @@ internal static class CopilotCliPathResolver
} }
} }
private static char GetSearchPathSeparator(bool isWindows)
{
return isWindows ? WindowsSearchPathSeparator : UnixSearchPathSeparator;
}
private static string CombineSearchPath(string directory, string fileName, bool isWindows)
{
if (string.IsNullOrEmpty(directory))
{
return fileName;
}
if (EndsWithDirectorySeparator(directory, isWindows))
{
return directory + fileName;
}
return directory + GetDirectorySeparator(isWindows) + fileName;
}
private static bool EndsWithDirectorySeparator(string path, bool isWindows)
{
char lastCharacter = path[^1];
return lastCharacter == GetDirectorySeparator(isWindows)
|| (isWindows && lastCharacter == UnixDirectorySeparator);
}
private static char GetDirectorySeparator(bool isWindows)
{
return isWindows ? WindowsDirectorySeparator : UnixDirectorySeparator;
}
private static IEnumerable<KeyValuePair<string, string?>> GetCurrentEnvironmentVariables() private static IEnumerable<KeyValuePair<string, string?>> GetCurrentEnvironmentVariables()
{ {
return Environment.GetEnvironmentVariables() return Environment.GetEnvironmentVariables()
@@ -8,9 +8,10 @@ public sealed class CopilotCliPathResolverTests
public void Resolve_UsesCopilotFromPath() public void Resolve_UsesCopilotFromPath()
{ {
string copilotDirectory = @"C:\tools\copilot"; string copilotDirectory = @"C:\tools\copilot";
string expectedCliPath = @"C:\tools\copilot\copilot.exe";
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase) HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
{ {
Path.Combine(copilotDirectory, "copilot.exe"), expectedCliPath,
}; };
string? cliPath = CopilotCliPathResolver.Resolve( string? cliPath = CopilotCliPathResolver.Resolve(
@@ -19,16 +20,17 @@ public sealed class CopilotCliPathResolverTests
isWindows: true, isWindows: true,
fileExists: existingFiles.Contains); fileExists: existingFiles.Contains);
Assert.Equal(Path.Combine(copilotDirectory, "copilot.exe"), cliPath, ignoreCase: true); Assert.Equal(expectedCliPath, cliPath, ignoreCase: true);
} }
[Fact] [Fact]
public void Resolve_UsesDefaultWindowsExtensionsWhenPathExtMissing() public void Resolve_UsesDefaultWindowsExtensionsWhenPathExtMissing()
{ {
string copilotDirectory = @"C:\tools\copilot"; string copilotDirectory = @"C:\tools\copilot";
string expectedCliPath = @"C:\tools\copilot\copilot.cmd";
HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase) HashSet<string> existingFiles = new(StringComparer.OrdinalIgnoreCase)
{ {
Path.Combine(copilotDirectory, "copilot.cmd"), expectedCliPath,
}; };
string? cliPath = CopilotCliPathResolver.Resolve( string? cliPath = CopilotCliPathResolver.Resolve(
@@ -37,7 +39,25 @@ public sealed class CopilotCliPathResolverTests
isWindows: true, isWindows: true,
fileExists: existingFiles.Contains); fileExists: existingFiles.Contains);
Assert.Equal(Path.Combine(copilotDirectory, "copilot.cmd"), cliPath, ignoreCase: true); Assert.Equal(expectedCliPath, cliPath, ignoreCase: true);
}
[Fact]
public void Resolve_UsesCopilotFromPathOutsideWindows()
{
const string expectedCliPath = "/usr/local/bin/copilot";
HashSet<string> existingFiles = new(StringComparer.Ordinal)
{
expectedCliPath,
};
string? cliPath = CopilotCliPathResolver.Resolve(
pathValue: "/usr/bin:/usr/local/bin",
pathExtValue: null,
isWindows: false,
fileExists: existingFiles.Contains);
Assert.Equal(expectedCliPath, cliPath);
} }
[Fact] [Fact]