Skip to content

Commit 715eef1

Browse files
author
Lessley Dennington
committed
release: publish gcm as dotnet tool
Add a new `DotnetTool` project containing the needed files to publish GCM as a cross-platform dotnet tool. Additionally, update GCM to use helper dlls if exes are not found in the Application directory. At present, dlls are only used in the dotnet tool scenario to minimize the package size and to make it cross-platform compatible.
1 parent 44534db commit 715eef1

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)