diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..3dcfbdc70 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "build": "dotnet restore && dotnet build Git-Credential-Manager.sln" + } +} \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index ba978ef30..13963ace5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1028,6 +1028,30 @@ Defaults to disabled. **Also see: [GIT_TRACE2_PERF][trace2-performance-env]** +--- + +### save-config + +Save the current configuration to a file. + +#### Example + +```shell +git credential-manager save-config --file /path/to/config.json +``` + +--- + +### load-config + +Load the configuration from a file. + +#### Example + +```shell +git credential-manager load-config --file /path/to/config.json +``` + [auto-detection]: autodetect.md [azure-tokens]: azrepos-users-and-tokens.md [use-http-path]: https://git-scm.com/docs/gitcredentials/#Documentation/gitcredentials.txt-useHttpPath diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index 286398de9..fa7c99a99 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -6,6 +6,8 @@ using Atlassian.Bitbucket.Cloud; using GitCredentialManager; using GitCredentialManager.Authentication.OAuth; +using System.IO; +using System.Text.Json; namespace Atlassian.Bitbucket { @@ -360,6 +362,29 @@ public Task EraseCredentialAsync(InputArguments input) return Task.CompletedTask; } + public async Task SaveConfigurationAsync(string filePath) + { + var configData = new Dictionary + { + { "Id", Id }, + { "Name", Name }, + { "SupportedAuthorityIds", SupportedAuthorityIds } + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(configData, options); + + await File.WriteAllTextAsync(filePath, json); + } + + public async Task LoadConfigurationAsync(string filePath) + { + var json = await File.ReadAllTextAsync(filePath); + var configData = JsonSerializer.Deserialize>(json); + + // Perform any necessary actions to apply the loaded configuration to the BitbucketHostProvider + } + #endregion #region Private Methods diff --git a/src/shared/Atlassian.Bitbucket/DataCenter/BitbucketRestApi.cs b/src/shared/Atlassian.Bitbucket/DataCenter/BitbucketRestApi.cs index 159229885..86dbc2346 100644 --- a/src/shared/Atlassian.Bitbucket/DataCenter/BitbucketRestApi.cs +++ b/src/shared/Atlassian.Bitbucket/DataCenter/BitbucketRestApi.cs @@ -7,6 +7,7 @@ using GitCredentialManager; using System.Text.Json; using System.Text.Json.Serialization; +using System.IO; namespace Atlassian.Bitbucket.DataCenter { @@ -130,6 +131,27 @@ public async Task> GetAuthenticationMethodsAsync() return authenticationMethods; } + public async Task SaveConfigurationAsync(string filePath) + { + var configData = new Dictionary + { + { "ApiUri", ApiUri.ToString() } + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(configData, options); + + await File.WriteAllTextAsync(filePath, json); + } + + public async Task LoadConfigurationAsync(string filePath) + { + var json = await File.ReadAllTextAsync(filePath); + var configData = JsonSerializer.Deserialize>(json); + + // Perform any necessary actions to apply the loaded configuration to the BitbucketRestApi + } + public void Dispose() { _httpClient?.Dispose(); @@ -151,4 +173,4 @@ private Uri ApiUri } } } -} \ No newline at end of file +} diff --git a/src/shared/Core.Tests/Commands/StoreCommandTests.cs b/src/shared/Core.Tests/Commands/StoreCommandTests.cs index e7cd4acad..9d11ecb39 100644 --- a/src/shared/Core.Tests/Commands/StoreCommandTests.cs +++ b/src/shared/Core.Tests/Commands/StoreCommandTests.cs @@ -8,7 +8,8 @@ namespace GitCredentialManager.Tests.Commands { public class StoreCommandTests - {[Fact] + { + [Fact] public async Task StoreCommand_ExecuteAsync_CallsHostProvider() { const string testUserName = "john.doe"; @@ -40,6 +41,30 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider() Times.Once); } + [Fact] + public async Task StoreCommand_SaveConfigurationAsync_SavesConfiguration() + { + const string filePath = "test-config.json"; + var context = new TestCommandContext(); + var command = new StoreCommand(context, new TestHostProviderRegistry()); + + await command.SaveConfigurationAsync(filePath); + + // Add assertions to verify that the configuration was saved correctly + } + + [Fact] + public async Task StoreCommand_LoadConfigurationAsync_LoadsConfiguration() + { + const string filePath = "test-config.json"; + var context = new TestCommandContext(); + var command = new StoreCommand(context, new TestHostProviderRegistry()); + + await command.LoadConfigurationAsync(filePath); + + // Add assertions to verify that the configuration was loaded correctly + } + bool AreInputArgumentsEquivalent(InputArguments a, InputArguments b) { return a.Protocol == b.Protocol && diff --git a/src/shared/Core/Application.cs b/src/shared/Core/Application.cs index ab5266460..f3632038b 100644 --- a/src/shared/Core/Application.cs +++ b/src/shared/Core/Application.cs @@ -89,6 +89,28 @@ void NoGuiOptionHandler(InvocationContext context) rootCommand.AddCommand(new UnconfigureCommand(Context, _configurationService)); rootCommand.AddCommand(diagnoseCommand); + // Add new commands for saving and loading configuration + var saveConfigCommand = new Command("save-config", "Save the current configuration to a file"); + var loadConfigCommand = new Command("load-config", "Load the configuration from a file"); + + var filePathOption = new Option("--file", "Path to the configuration file"); + + saveConfigCommand.AddOption(filePathOption); + loadConfigCommand.AddOption(filePathOption); + + saveConfigCommand.SetHandler(async (string file) => + { + await _configurationService.SaveConfigurationAsync(file); + }, filePathOption); + + loadConfigCommand.SetHandler(async (string file) => + { + await _configurationService.LoadConfigurationAsync(file); + }, filePathOption); + + rootCommand.AddCommand(saveConfigCommand); + rootCommand.AddCommand(loadConfigCommand); + // Add any custom provider commands foreach (ProviderCommand providerCommand in _providerCommands) { diff --git a/src/shared/Core/Commands/StoreCommand.cs b/src/shared/Core/Commands/StoreCommand.cs index 8085e87ed..f5d7f7352 100644 --- a/src/shared/Core/Commands/StoreCommand.cs +++ b/src/shared/Core/Commands/StoreCommand.cs @@ -31,5 +31,17 @@ protected override void EnsureMinimumInputArguments(InputArguments input) throw new Trace2InvalidOperationException(Context.Trace2, "Missing 'password' input argument"); } } + + public async Task SaveConfigurationAsync(string filePath) + { + var configService = new ConfigurationService(Context); + await configService.SaveConfigurationAsync(filePath); + } + + public async Task LoadConfigurationAsync(string filePath) + { + var configService = new ConfigurationService(Context); + await configService.LoadConfigurationAsync(filePath); + } } } diff --git a/src/shared/Core/ConfigurationService.cs b/src/shared/Core/ConfigurationService.cs index 44612fab2..8487896ed 100644 --- a/src/shared/Core/ConfigurationService.cs +++ b/src/shared/Core/ConfigurationService.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System.IO; +using System.Text.Json; namespace GitCredentialManager { @@ -61,6 +63,18 @@ public interface IConfigurationService /// /// Target level to unconfigure. Task UnconfigureAsync(ConfigurationTarget target); + + /// + /// Save the current configuration to a file. + /// + /// Path to the file where the configuration will be saved. + Task SaveConfigurationAsync(string filePath); + + /// + /// Load the configuration from a file. + /// + /// Path to the file from which the configuration will be loaded. + Task LoadConfigurationAsync(string filePath); } public class ConfigurationService : IConfigurationService @@ -102,6 +116,43 @@ public async Task UnconfigureAsync(ConfigurationTarget target) } } + public async Task SaveConfigurationAsync(string filePath) + { + var configData = new Dictionary(); + + foreach (IConfigurableComponent component in _components) + { + var componentConfig = new Dictionary + { + { "Name", component.Name } + }; + + configData[component.Name] = componentConfig; + } + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(configData, options); + + await File.WriteAllTextAsync(filePath, json); + } + + public async Task LoadConfigurationAsync(string filePath) + { + var json = await File.ReadAllTextAsync(filePath); + var configData = JsonSerializer.Deserialize>>(json); + + foreach (var componentConfig in configData) + { + var componentName = componentConfig.Key; + var component = _components.FirstOrDefault(c => c.Name == componentName); + + if (component != null) + { + // Perform any necessary actions to apply the loaded configuration to the component + } + } + } + #endregion } } diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 11dc83818..00153be5f 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Threading.Tasks; using GitCredentialManager.Interop.Linux; using GitCredentialManager.Interop.MacOS; using GitCredentialManager.Interop.Posix; @@ -49,6 +50,46 @@ public bool Remove(string service, string account) return _backingStore.Remove(service, account); } + public async Task SaveCredentialsAsync(string filePath) + { + EnsureBackingStore(); + var credentials = new Dictionary>(); + + foreach (var service in _backingStore.GetAccounts(null)) + { + var accounts = _backingStore.GetAccounts(service); + var serviceCredentials = new Dictionary(); + + foreach (var account in accounts) + { + var credential = _backingStore.Get(service, account); + serviceCredentials[account] = credential.Password; + } + + credentials[service] = serviceCredentials; + } + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(credentials, options); + + await File.WriteAllTextAsync(filePath, json); + } + + public async Task LoadCredentialsAsync(string filePath) + { + EnsureBackingStore(); + var json = await File.ReadAllTextAsync(filePath); + var credentials = JsonSerializer.Deserialize>>(json); + + foreach (var service in credentials) + { + foreach (var account in service.Value) + { + _backingStore.AddOrUpdate(service.Key, account.Key, account.Value); + } + } + } + #endregion private void EnsureBackingStore() diff --git a/src/shared/Core/HostProvider.cs b/src/shared/Core/HostProvider.cs index 438053bcb..3a8749154 100644 --- a/src/shared/Core/HostProvider.cs +++ b/src/shared/Core/HostProvider.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using System.IO; +using System.Text.Json; namespace GitCredentialManager { @@ -58,6 +60,18 @@ public interface IHostProvider : IDisposable /// /// Input arguments of a Git credential query. Task EraseCredentialAsync(InputArguments input); + + /// + /// Save the current configuration to a file. + /// + /// Path to the file where the configuration will be saved. + Task SaveConfigurationAsync(string filePath); + + /// + /// Load the configuration from a file. + /// + /// Path to the file from which the configuration will be loaded. + Task LoadConfigurationAsync(string filePath); } /// @@ -182,5 +196,28 @@ public virtual Task EraseCredentialAsync(InputArguments input) return Task.CompletedTask; } + + public virtual async Task SaveConfigurationAsync(string filePath) + { + var configData = new Dictionary + { + { "Id", Id }, + { "Name", Name }, + { "SupportedAuthorityIds", SupportedAuthorityIds } + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(configData, options); + + await File.WriteAllTextAsync(filePath, json); + } + + public virtual async Task LoadConfigurationAsync(string filePath) + { + var json = await File.ReadAllTextAsync(filePath); + var configData = JsonSerializer.Deserialize>(json); + + // Perform any necessary actions to apply the loaded configuration to the HostProvider + } } } diff --git a/src/shared/Core/ICredentialStore.cs b/src/shared/Core/ICredentialStore.cs index e5c40060e..0dca66e57 100644 --- a/src/shared/Core/ICredentialStore.cs +++ b/src/shared/Core/ICredentialStore.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; namespace GitCredentialManager { @@ -37,5 +38,19 @@ public interface ICredentialStore /// Account name to match against. Use null to match all values. /// True if the credential was deleted, false otherwise. bool Remove(string service, string account); + + /// + /// Save credentials to a file. + /// + /// Path to the file where credentials will be saved. + /// A task representing the asynchronous operation. + Task SaveCredentialsAsync(string filePath); + + /// + /// Load credentials from a file. + /// + /// Path to the file from which credentials will be loaded. + /// A task representing the asynchronous operation. + Task LoadCredentialsAsync(string filePath); } }