Skip to content

Commit 89b565a

Browse files
authored
Merge pull request #886 from ldennington/dotnet-tool
release: package gcm as dotnet tool
2 parents 44534db + 715eef1 commit 89b565a

File tree

11 files changed

+345
-79
lines changed

11 files changed

+345
-79
lines changed

.github/workflows/release.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,29 @@ jobs:
600600
uploadDirectoryToRelease('linux-sign'),
601601
uploadDirectoryToRelease('linux-build/tar')
602602
]);
603+
604+
create-dotnet-tool:
605+
name: Publish dotnet tool
606+
runs-on: ubuntu-latest
607+
steps:
608+
- uses: actions/checkout@v3
609+
with:
610+
fetch-depth: 0 # Indicate full history so Nerdbank.GitVersioning works.
611+
612+
- name: Setup .NET
613+
uses: actions/setup-dotnet@v2
614+
with:
615+
dotnet-version: 6.0.201
616+
617+
- uses: dotnet/nbgv@master
618+
with:
619+
setCommonVars: true
620+
621+
- name: Package tool
622+
run: |
623+
src/shared/DotnetTool/pack-tool.sh
624+
625+
- name: Publish tool
626+
run: |
627+
dotnet nuget push ./out/nupkg/*.nupkg \
628+
--api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json

src/shared/Atlassian.Bitbucket.Tests/BitbucketAuthenticationTest.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
113113
{
114114
var targetUri = new Uri("https://bitbucket.org");
115115

116-
var helperPath = "/usr/bin/test-helper";
116+
var command = "/usr/bin/test-helper";
117+
var args = "";
117118
var expectedUserName = "jsquire";
118119
var expectedPassword = "password";
119120
var resultDict = new Dictionary<string, string>
@@ -128,7 +129,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
128129
context.SessionManager.IsDesktopSession = true; // Enable OAuth and UI helper selection
129130

130131
var authMock = new Mock<BitbucketAuthentication>(context) { CallBase = true };
131-
authMock.Setup(x => x.TryFindHelperExecutablePath(out helperPath))
132+
authMock.Setup(x => x.TryFindHelperCommand(out command, out args))
132133
.Returns(true);
133134
authMock.Setup(x => x.InvokeHelperAsync(It.IsAny<string>(), It.IsAny<string>(), null, CancellationToken.None))
134135
.ReturnsAsync(resultDict);
@@ -140,7 +141,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
140141
Assert.Equal(result.Credential.Account, expectedUserName);
141142
Assert.Equal(result.Credential.Password, expectedPassword);
142143

143-
authMock.Verify(x => x.InvokeHelperAsync(helperPath, expectedArgs, null, CancellationToken.None),
144+
authMock.Verify(x => x.InvokeHelperAsync(command, expectedArgs, null, CancellationToken.None),
144145
Times.Once);
145146
}
146147

@@ -149,7 +150,8 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_BasicOnly_User_BBC
149150
{
150151
var targetUri = new Uri("https://bitbucket.org");
151152

152-
var helperPath = "/usr/bin/test-helper";
153+
var command = "/usr/bin/test-helper";
154+
var args = "";
153155
var expectedUserName = "jsquire";
154156
var expectedPassword = "password";
155157
var resultDict = new Dictionary<string, string>
@@ -164,7 +166,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_BasicOnly_User_BBC
164166
context.SessionManager.IsDesktopSession = true; // Enable UI helper selection
165167

166168
var authMock = new Mock<BitbucketAuthentication>(context) { CallBase = true };
167-
authMock.Setup(x => x.TryFindHelperExecutablePath(out helperPath))
169+
authMock.Setup(x => x.TryFindHelperCommand(out command, out args))
168170
.Returns(true);
169171
authMock.Setup(x => x.InvokeHelperAsync(It.IsAny<string>(), It.IsAny<string>(), null, CancellationToken.None))
170172
.ReturnsAsync(resultDict);
@@ -176,7 +178,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_BasicOnly_User_BBC
176178
Assert.Equal(result.Credential.Account, expectedUserName);
177179
Assert.Equal(result.Credential.Password, expectedPassword);
178180

179-
authMock.Verify(x => x.InvokeHelperAsync(helperPath, expectedArgs, null, CancellationToken.None),
181+
authMock.Verify(x => x.InvokeHelperAsync(command, expectedArgs, null, CancellationToken.None),
180182
Times.Once);
181183
}
182184

@@ -185,7 +187,8 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
185187
{
186188
var targetUri = new Uri("https://example.com/bitbucket");
187189

188-
var helperPath = "/usr/bin/test-helper";
190+
var command = "/usr/bin/test-helper";
191+
var args = "";
189192
var expectedUserName = "jsquire";
190193
var expectedPassword = "password";
191194
var resultDict = new Dictionary<string, string>
@@ -200,7 +203,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
200203
context.SessionManager.IsDesktopSession = true; // Enable OAuth and UI helper selection
201204

202205
var authMock = new Mock<BitbucketAuthentication>(context) { CallBase = true };
203-
authMock.Setup(x => x.TryFindHelperExecutablePath(out helperPath))
206+
authMock.Setup(x => x.TryFindHelperCommand(out command, out args))
204207
.Returns(true);
205208
authMock.Setup(x => x.InvokeHelperAsync(It.IsAny<string>(), It.IsAny<string>(), null, CancellationToken.None))
206209
.ReturnsAsync(resultDict);
@@ -212,7 +215,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_AllModes_NoUser_BB
212215
Assert.Equal(result.Credential.Account, expectedUserName);
213216
Assert.Equal(result.Credential.Password, expectedPassword);
214217

215-
authMock.Verify(x => x.InvokeHelperAsync(helperPath, expectedArgs, null, CancellationToken.None),
218+
authMock.Verify(x => x.InvokeHelperAsync(command, expectedArgs, null, CancellationToken.None),
216219
Times.Once);
217220
}
218221
}

src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,30 +93,31 @@ public async Task<CredentialsPromptResult> GetCredentialsAsync(Uri targetUri, st
9393

9494
// Shell out to the UI helper and show the Bitbucket u/p prompt
9595
if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession &&
96-
TryFindHelperExecutablePath(out string helperPath))
96+
TryFindHelperCommand(out string helperCommand, out string args))
9797
{
98-
var cmdArgs = new StringBuilder("prompt");
98+
var promptArgs = new StringBuilder(args);
99+
promptArgs.Append("prompt");
99100
if (!BitbucketHelper.IsBitbucketOrg(targetUri))
100101
{
101-
cmdArgs.AppendFormat(" --url {0}", QuoteCmdArg(targetUri.ToString()));
102+
promptArgs.AppendFormat(" --url {0}", QuoteCmdArg(targetUri.ToString()));
102103
}
103104

104105
if (!string.IsNullOrWhiteSpace(userName))
105106
{
106-
cmdArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName));
107+
promptArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName));
107108
}
108109

109110
if ((modes & AuthenticationModes.Basic) != 0)
110111
{
111-
cmdArgs.Append(" --show-basic");
112+
promptArgs.Append(" --show-basic");
112113
}
113114

114115
if ((modes & AuthenticationModes.OAuth) != 0)
115116
{
116-
cmdArgs.Append(" --show-oauth");
117+
promptArgs.Append(" --show-oauth");
117118
}
118119

119-
IDictionary<string, string> output = await InvokeHelperAsync(helperPath, cmdArgs.ToString());
120+
IDictionary<string, string> output = await InvokeHelperAsync(helperCommand, promptArgs.ToString());
120121

121122
if (output.TryGetValue("mode", out string mode) &&
122123
StringComparer.OrdinalIgnoreCase.Equals(mode, "oauth"))
@@ -223,13 +224,14 @@ public string GetRefreshTokenServiceName(InputArguments input)
223224
return client.GetRefreshTokenServiceName(input);
224225
}
225226

226-
protected internal virtual bool TryFindHelperExecutablePath(out string path)
227+
protected internal virtual bool TryFindHelperCommand(out string command, out string args)
227228
{
228-
return TryFindHelperExecutablePath(
229+
return TryFindHelperCommand(
229230
BitbucketConstants.EnvironmentVariables.AuthenticationHelper,
230231
BitbucketConstants.GitConfiguration.Credential.AuthenticationHelper,
231232
BitbucketConstants.DefaultAuthenticationHelper,
232-
out path);
233+
out command,
234+
out args);
233235
}
234236

235237
private HttpClient _httpClient;

src/shared/Core/Authentication/AuthenticationBase.cs

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -110,58 +110,111 @@ protected void ThrowIfTerminalPromptsDisabled()
110110
}
111111
}
112112

113-
protected bool TryFindHelperExecutablePath(string envar, string configName, string defaultValue, out string path)
113+
protected bool TryFindHelperCommand(string envar, string configName, string defaultValue, out string command, out string args)
114114
{
115-
bool isOverride = false;
115+
command = null;
116+
args = null;
117+
118+
//
119+
// Search for UI helpers with the following precedence and logic..
120+
//
121+
// 1. (unset): use the default helper name that's in the source code and go to #3
122+
// 2. <absolute>: use the absolute path only and exactly as entered
123+
// 3. <relative>: search for..
124+
// a. <appdir>/<relative>(.exe) - run this directly
125+
// b. <appdir>/<relative>(.dll) - use `dotnet exec` to run
126+
// c. Search PATH for <relative>(.exe) - run this directly
127+
// NOTE: do NOT search PATH for <relative>(.dll) as we don't know if this is a dotnet executable..
128+
//
129+
// We print warning messages for missing helpers specified by the user, not the in-box ones.
130+
//
116131
if (Context.Settings.TryGetPathSetting(
117-
envar, Constants.GitConfiguration.Credential.SectionName, configName, out string helperName))
132+
envar, Constants.GitConfiguration.Credential.SectionName, configName, out string helperName))
118133
{
134+
// If the user set the helper override to the empty string then they are signalling not to use a helper
135+
if (string.IsNullOrEmpty(helperName))
136+
{
137+
Context.Trace.WriteLine("UI helper override specified as the empty string.");
138+
return false;
139+
}
140+
119141
Context.Trace.WriteLine($"UI helper override specified: '{helperName}'.");
120-
isOverride = true;
121142
}
122143
else
123144
{
124-
// Use the default helper if none was specified.
125-
// On Windows append ".exe" for the default helpers only. If a user has specified their own
126-
// helper they should append the correct extension.
127-
helperName = PlatformUtils.IsWindows() ? $"{defaultValue}.exe" : defaultValue;
145+
Context.Trace.WriteLine($"Using default UI helper: '{defaultValue}'.");
146+
helperName = defaultValue;
128147
}
129148

130-
// If the user set the helper override to the empty string then they are signalling not to use a helper
131-
if (string.IsNullOrEmpty(helperName))
149+
//
150+
// Check for an absolute path.. run this directly without intermediaries or modification
151+
//
152+
if (Path.IsPathRooted(helperName))
132153
{
133-
path = null;
154+
if (Context.FileSystem.FileExists(helperName))
155+
{
156+
Context.Trace.WriteLine($"UI helper found at '{helperName}'.");
157+
command = helperName;
158+
return true;
159+
}
160+
161+
Context.Trace.WriteLine($"UI helper was not found at '{helperName}'.");
162+
Context.Streams.Error.WriteLine($"warning: could not find configured UI helper '{helperName}'");
134163
return false;
135164
}
136165

137-
if (Path.IsPathRooted(helperName))
138-
{
139-
path = helperName;
140-
}
141-
else
166+
//
167+
// Search the installation directory for an in-box helper
168+
//
169+
string appDir = Path.GetDirectoryName(Context.ApplicationPath);
170+
string inBoxExePath = Path.Combine(appDir, PlatformUtils.IsWindows() ? $"{helperName}.exe" : helperName);
171+
string inBoxDllPath = Path.Combine(appDir, $"{helperName}.dll");
172+
173+
// Look for in-box native executables
174+
if (Context.FileSystem.FileExists(inBoxExePath))
142175
{
143-
string executableDirectory = Path.GetDirectoryName(Context.ApplicationPath);
144-
path = Path.Combine(executableDirectory!, helperName);
176+
Context.Trace.WriteLine($"Found in-box native UI helper: '{inBoxExePath}'");
177+
command = inBoxExePath;
178+
return true;
145179
}
146180

147-
if (!Context.FileSystem.FileExists(path))
181+
// Look for in-box .NET framework-dependent executables
182+
if (Context.FileSystem.FileExists(inBoxDllPath))
148183
{
149-
// Only warn for missing helpers specified by the user, not in-box ones
150-
if (isOverride)
184+
string dotnetName = PlatformUtils.IsWindows() ? "dotnet.exe" : "dotnet";
185+
if (!Context.Environment.TryLocateExecutable(dotnetName, out string dotnetPath))
151186
{
152-
Context.Trace.WriteLine($"UI helper '{helperName}' was not found at '{path}'.");
153-
Context.Streams.Error.WriteLine($"warning: could not find configured UI helper '{helperName}'");
187+
Context.Trace.WriteLine($"Unable to run UI helper '{inBoxDllPath}' without the .NET CLI.");
188+
Context.Streams.Error.WriteLine($"warning: could not find .NET CLI to run UI helper '{inBoxDllPath}'");
189+
return false;
154190
}
155191

156-
return false;
192+
Context.Trace.WriteLine($"Found in-box framework-dependent UI helper: '{inBoxDllPath}'");
193+
command = dotnetPath;
194+
args = $"exec {QuoteCmdArg(inBoxDllPath)} ";
195+
return true;
196+
}
197+
198+
//
199+
// Search the PATH for a native executable (do NOT search for out-of-box .NET framework-dependent DLLs)
200+
//
201+
if (Context.Environment.TryLocateExecutable(helperName, out command))
202+
{
203+
Context.Trace.WriteLine($"Found UI helper on PATH: '{helperName}'");
204+
return true;
157205
}
158206

159-
return true;
207+
//
208+
// No helper found!
209+
//
210+
Context.Trace.WriteLine($"UI helper '{helperName}' was not found.");
211+
Context.Streams.Error.WriteLine($"warning: could not find UI helper '{helperName}'");
212+
return false;
160213
}
161214

162215
public static string QuoteCmdArg(string str)
163216
{
164-
char[] needsQuoteChars = {'"', ' ', '\\', '\n', '\r', '\t'};
217+
char[] needsQuoteChars = { '"', ' ', '\\', '\n', '\r', '\t' };
165218
bool needsQuotes = str.Any(x => needsQuoteChars.Contains(x));
166219

167220
if (!needsQuotes)

src/shared/Core/Authentication/BasicAuthentication.cs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ public async Task<ICredential> GetCredentialsAsync(string resource, string userN
3535
ThrowIfUserInteractionDisabled();
3636

3737
if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession &&
38-
TryFindHelperExecutablePath(out string helperPath))
38+
TryFindHelperCommand(out string command, out string args))
3939
{
40-
return await GetCredentialsByUiAsync(helperPath, resource, userName);
40+
return await GetCredentialsByUiAsync(command, args, resource, userName);
4141
}
4242

4343
ThrowIfTerminalPromptsDisabled();
@@ -66,9 +66,10 @@ private ICredential GetCredentialsByTty(string resource, string userName)
6666
return new GitCredential(userName, password);
6767
}
6868

69-
private async Task<ICredential> GetCredentialsByUiAsync(string helperPath, string resource, string userName)
69+
private async Task<ICredential> GetCredentialsByUiAsync(string command, string args, string resource, string userName)
7070
{
71-
var promptArgs = new StringBuilder("basic");
71+
var promptArgs = new StringBuilder(args);
72+
promptArgs.Append("basic");
7273

7374
if (!string.IsNullOrWhiteSpace(resource))
7475
{
@@ -80,7 +81,7 @@ private async Task<ICredential> GetCredentialsByUiAsync(string helperPath, strin
8081
promptArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName));
8182
}
8283

83-
IDictionary<string, string> resultDict = await InvokeHelperAsync(helperPath, promptArgs.ToString(), null);
84+
IDictionary<string, string> resultDict = await InvokeHelperAsync(command, promptArgs.ToString(), null);
8485

8586
if (!resultDict.TryGetValue("username", out userName))
8687
{
@@ -95,13 +96,14 @@ private async Task<ICredential> GetCredentialsByUiAsync(string helperPath, strin
9596
return new GitCredential(userName, password);
9697
}
9798

98-
private bool TryFindHelperExecutablePath(out string path)
99+
private bool TryFindHelperCommand(out string command, out string args)
99100
{
100-
return TryFindHelperExecutablePath(
101+
return TryFindHelperCommand(
101102
Constants.EnvironmentVariables.GcmUiHelper,
102103
Constants.GitConfiguration.Credential.UiHelper,
103104
Constants.DefaultUiHelper,
104-
out path);
105+
out command,
106+
out args);
105107
}
106108
}
107109
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.Build.NoTargets/3.5.6">
2+
<PropertyGroup>
3+
<TargetFramework>net6.0</TargetFramework>
4+
<PackAsTool>true</PackAsTool>
5+
<NuSpecFile>dotnet-tool.nuspec</NuSpecFile>
6+
<!-- Inject correct properties into NuSpec -->
7+
<NuspecProperties>
8+
version=$(PackageVersion);
9+
publishDir=$(PublishDir);
10+
</NuspecProperties>
11+
<NoBuild>true</NoBuild>
12+
<OutputPath>../../../out/nupkg</OutputPath>
13+
</PropertyGroup>
14+
</Project>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<DotNetCliTool Version="1">
3+
<Commands>
4+
<Command Name="git-credential-manager-core" EntryPoint="git-credential-manager-core.dll" Runner="dotnet" Version="6.0" />
5+
</Commands>
6+
</DotNetCliTool>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
3+
<metadata>
4+
<id>GitCredentialManager.Cli</id>
5+
<version>$version$</version>
6+
<description>Secure, cross-platform Git credential storage with authentication to Azure Repos, GitHub, and other popular Git hosting services.</description>
7+
<authors>Git Credential Manager</authors>
8+
<packageTypes>
9+
<packageType name="DotnetTool" />
10+
</packageTypes>
11+
</metadata>
12+
<files>
13+
<file src="$publishdir$" target="tools/net6.0/any" />
14+
</files>
15+
</package>

0 commit comments

Comments
 (0)