diff --git a/.gitmodules b/.gitmodules index 837bee2191..fa31e929f1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,10 +30,6 @@ path = modules/Diagnostics url = https://github.com/aspnet/Diagnostics.git branch = release/2.2 -[submodule "modules/DotNetTools"] - path = modules/DotNetTools - url = https://github.com/aspnet/DotNetTools.git - branch = release/2.2 [submodule "modules/EntityFrameworkCore"] path = modules/EntityFrameworkCore url = https://github.com/aspnet/EntityFrameworkCore.git diff --git a/build/buildorder.props b/build/buildorder.props index 740c110947..fb7906a5ab 100644 --- a/build/buildorder.props +++ b/build/buildorder.props @@ -7,7 +7,6 @@ - diff --git a/build/repo.props b/build/repo.props index 486ea83c12..416ea94183 100644 --- a/build/repo.props +++ b/build/repo.props @@ -49,12 +49,16 @@ - + diff --git a/build/submodules.props b/build/submodules.props index 1d429fae9c..3b768bb267 100644 --- a/build/submodules.props +++ b/build/submodules.props @@ -54,7 +54,6 @@ - diff --git a/eng/Dependencies.props b/eng/Dependencies.props index a88732f80c..dfb4ddbee8 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -7,23 +7,29 @@ + + + + + + diff --git a/eng/NuGetPackageVerifier.json b/eng/NuGetPackageVerifier.json new file mode 100644 index 0000000000..4ce6d1a168 --- /dev/null +++ b/eng/NuGetPackageVerifier.json @@ -0,0 +1,102 @@ +{ + "adx": { + "rules": [ + "AdxVerificationCompositeRule" + ], + "packages": { + "dotnet-watch": { + "packageTypes": [ + "DotnetTool" + ] + }, + "dotnet-sql-cache": { + "packageTypes": [ + "DotnetTool" + ], + "Exclusions": { + "NEUTRAL_RESOURCES_LANGUAGE": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "WRONG_PUBLICKEYTOKEN": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.0/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/unix/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_INFORMATIONAL_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.0/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/unix/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_FILE_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.0/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/unix/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.0/System.Text.Encoding.CodePages.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/unix/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/runtimes/win/lib/netcoreapp2.1/System.Data.SqlClient.dll": "Assembly is built by another project but bundled in our nupkg." + } + } + }, + "dotnet-user-secrets": { + "packageTypes": [ + "DotnetTool" + ], + "Exclusions": { + "NEUTRAL_RESOURCES_LANGUAGE": { + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "SERVICING_ATTRIBUTE": { + "tools/netcoreapp2.1/any/Newtonsoft.Json.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "WRONG_PUBLICKEYTOKEN": { + "tools/netcoreapp2.1/any/Newtonsoft.Json.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_INFORMATIONAL_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/Newtonsoft.Json.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_FILE_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/Newtonsoft.Json.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + }, + "ASSEMBLY_VERSION_MISMATCH": { + "tools/netcoreapp2.1/any/Newtonsoft.Json.dll": "Assembly is built by another project but bundled in our nupkg.", + "tools/netcoreapp2.1/any/System.Runtime.CompilerServices.Unsafe.dll": "Assembly is built by another project but bundled in our nupkg." + } + } + }, + "dotnet-dev-certs": { + "packageTypes": [ + "DotnetTool" + ] + }, + "Microsoft.AspNetCore.DeveloperCertificates.XPlat": { + "Exclusions": { + "DOC_MISSING": { + "lib/netcoreapp2.1/Microsoft.AspNetCore.DeveloperCertificates.XPlat.dll": "Docs not required to shipoob package" + } + } + } + } + }, + "Default": { + "rules": [ + "DefaultCompositeRule" + ] + } +} diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 0415e44d3e..55d8dc6094 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -13,6 +13,11 @@ + + + + + diff --git a/eng/tools/BaselineGenerator/baseline.xml b/eng/tools/BaselineGenerator/baseline.xml index 759e73ffa7..f6ec31acb2 100644 --- a/eng/tools/BaselineGenerator/baseline.xml +++ b/eng/tools/BaselineGenerator/baseline.xml @@ -1,4 +1,8 @@ + + + + diff --git a/modules/DotNetTools b/modules/DotNetTools deleted file mode 160000 index d745b8c161..0000000000 --- a/modules/DotNetTools +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d745b8c161d42665ceb51597888062336fec7764 diff --git a/src/Tools/Directory.Build.props b/src/Tools/Directory.Build.props new file mode 100644 index 0000000000..6b35802689 --- /dev/null +++ b/src/Tools/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + $(RepositoryRoot)obj\$(MSBuildProjectName)\ + $(RepositoryRoot)bin\$(MSBuildProjectName)\ + + + diff --git a/src/Tools/Directory.Build.targets b/src/Tools/Directory.Build.targets new file mode 100644 index 0000000000..7928d3f6cf --- /dev/null +++ b/src/Tools/Directory.Build.targets @@ -0,0 +1,11 @@ + + + + + true + + + + + + diff --git a/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs b/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs new file mode 100644 index 0000000000..d3f58eae35 --- /dev/null +++ b/src/Tools/FirstRunCertGenerator/src/CertificateGenerator.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.AspNetCore.Certificates.Generation; + +namespace Microsoft.AspNetCore.DeveloperCertificates.XPlat +{ + public static class CertificateGenerator + { + public static void GenerateAspNetHttpsCertificate() + { + var manager = new CertificateManager(); + var now = DateTimeOffset.Now; + manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1)); + } + } +} diff --git a/src/Tools/FirstRunCertGenerator/src/Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj b/src/Tools/FirstRunCertGenerator/src/Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj new file mode 100644 index 0000000000..ce61b3ab62 --- /dev/null +++ b/src/Tools/FirstRunCertGenerator/src/Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj @@ -0,0 +1,14 @@ + + + + netcoreapp2.1 + Package for the CLI first run experience. + $(DefineConstants);XPLAT + aspnet;cli + + + + + + + diff --git a/src/Tools/README.md b/src/Tools/README.md new file mode 100644 index 0000000000..e13c6ec6e3 --- /dev/null +++ b/src/Tools/README.md @@ -0,0 +1,32 @@ +DotNetTools +=========== + +## Projects + +The folder contains command-line tools for ASP.NET Core that are bundled* in the .NET Core CLI. Follow the links below for more details on each tool. + + - [dotnet-watch](dotnet-watch/README.md) + - [dotnet-user-secrets](dotnet-user-secrets/README.md) + - [dotnet-sql-cache](dotnet-sql-cache/README.md) + - [dotnet-dev-certs](dotnet-dev-certs/README.md) + +*\*This applies to .NET Core CLI 2.1.300-preview2 and up. For earlier versions of the CLI, these tools must be installed separately.* + +*For 2.0 CLI and earlier, see for details.* + +## Usage + +The command line tools can be invoked as a subcommand of `dotnet`. + +```sh +dotnet watch +dotnet user-secrets +dotnet sql-cache +dotnet dev-certs +``` + +Add `--help` to see more details. For example, + +``` +dotnet watch --help +``` diff --git a/src/Tools/dotnet-dev-certs/README.md b/src/Tools/dotnet-dev-certs/README.md new file mode 100644 index 0000000000..e565dbc0e8 --- /dev/null +++ b/src/Tools/dotnet-dev-certs/README.md @@ -0,0 +1,8 @@ +dotnet-dev-certs +================ + +`dotnet-dev-certs` is a command line tool to generate certificates used in ASP.NET Core during development. + +### How To Use + +Run `dotnet dev-certs --help` for more information about usage. diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs new file mode 100644 index 0000000000..170e11b09d --- /dev/null +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -0,0 +1,247 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.AspNetCore.DeveloperCertificates.Tools +{ + internal class Program + { + private const int CriticalError = -1; + private const int Success = 0; + private const int ErrorCreatingTheCertificate = 1; + private const int ErrorSavingTheCertificate = 2; + private const int ErrorExportingTheCertificate = 3; + private const int ErrorTrustingTheCertificate = 4; + private const int ErrorUserCancelledTrustPrompt = 5; + private const int ErrorNoValidCertificateFound = 6; + private const int ErrorCertificateNotTrusted = 7; + private const int ErrorCleaningUpCertificates = 8; + + public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365); + + public static int Main(string[] args) + { + try + { + var app = new CommandLineApplication + { + Name = "dotnet dev-certs" + }; + + app.Command("https", c => + { + var exportPath = c.Option("-ep|--export-path", + "Full path to the exported certificate", + CommandOptionType.SingleValue); + + var password = c.Option("-p|--password", + "Password to use when exporting the certificate with the private key into a pfx file", + CommandOptionType.SingleValue); + + var check = c.Option( + "-c|--check", + "Check for the existence of the certificate but do not perform any action", + CommandOptionType.NoValue); + + var clean = c.Option( + "--clean", + "Cleans all HTTPS development certificates from the machine.", + CommandOptionType.NoValue); + + CommandOption trust = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + trust = c.Option("-t|--trust", + "Trust the certificate on the current platform", + CommandOptionType.NoValue); + } + + var verbose = c.Option("-v|--verbose", + "Display more debug information.", + CommandOptionType.NoValue); + + var quiet = c.Option("-q|--quiet", + "Display warnings and errors only.", + CommandOptionType.NoValue); + + c.HelpOption("-h|--help"); + + c.OnExecute(() => + { + var reporter = new ConsoleReporter(PhysicalConsole.Singleton, verbose.HasValue(), quiet.HasValue()); + if ((clean.HasValue() && (exportPath.HasValue() || password.HasValue() || trust?.HasValue() == true)) || + (check.HasValue() && (exportPath.HasValue() || password.HasValue() || clean.HasValue()))) + { + reporter.Error(@"Incompatible set of flags. Sample usages +'dotnet dev-certs https --clean' +'dotnet dev-certs https --check --trust' +'dotnet dev-certs https -ep ./certificate.pfx -p password --trust'"); + } + + if (check.HasValue()) + { + return CheckHttpsCertificate(trust, reporter); + } + + if (clean.HasValue()) + { + return CleanHttpsCertificates(reporter); + } + + return EnsureHttpsCertificate(exportPath, password, trust, reporter); + }); + }); + + app.HelpOption("-h|--help"); + + app.OnExecute(() => + { + app.ShowHelp(); + return Success; + }); + + return app.Execute(args); + } + catch + { + return CriticalError; + } + } + + private static int CleanHttpsCertificates(IReporter reporter) + { + var manager = new CertificateManager(); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + reporter.Output("Cleaning HTTPS development certificates from the machine. A prompt might get " + + "displayed to confirm the removal of some of the certificates."); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + reporter.Output("Cleaning HTTPS development certificates from the machine. This operation might " + + "require elevated privileges. If that is the case, a prompt for credentials will be displayed."); + } + + manager.CleanupHttpsCertificates(); + reporter.Verbose("HTTPS development certificates successfully removed from the machine."); + return Success; + } + catch(Exception e) + { + reporter.Error("There was an error trying to clean HTTPS development certificates on this machine."); + reporter.Error(e.Message); + + return ErrorCleaningUpCertificates; + } + } + + private static int CheckHttpsCertificate(CommandOption trust, IReporter reporter) + { + var now = DateTimeOffset.Now; + var certificateManager = new CertificateManager(); + var certificates = certificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true); + if (certificates.Count == 0) + { + reporter.Verbose("No valid certificate found."); + return ErrorNoValidCertificateFound; + } + else + { + reporter.Verbose("A valid certificate was found."); + } + + if (trust != null && trust.HasValue()) + { + var store = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? StoreName.My : StoreName.Root; + var trustedCertificates = certificateManager.ListCertificates(CertificatePurpose.HTTPS, store, StoreLocation.CurrentUser, isValid: true); + if (!certificates.Any(c => certificateManager.IsTrusted(c))) + { + reporter.Verbose($@"The following certificates were found, but none of them is trusted: +{string.Join(Environment.NewLine, certificates.Select(c => $"{c.Subject} - {c.Thumbprint}"))}"); + return ErrorCertificateNotTrusted; + } + else + { + reporter.Verbose("A trusted certificate was found."); + } + } + + return Success; + } + + private static int EnsureHttpsCertificate(CommandOption exportPath, CommandOption password, CommandOption trust, IReporter reporter) + { + var now = DateTimeOffset.Now; + var manager = new CertificateManager(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && trust?.HasValue() == true) + { + reporter.Warn("Trusting the HTTPS development certificate was requested. If the certificate is not " + + "already trusted we will run the following command:" + Environment.NewLine + + "'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <>'" + + Environment.NewLine + "This command might prompt you for your password to install the certificate " + + "on the system keychain."); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && trust?.HasValue() == true) + { + reporter.Warn("Trusting the HTTPS development certificate was requested. A confirmation prompt will be displayed " + + "if the certificate was not previously trusted. Click yes on the prompt to trust the certificate."); + } + + var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, + now.Add(HttpsCertificateValidity), + exportPath.Value(), + trust == null ? false : trust.HasValue(), + password.HasValue(), + password.Value()); + + switch (result) + { + case EnsureCertificateResult.Succeeded: + reporter.Output("The HTTPS developer certificate was generated successfully."); + if (exportPath.Value() != null) + { + reporter.Verbose($"The certificate was exported to {Path.GetFullPath(exportPath.Value())}"); + } + return Success; + case EnsureCertificateResult.ValidCertificatePresent: + reporter.Output("A valid HTTPS certificate is already present."); + if (exportPath.Value() != null) + { + reporter.Verbose($"The certificate was exported to {Path.GetFullPath(exportPath.Value())}"); + } + return Success; + case EnsureCertificateResult.ErrorCreatingTheCertificate: + reporter.Error("There was an error creating the HTTPS developer certificate."); + return ErrorCreatingTheCertificate; + case EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore: + reporter.Error("There was an error saving the HTTPS developer certificate to the current user personal certificate store."); + return ErrorSavingTheCertificate; + case EnsureCertificateResult.ErrorExportingTheCertificate: + reporter.Warn("There was an error exporting HTTPS developer certificate to a file."); + return ErrorExportingTheCertificate; + case EnsureCertificateResult.FailedToTrustTheCertificate: + reporter.Warn("There was an error trusting HTTPS developer certificate."); + return ErrorTrustingTheCertificate; + case EnsureCertificateResult.UserCancelledTrustStep: + reporter.Warn("The user cancelled the trust step."); + return ErrorUserCancelledTrustPrompt; + default: + reporter.Error("Something went wrong. The HTTPS developer certificate could not be created."); + return CriticalError; + } + } + } +} diff --git a/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj b/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj new file mode 100644 index 0000000000..1f0f906e50 --- /dev/null +++ b/src/Tools/dotnet-dev-certs/src/dotnet-dev-certs.csproj @@ -0,0 +1,32 @@ + + + + netcoreapp2.1 + exe + Command line tool to generate certificates used in ASP.NET Core during development. + Microsoft.AspNetCore.DeveloperCertificates.Tools + dotnet;developercertificates + true + + win-x64;win-x86 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tools/dotnet-sql-cache/README.md b/src/Tools/dotnet-sql-cache/README.md new file mode 100644 index 0000000000..141c1cab65 --- /dev/null +++ b/src/Tools/dotnet-sql-cache/README.md @@ -0,0 +1,8 @@ +dotnet-sql-cache +================ + +`dotnet-sql-cache` is a command line tool that creates table and indexes in Microsoft SQL Server database to be used for distributed caching + +### How To Use + +Run `dotnet sql-cache --help` for more information about usage. diff --git a/src/Tools/dotnet-sql-cache/src/Program.cs b/src/Tools/dotnet-sql-cache/src/Program.cs new file mode 100644 index 0000000000..874244f603 --- /dev/null +++ b/src/Tools/dotnet-sql-cache/src/Program.cs @@ -0,0 +1,173 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Data; +using System.Data.SqlClient; +using System.Reflection; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.Caching.SqlConfig.Tools +{ + public class Program + { + private string _connectionString = null; + private string _schemaName = null; + private string _tableName = null; + private readonly IConsole _console; + + public Program(IConsole console) + { + Ensure.NotNull(console, nameof(console)); + + _console = console; + } + + public static int Main(string[] args) + { + return new Program(PhysicalConsole.Singleton).Run(args); + } + + public int Run(string[] args) + { + DebugHelper.HandleDebugSwitch(ref args); + + try + { + var app = new CommandLineApplication + { + Name = "dotnet sql-cache", + FullName = "SQL Server Cache Command Line Tool", + Description = + "Creates table and indexes in Microsoft SQL Server database to be used for distributed caching", + }; + + app.HelpOption(); + app.VersionOptionFromAssemblyAttributes(typeof(Program).GetTypeInfo().Assembly); + var verbose = app.VerboseOption(); + + app.Command("create", command => + { + command.Description = app.Description; + + var connectionStringArg = command.Argument( + "[connectionString]", "The connection string to connect to the database."); + + var schemaNameArg = command.Argument( + "[schemaName]", "Name of the table schema."); + + var tableNameArg = command.Argument( + "[tableName]", "Name of the table to be created."); + + command.HelpOption(); + + command.OnExecute(() => + { + var reporter = CreateReporter(verbose.HasValue()); + if (string.IsNullOrEmpty(connectionStringArg.Value) + || string.IsNullOrEmpty(schemaNameArg.Value) + || string.IsNullOrEmpty(tableNameArg.Value)) + { + reporter.Error("Invalid input"); + app.ShowHelp(); + return 2; + } + + _connectionString = connectionStringArg.Value; + _schemaName = schemaNameArg.Value; + _tableName = tableNameArg.Value; + + return CreateTableAndIndexes(reporter); + }); + }); + + // Show help information if no subcommand/option was specified. + app.OnExecute(() => + { + app.ShowHelp(); + return 2; + }); + + return app.Execute(args); + } + catch (Exception exception) + { + CreateReporter(verbose: false).Error($"An error occurred. {exception.Message}"); + return 1; + } + } + + private IReporter CreateReporter(bool verbose) + => new ConsoleReporter(_console, verbose, quiet: false); + private int CreateTableAndIndexes(IReporter reporter) + { + ValidateConnectionString(); + + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + var sqlQueries = new SqlQueries(_schemaName, _tableName); + var command = new SqlCommand(sqlQueries.TableInfo, connection); + + using (var reader = command.ExecuteReader(CommandBehavior.SingleRow)) + { + if (reader.Read()) + { + reporter.Warn( + $"Table with schema '{_schemaName}' and name '{_tableName}' already exists. " + + "Provide a different table name and try again."); + return 1; + } + } + + using (var transaction = connection.BeginTransaction()) + { + try + { + command = new SqlCommand(sqlQueries.CreateTable, connection, transaction); + + reporter.Verbose($"Executing {command.CommandText}"); + command.ExecuteNonQuery(); + + command = new SqlCommand( + sqlQueries.CreateNonClusteredIndexOnExpirationTime, + connection, + transaction); + + reporter.Verbose($"Executing {command.CommandText}"); + command.ExecuteNonQuery(); + + transaction.Commit(); + + reporter.Output("Table and index were created successfully."); + } + catch (Exception ex) + { + reporter.Error( + $"An error occurred while trying to create the table and index. {ex.Message}"); + transaction.Rollback(); + + return 1; + } + } + } + + return 0; + } + + private void ValidateConnectionString() + { + try + { + new SqlConnectionStringBuilder(_connectionString); + } + catch (Exception ex) + { + throw new ArgumentException( + $"Invalid SQL Server connection string '{_connectionString}'. {ex.Message}", ex); + } + } + } +} diff --git a/src/Tools/dotnet-sql-cache/src/SqlQueries.cs b/src/Tools/dotnet-sql-cache/src/SqlQueries.cs new file mode 100644 index 0000000000..7b9c73a990 --- /dev/null +++ b/src/Tools/dotnet-sql-cache/src/SqlQueries.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Caching.SqlConfig.Tools +{ + internal class SqlQueries + { + private const string CreateTableFormat = "CREATE TABLE {0}(" + + // Maximum size of primary key column is 900 bytes (898 bytes from key + 2 additional bytes used by the + // Sql Server). In the case where the key is greater than 898 bytes, then it gets truncated. + // - Add collation to the key column to make it case-sensitive + "Id nvarchar(449) COLLATE SQL_Latin1_General_CP1_CS_AS NOT NULL, " + + "Value varbinary(MAX) NOT NULL, " + + "ExpiresAtTime datetimeoffset NOT NULL, " + + "SlidingExpirationInSeconds bigint NULL," + + "AbsoluteExpiration datetimeoffset NULL, " + + "PRIMARY KEY (Id))"; + + private const string CreateNonClusteredIndexOnExpirationTimeFormat + = "CREATE NONCLUSTERED INDEX Index_ExpiresAtTime ON {0}(ExpiresAtTime)"; + + private const string TableInfoFormat = + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE " + + "FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA = '{0}' " + + "AND TABLE_NAME = '{1}'"; + + public SqlQueries(string schemaName, string tableName) + { + if (string.IsNullOrEmpty(schemaName)) + { + throw new ArgumentException("Schema name cannot be empty or null"); + } + if (string.IsNullOrEmpty(tableName)) + { + throw new ArgumentException("Table name cannot be empty or null"); + } + + var tableNameWithSchema = string.Format( + "{0}.{1}", DelimitIdentifier(schemaName), DelimitIdentifier(tableName)); + CreateTable = string.Format(CreateTableFormat, tableNameWithSchema); + CreateNonClusteredIndexOnExpirationTime = string.Format( + CreateNonClusteredIndexOnExpirationTimeFormat, + tableNameWithSchema); + TableInfo = string.Format(TableInfoFormat, EscapeLiteral(schemaName), EscapeLiteral(tableName)); + } + + public string CreateTable { get; } + + public string CreateNonClusteredIndexOnExpirationTime { get; } + + public string TableInfo { get; } + + // From EF's SqlServerQuerySqlGenerator + private string DelimitIdentifier(string identifier) + { + return "[" + identifier.Replace("]", "]]") + "]"; + } + + private string EscapeLiteral(string literal) + { + return literal.Replace("'", "''"); + } + } +} diff --git a/src/Tools/dotnet-sql-cache/src/dotnet-sql-cache.csproj b/src/Tools/dotnet-sql-cache/src/dotnet-sql-cache.csproj new file mode 100644 index 0000000000..3856c4877c --- /dev/null +++ b/src/Tools/dotnet-sql-cache/src/dotnet-sql-cache.csproj @@ -0,0 +1,30 @@ + + + + netcoreapp2.1 + exe + Command line tool to create tables and indexes in a Microsoft SQL Server database for distributed caching. + cache;distributedcache;sqlserver + true + + win-x64;win-x86 + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tools/dotnet-user-secrets/README.md b/src/Tools/dotnet-user-secrets/README.md new file mode 100644 index 0000000000..0d8666cdb6 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/README.md @@ -0,0 +1,8 @@ +dotnet-user-secrets +=================== + +`dotnet-user-secrets` is a command line tool for managing the secrets in a user secret store. + +### How To Use + +Run `dotnet user-secrets --help` for more information about usage. diff --git a/src/Tools/dotnet-user-secrets/src/CommandLineOptions.cs b/src/Tools/dotnet-user-secrets/src/CommandLineOptions.cs new file mode 100644 index 0000000000..6ce543cc00 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/CommandLineOptions.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools +{ + public class CommandLineOptions + { + public ICommand Command { get; set; } + public string Configuration { get; private set; } + public string Id { get; private set; } + public bool IsHelp { get; private set; } + public bool IsVerbose { get; private set; } + public string Project { get; private set; } + + public static CommandLineOptions Parse(string[] args, IConsole console) + { + var app = new CommandLineApplication() + { + Out = console.Out, + Error = console.Error, + Name = "dotnet user-secrets", + FullName = "User Secrets Manager", + Description = "Manages user secrets" + }; + + app.HelpOption(); + app.VersionOptionFromAssemblyAttributes(typeof(Program).GetTypeInfo().Assembly); + + var optionVerbose = app.VerboseOption(); + + var optionProject = app.Option("-p|--project ", "Path to project. Defaults to searching the current directory.", + CommandOptionType.SingleValue, inherited: true); + + var optionConfig = app.Option("-c|--configuration ", $"The project configuration to use. Defaults to 'Debug'.", + CommandOptionType.SingleValue, inherited: true); + + // the escape hatch if project evaluation fails, or if users want to alter a secret store other than the one + // in the current project + var optionId = app.Option("--id", "The user secret ID to use.", + CommandOptionType.SingleValue, inherited: true); + + var options = new CommandLineOptions(); + + app.Command("set", c => SetCommand.Configure(c, options, console)); + app.Command("remove", c => RemoveCommand.Configure(c, options)); + app.Command("list", c => ListCommand.Configure(c, options)); + app.Command("clear", c => ClearCommand.Configure(c, options)); + + // Show help information if no subcommand/option was specified. + app.OnExecute(() => app.ShowHelp()); + + if (app.Execute(args) != 0) + { + // when command line parsing error in subcommand + return null; + } + + options.Configuration = optionConfig.Value(); + options.Id = optionId.Value(); + options.IsHelp = app.IsShowingInformation; + options.IsVerbose = optionVerbose.HasValue(); + options.Project = optionProject.Value(); + + return options; + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ClearCommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/ClearCommand.cs new file mode 100644 index 0000000000..108fd542d7 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/ClearCommand.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + internal class ClearCommand : ICommand + { + public static void Configure(CommandLineApplication command, CommandLineOptions options) + { + command.Description = "Deletes all the application secrets"; + command.HelpOption(); + + command.OnExecute(() => + { + options.Command = new ClearCommand(); + }); + } + + public void Execute(CommandContext context) + { + context.SecretStore.Clear(); + context.SecretStore.Save(); + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Internal/CommandContext.cs b/src/Tools/dotnet-user-secrets/src/Internal/CommandContext.cs new file mode 100644 index 0000000000..0ac0ea40fe --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/CommandContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + public class CommandContext + { + public CommandContext( + SecretsStore store, + IReporter reporter, + IConsole console) + { + SecretStore = store; + Reporter = reporter; + Console = console; + } + + public IConsole Console { get; } + public IReporter Reporter { get; } + public SecretsStore SecretStore { get; } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ICommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/ICommand.cs new file mode 100644 index 0000000000..636c08a07c --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/ICommand.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + public interface ICommand + { + void Execute(CommandContext context); + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs new file mode 100644 index 0000000000..bbefab2a13 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.CommandLineUtils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + internal class ListCommand : ICommand + { + private readonly bool _jsonOutput; + + public static void Configure(CommandLineApplication command, CommandLineOptions options) + { + command.Description = "Lists all the application secrets"; + command.HelpOption(); + + var optJson = command.Option("--json", "Use json output. JSON is wrapped by '//BEGIN' and '//END'", + CommandOptionType.NoValue); + + command.OnExecute(() => + { + options.Command = new ListCommand(optJson.HasValue()); + }); + } + + public ListCommand(bool jsonOutput) + { + _jsonOutput = jsonOutput; + } + + public void Execute(CommandContext context) + { + if (_jsonOutput) + { + ReportJson(context); + return; + } + + if (context.SecretStore.Count == 0) + { + context.Reporter.Output(Resources.Error_No_Secrets_Found); + } + else + { + foreach (var secret in context.SecretStore.AsEnumerable()) + { + context.Reporter.Output(Resources.FormatMessage_Secret_Value_Format(secret.Key, secret.Value)); + } + } + } + + private void ReportJson(CommandContext context) + { + var jObject = new JObject(); + foreach(var item in context.SecretStore.AsEnumerable()) + { + jObject[item.Key] = item.Value; + } + + context.Reporter.Output("//BEGIN"); + context.Reporter.Output(jObject.ToString(Formatting.Indented)); + context.Reporter.Output("//END"); + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Internal/MsBuildProjectFinder.cs b/src/Tools/dotnet-user-secrets/src/Internal/MsBuildProjectFinder.cs new file mode 100644 index 0000000000..a24843b04d --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/MsBuildProjectFinder.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + internal class MsBuildProjectFinder + { + private readonly string _directory; + + public MsBuildProjectFinder(string directory) + { + Ensure.NotNullOrEmpty(directory, nameof(directory)); + + _directory = directory; + } + + public string FindMsBuildProject(string project) + { + var projectPath = project ?? _directory; + + if (!Path.IsPathRooted(projectPath)) + { + projectPath = Path.Combine(_directory, projectPath); + } + + if (Directory.Exists(projectPath)) + { + var projects = Directory.EnumerateFileSystemEntries(projectPath, "*.*proj", SearchOption.TopDirectoryOnly) + .Where(f => !".xproj".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (projects.Count > 1) + { + throw new FileNotFoundException(Resources.FormatError_MultipleProjectsFound(projectPath)); + } + + if (projects.Count == 0) + { + throw new FileNotFoundException(Resources.FormatError_NoProjectsFound(projectPath)); + } + + return projects[0]; + } + + if (!File.Exists(projectPath)) + { + throw new FileNotFoundException(Resources.FormatError_ProjectPath_NotFound(projectPath)); + } + + return projectPath; + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ProjectIdResolver.cs b/src/Tools/dotnet-user-secrets/src/Internal/ProjectIdResolver.cs new file mode 100644 index 0000000000..da5111ca51 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/ProjectIdResolver.cs @@ -0,0 +1,124 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + public class ProjectIdResolver + { + private const string DefaultConfig = "Debug"; + private readonly IReporter _reporter; + private readonly string _targetsFile; + private readonly string _workingDirectory; + + public ProjectIdResolver(IReporter reporter, string workingDirectory) + { + _workingDirectory = workingDirectory; + _reporter = reporter; + _targetsFile = FindTargetsFile(); + } + + public string Resolve(string project, string configuration) + { + var finder = new MsBuildProjectFinder(_workingDirectory); + var projectFile = finder.FindMsBuildProject(project); + + _reporter.Verbose(Resources.FormatMessage_Project_File_Path(projectFile)); + + configuration = !string.IsNullOrEmpty(configuration) + ? configuration + : DefaultConfig; + + var outputFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var args = new[] + { + "msbuild", + projectFile, + "/nologo", + "/t:_ExtractUserSecretsMetadata", // defined in SecretManager.targets + "/p:_UserSecretsMetadataFile=" + outputFile, + "/p:Configuration=" + configuration, + "/p:CustomAfterMicrosoftCommonTargets=" + _targetsFile, + "/p:CustomAfterMicrosoftCommonCrossTargetingTargets=" + _targetsFile, + }; + var psi = new ProcessStartInfo + { + FileName = DotNetMuxer.MuxerPathOrDefault(), + Arguments = ArgumentEscaper.EscapeAndConcatenate(args), + RedirectStandardOutput = true, + RedirectStandardError = true + }; + +#if DEBUG + _reporter.Verbose($"Invoking '{psi.FileName} {psi.Arguments}'"); +#endif + + var process = Process.Start(psi); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + _reporter.Verbose(process.StandardOutput.ReadToEnd()); + _reporter.Verbose(process.StandardError.ReadToEnd()); + throw new InvalidOperationException(Resources.FormatError_ProjectFailedToLoad(projectFile)); + } + + var id = File.ReadAllText(outputFile)?.Trim(); + if (string.IsNullOrEmpty(id)) + { + throw new InvalidOperationException(Resources.FormatError_ProjectMissingId(projectFile)); + } + return id; + + } + finally + { + TryDelete(outputFile); + } + } + + private string FindTargetsFile() + { + var assemblyDir = Path.GetDirectoryName(typeof(ProjectIdResolver).Assembly.Location); + var searchPaths = new[] + { + Path.Combine(AppContext.BaseDirectory, "assets"), + Path.Combine(assemblyDir, "assets"), + AppContext.BaseDirectory, + assemblyDir, + }; + + var targetPath = searchPaths.Select(p => Path.Combine(p, "SecretManager.targets")).FirstOrDefault(File.Exists); + if (targetPath == null) + { + _reporter.Error("Fatal error: could not find SecretManager.targets"); + return null; + } + return targetPath; + } + + private static void TryDelete(string file) + { + try + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + catch + { + // whatever + } + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ReadableJsonConfigurationSource.cs b/src/Tools/dotnet-user-secrets/src/Internal/ReadableJsonConfigurationSource.cs new file mode 100644 index 0000000000..96d0554aeb --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/ReadableJsonConfigurationSource.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Extensions.Configuration.Json; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + public class ReadableJsonConfigurationProvider : JsonConfigurationProvider + { + public ReadableJsonConfigurationProvider() + : base(new JsonConfigurationSource()) + { + } + + public IDictionary CurrentData => Data; + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/Internal/RemoveCommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/RemoveCommand.cs new file mode 100644 index 0000000000..3bc594b149 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/RemoveCommand.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + internal class RemoveCommand : ICommand + { + private readonly string _keyName; + + public static void Configure(CommandLineApplication command, CommandLineOptions options) + { + command.Description = "Removes the specified user secret"; + command.HelpOption(); + + var keyArg = command.Argument("[name]", "Name of the secret"); + command.OnExecute(() => + { + if (keyArg.Value == null) + { + throw new CommandParsingException(command, Resources.FormatError_MissingArgument("name")); + } + + options.Command = new RemoveCommand(keyArg.Value); + }); + } + + + public RemoveCommand(string keyName) + { + _keyName = keyName; + } + + public void Execute(CommandContext context) + { + if (!context.SecretStore.ContainsKey(_keyName)) + { + context.Reporter.Warn(Resources.FormatError_Missing_Secret(_keyName)); + } + else + { + context.SecretStore.Remove(_keyName); + context.SecretStore.Save(); + } + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs b/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs new file mode 100644 index 0000000000..93d46242f2 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; +using Microsoft.Extensions.Tools.Internal; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + public class SecretsStore + { + private readonly string _secretsFilePath; + private IDictionary _secrets; + + public SecretsStore(string userSecretsId, IReporter reporter) + { + Ensure.NotNull(userSecretsId, nameof(userSecretsId)); + + _secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId); + + // workaround bug in configuration + var secretDir = Path.GetDirectoryName(_secretsFilePath); + Directory.CreateDirectory(secretDir); + + reporter.Verbose(Resources.FormatMessage_Secret_File_Path(_secretsFilePath)); + _secrets = Load(userSecretsId); + } + + public string this[string key] + { + get + { + return _secrets[key]; + } + } + + public int Count => _secrets.Count; + + public bool ContainsKey(string key) => _secrets.ContainsKey(key); + + public IEnumerable> AsEnumerable() => _secrets; + + public void Clear() => _secrets.Clear(); + + public void Set(string key, string value) => _secrets[key] = value; + + public void Remove(string key) + { + if (_secrets.ContainsKey(key)) + { + _secrets.Remove(key); + } + } + + public virtual void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(_secretsFilePath)); + + var contents = new JObject(); + if (_secrets != null) + { + foreach (var secret in _secrets.AsEnumerable()) + { + contents[secret.Key] = secret.Value; + } + } + + File.WriteAllText(_secretsFilePath, contents.ToString(), Encoding.UTF8); + } + + protected virtual IDictionary Load(string userSecretsId) + { + return new ConfigurationBuilder() + .AddJsonFile(_secretsFilePath, optional: true) + .Build() + .AsEnumerable() + .Where(i => i.Value != null) + .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/Internal/SetCommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/SetCommand.cs new file mode 100644 index 0000000000..fa389ca242 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Internal/SetCommand.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + internal class SetCommand + { + public static void Configure(CommandLineApplication command, CommandLineOptions options, IConsole console) + { + command.Description = "Sets the user secret to the specified value"; + command.ExtendedHelpText = @" +Additional Info: + This command will also handle piped input. Piped input is expected to be a valid JSON format. + +Examples: + dotnet user-secrets set ConnStr ""User ID=bob;Password=***"" +"; + + var catCmd = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? @"type .\secrets.json" + : "cat ./secrets.json"; + + command.ExtendedHelpText += $@" {catCmd} | dotnet user-secrets set"; + + command.HelpOption(); + + var nameArg = command.Argument("[name]", "Name of the secret"); + var valueArg = command.Argument("[value]", "Value of the secret"); + + command.OnExecute(() => + { + if (console.IsInputRedirected && nameArg.Value == null) + { + options.Command = new FromStdInStrategy(); + } + else + { + if (string.IsNullOrEmpty(nameArg.Value)) + { + throw new CommandParsingException(command, Resources.FormatError_MissingArgument("name")); + } + + if (valueArg.Value == null) + { + throw new CommandParsingException(command, Resources.FormatError_MissingArgument("value")); + } + + options.Command = new ForOneValueStrategy(nameArg.Value, valueArg.Value); + } + }); + } + + public class FromStdInStrategy : ICommand + { + public void Execute(CommandContext context) + { + // parses stdin with the same parser that Microsoft.Extensions.Configuration.Json would use + var provider = new ReadableJsonConfigurationProvider(); + using (var stream = new MemoryStream()) + { + using (var writer = new StreamWriter(stream, Encoding.Unicode, 1024, true)) + { + writer.Write(context.Console.In.ReadToEnd()); // TODO buffer? + } + + stream.Seek(0, SeekOrigin.Begin); + provider.Load(stream); + } + + foreach (var k in provider.CurrentData) + { + context.SecretStore.Set(k.Key, k.Value); + } + + context.Reporter.Output(Resources.FormatMessage_Saved_Secrets(provider.CurrentData.Count)); + + context.SecretStore.Save(); + } + } + + public class ForOneValueStrategy : ICommand + { + private readonly string _keyName; + private readonly string _keyValue; + + public ForOneValueStrategy(string keyName, string keyValue) + { + _keyName = keyName; + _keyValue = keyValue; + } + + public void Execute(CommandContext context) + { + context.SecretStore.Set(_keyName, _keyValue); + context.SecretStore.Save(); + context.Reporter.Output(Resources.FormatMessage_Saved_Secret(_keyName, _keyValue)); + } + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Program.cs b/src/Tools/dotnet-user-secrets/src/Program.cs new file mode 100644 index 0000000000..187e40128f --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Program.cs @@ -0,0 +1,105 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.Extensions.SecretManager.Tools +{ + public class Program + { + private readonly IConsole _console; + private readonly string _workingDirectory; + + public static int Main(string[] args) + { + DebugHelper.HandleDebugSwitch(ref args); + + int rc; + new Program(PhysicalConsole.Singleton, Directory.GetCurrentDirectory()).TryRun(args, out rc); + return rc; + } + + public Program(IConsole console, string workingDirectory) + { + _console = console; + _workingDirectory = workingDirectory; + } + + public bool TryRun(string[] args, out int returnCode) + { + try + { + returnCode = RunInternal(args); + return true; + } + catch (Exception exception) + { + var reporter = CreateReporter(verbose: true); + reporter.Verbose(exception.ToString()); + reporter.Error(Resources.FormatError_Command_Failed(exception.Message)); + returnCode = 1; + return false; + } + } + + internal int RunInternal(params string[] args) + { + CommandLineOptions options; + try + { + options = CommandLineOptions.Parse(args, _console); + } + catch (CommandParsingException ex) + { + CreateReporter(verbose: false).Error(ex.Message); + return 1; + } + + if (options == null) + { + return 1; + } + + if (options.IsHelp) + { + return 2; + } + + var reporter = CreateReporter(options.IsVerbose); + + string userSecretsId; + try + { + userSecretsId = ResolveId(options, reporter); + } + catch (Exception ex) when (ex is InvalidOperationException || ex is FileNotFoundException) + { + reporter.Error(ex.Message); + return 1; + } + + var store = new SecretsStore(userSecretsId, reporter); + var context = new Internal.CommandContext(store, reporter, _console); + options.Command.Execute(context); + return 0; + } + + private IReporter CreateReporter(bool verbose) + => new ConsoleReporter(_console, verbose, quiet: false); + + internal string ResolveId(CommandLineOptions options, IReporter reporter) + { + if (!string.IsNullOrEmpty(options.Id)) + { + return options.Id; + } + + var resolver = new ProjectIdResolver(reporter, _workingDirectory); + return resolver.Resolve(options.Project, options.Configuration); + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Properties/AssemblyInfo.cs b/src/Tools/dotnet-user-secrets/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..ae77d384c1 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.SecretManager.Tools.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Tools/dotnet-user-secrets/src/Properties/Resources.Designer.cs b/src/Tools/dotnet-user-secrets/src/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..a75fc0108f --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Properties/Resources.Designer.cs @@ -0,0 +1,256 @@ +// +namespace Microsoft.Extensions.SecretManager.Tools +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.Extensions.SecretManager.Tools.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// Command failed : {message} + /// + internal static string Error_Command_Failed + { + get { return GetString("Error_Command_Failed"); } + } + + /// + /// Command failed : {message} + /// + internal static string FormatError_Command_Failed(object message) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_Command_Failed", "message"), message); + } + + /// + /// Missing parameter value for '{name}'. + /// Use the '--help' flag to see info. + /// + internal static string Error_MissingArgument + { + get { return GetString("Error_MissingArgument"); } + } + + /// + /// Missing parameter value for '{name}'. + /// Use the '--help' flag to see info. + /// + internal static string FormatError_MissingArgument(object name) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_MissingArgument", "name"), name); + } + + /// + /// Cannot find '{key}' in the secret store. + /// + internal static string Error_Missing_Secret + { + get { return GetString("Error_Missing_Secret"); } + } + + /// + /// Cannot find '{key}' in the secret store. + /// + internal static string FormatError_Missing_Secret(object key) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_Missing_Secret", "key"), key); + } + + /// + /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + /// + internal static string Error_MultipleProjectsFound + { + get { return GetString("Error_MultipleProjectsFound"); } + } + + /// + /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + /// + internal static string FormatError_MultipleProjectsFound(object projectPath) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath); + } + + /// + /// No secrets configured for this application. + /// + internal static string Error_No_Secrets_Found + { + get { return GetString("Error_No_Secrets_Found"); } + } + + /// + /// No secrets configured for this application. + /// + internal static string FormatError_No_Secrets_Found() + { + return GetString("Error_No_Secrets_Found"); + } + + /// + /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + /// + internal static string Error_NoProjectsFound + { + get { return GetString("Error_NoProjectsFound"); } + } + + /// + /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + /// + internal static string FormatError_NoProjectsFound(object projectPath) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath); + } + + /// + /// Could not find the global property 'UserSecretsId' in MSBuild project '{project}'. Ensure this property is set in the project or use the '--id' command line option. + /// + internal static string Error_ProjectMissingId + { + get { return GetString("Error_ProjectMissingId"); } + } + + /// + /// Could not find the global property 'UserSecretsId' in MSBuild project '{project}'. Ensure this property is set in the project or use the '--id' command line option. + /// + internal static string FormatError_ProjectMissingId(object project) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectMissingId", "project"), project); + } + + /// + /// The project file '{path}' does not exist. + /// + internal static string Error_ProjectPath_NotFound + { + get { return GetString("Error_ProjectPath_NotFound"); } + } + + /// + /// The project file '{path}' does not exist. + /// + internal static string FormatError_ProjectPath_NotFound(object path) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path); + } + + /// + /// Could not load the MSBuild project '{project}'. + /// + internal static string Error_ProjectFailedToLoad + { + get { return GetString("Error_ProjectFailedToLoad"); } + } + + /// + /// Could not load the MSBuild project '{project}'. + /// + internal static string FormatError_ProjectFailedToLoad(object project) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectFailedToLoad", "project"), project); + } + + /// + /// Project file path {project}. + /// + internal static string Message_Project_File_Path + { + get { return GetString("Message_Project_File_Path"); } + } + + /// + /// Project file path {project}. + /// + internal static string FormatMessage_Project_File_Path(object project) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Message_Project_File_Path", "project"), project); + } + + /// + /// Successfully saved {key} = {value} to the secret store. + /// + internal static string Message_Saved_Secret + { + get { return GetString("Message_Saved_Secret"); } + } + + /// + /// Successfully saved {key} = {value} to the secret store. + /// + internal static string FormatMessage_Saved_Secret(object key, object value) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secret", "key", "value"), key, value); + } + + /// + /// Successfully saved {number} secrets to the secret store. + /// + internal static string Message_Saved_Secrets + { + get { return GetString("Message_Saved_Secrets"); } + } + + /// + /// Successfully saved {number} secrets to the secret store. + /// + internal static string FormatMessage_Saved_Secrets(object number) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secrets", "number"), number); + } + + /// + /// Secrets file path {secretsFilePath}. + /// + internal static string Message_Secret_File_Path + { + get { return GetString("Message_Secret_File_Path"); } + } + + /// + /// Secrets file path {secretsFilePath}. + /// + internal static string FormatMessage_Secret_File_Path(object secretsFilePath) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_File_Path", "secretsFilePath"), secretsFilePath); + } + + /// + /// {key} = {value} + /// + internal static string Message_Secret_Value_Format + { + get { return GetString("Message_Secret_Value_Format"); } + } + + /// + /// {key} = {value} + /// + internal static string FormatMessage_Secret_Value_Format(object key, object value) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_Value_Format", "key", "value"), key, value); + } + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Tools/dotnet-user-secrets/src/Resources.resx b/src/Tools/dotnet-user-secrets/src/Resources.resx new file mode 100644 index 0000000000..c9222930fc --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/Resources.resx @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Command failed : {message} + + + Missing parameter value for '{name}'. +Use the '--help' flag to see info. + + + Cannot find '{key}' in the secret store. + + + Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + + + No secrets configured for this application. + + + Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + + + Could not find the global property 'UserSecretsId' in MSBuild project '{project}'. Ensure this property is set in the project or use the '--id' command line option. + + + The project file '{path}' does not exist. + + + Could not load the MSBuild project '{project}'. + + + Project file path {project}. + + + Successfully saved {key} = {value} to the secret store. + + + Successfully saved {number} secrets to the secret store. + + + Secrets file path {secretsFilePath}. + + + {key} = {value} + + \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/src/assets/SecretManager.targets b/src/Tools/dotnet-user-secrets/src/assets/SecretManager.targets new file mode 100644 index 0000000000..8cf63eac00 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/assets/SecretManager.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj b/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj new file mode 100644 index 0000000000..a66a31dd7e --- /dev/null +++ b/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj @@ -0,0 +1,42 @@ + + + + netcoreapp2.1 + exe + Command line tool to manage user secrets for Microsoft.Extensions.Configuration. + false + Microsoft.Extensions.SecretManager.Tools + configuration;secrets;usersecrets + true + + win-x64;win-x86 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tools/dotnet-user-secrets/test/MsBuildProjectFinderTest.cs b/src/Tools/dotnet-user-secrets/test/MsBuildProjectFinderTest.cs new file mode 100644 index 0000000000..6e7a290834 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/MsBuildProjectFinderTest.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Xunit; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests +{ + public class MsBuildProjectFinderTest + { + [Theory] + [InlineData(".csproj")] + [InlineData(".vbproj")] + [InlineData(".fsproj")] + public void FindsSingleProject(string extension) + { + using (var files = new TemporaryFileProvider()) + { + var filename = "TestProject" + extension; + files.Add(filename, ""); + + var finder = new MsBuildProjectFinder(files.Root); + + Assert.Equal(Path.Combine(files.Root, filename), finder.FindMsBuildProject(null)); + } + } + + [Fact] + public void ThrowsWhenNoFile() + { + using (var files = new TemporaryFileProvider()) + { + var finder = new MsBuildProjectFinder(files.Root); + + Assert.Throws(() => finder.FindMsBuildProject(null)); + } + } + + [Fact] + public void DoesNotMatchXproj() + { + using (var files = new TemporaryFileProvider()) + { + var finder = new MsBuildProjectFinder(files.Root); + files.Add("test.xproj", ""); + + Assert.Throws(() => finder.FindMsBuildProject(null)); + } + } + + [Fact] + public void ThrowsWhenMultipleFile() + { + using (var files = new TemporaryFileProvider()) + { + files.Add("Test1.csproj", ""); + files.Add("Test2.csproj", ""); + var finder = new MsBuildProjectFinder(files.Root); + + Assert.Throws(() => finder.FindMsBuildProject(null)); + } + } + + [Fact] + public void ThrowsWhenFileDoesNotExist() + { + using (var files = new TemporaryFileProvider()) + { + var finder = new MsBuildProjectFinder(files.Root); + + Assert.Throws(() => finder.FindMsBuildProject("test.csproj")); + } + } + + [Fact] + public void ThrowsWhenRootDoesNotExist() + { + var files = new TemporaryFileProvider(); + var finder = new MsBuildProjectFinder(files.Root); + files.Dispose(); + Assert.Throws(() => finder.FindMsBuildProject(null)); + } + } +} diff --git a/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs b/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs new file mode 100644 index 0000000000..ee7ca55247 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs @@ -0,0 +1,327 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.Extensions.Configuration.UserSecrets; +using Microsoft.Extensions.Configuration.UserSecrets.Tests; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests +{ + public class SecretManagerTests : IClassFixture + { + private readonly TestConsole _console; + private readonly UserSecretsTestFixture _fixture; + private readonly StringBuilder _output = new StringBuilder(); + + public SecretManagerTests(UserSecretsTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + + _console = new TestConsole(output) + { + Error = new StringWriter(_output), + Out = new StringWriter(_output), + }; + } + + private Program CreateProgram() + { + return new Program(_console, Directory.GetCurrentDirectory()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Error_MissingId(string id) + { + var project = Path.Combine(_fixture.CreateProject(id), "TestProject.csproj"); + var secretManager = CreateProgram(); + + secretManager.RunInternal("list", "-p", project); + Assert.Contains(Resources.FormatError_ProjectMissingId(project), _output.ToString()); + } + + [Fact] + public void Error_InvalidProjectFormat() + { + var project = Path.Combine(_fixture.CreateProject("<"), "TestProject.csproj"); + var secretManager = CreateProgram(); + + secretManager.RunInternal("list", "-p", project); + Assert.Contains(Resources.FormatError_ProjectFailedToLoad(project), _output.ToString()); + } + + [Fact] + public void Error_Project_DoesNotExist() + { + var projectPath = Path.Combine(_fixture.GetTempSecretProject(), "does_not_exist", "TestProject.csproj"); + var secretManager = CreateProgram(); + + secretManager.RunInternal("list", "--project", projectPath); + Assert.Contains(Resources.FormatError_ProjectPath_NotFound(projectPath), _output.ToString()); + } + + [Fact] + public void SupportsRelativePaths() + { + var projectPath = _fixture.GetTempSecretProject(); + var cwd = Path.Combine(projectPath, "nested1"); + Directory.CreateDirectory(cwd); + var secretManager = new Program(_console, cwd); + + secretManager.RunInternal("list", "-p", ".." + Path.DirectorySeparatorChar, "--verbose"); + + Assert.Contains(Resources.FormatMessage_Project_File_Path(Path.Combine(cwd, "..", "TestProject.csproj")), _output.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SetSecrets(bool fromCurrentDirectory) + { + var secrets = new KeyValuePair[] + { + new KeyValuePair("key1", Guid.NewGuid().ToString()), + new KeyValuePair("Facebook:AppId", Guid.NewGuid().ToString()), + new KeyValuePair(@"key-@\/.~123!#$%^&*())-+==", @"key-@\/.~123!#$%^&*())-+=="), + new KeyValuePair("key2", string.Empty) + }; + + var projectPath = _fixture.GetTempSecretProject(); + var dir = fromCurrentDirectory + ? projectPath + : Path.GetTempPath(); + var secretManager = new Program(_console, dir); + + foreach (var secret in secrets) + { + var parameters = fromCurrentDirectory ? + new string[] { "set", secret.Key, secret.Value } : + new string[] { "set", secret.Key, secret.Value, "-p", projectPath }; + secretManager.RunInternal(parameters); + } + + foreach (var keyValue in secrets) + { + Assert.Contains( + string.Format("Successfully saved {0} = {1} to the secret store.", keyValue.Key, keyValue.Value), + _output.ToString()); + } + + _output.Clear(); + var args = fromCurrentDirectory + ? new string[] { "list" } + : new string[] { "list", "-p", projectPath }; + secretManager.RunInternal(args); + foreach (var keyValue in secrets) + { + Assert.Contains( + string.Format("{0} = {1}", keyValue.Key, keyValue.Value), + _output.ToString()); + } + + // Remove secrets. + _output.Clear(); + foreach (var secret in secrets) + { + var parameters = fromCurrentDirectory ? + new string[] { "remove", secret.Key } : + new string[] { "remove", secret.Key, "-p", projectPath }; + secretManager.RunInternal(parameters); + } + + // Verify secrets are removed. + _output.Clear(); + args = fromCurrentDirectory + ? new string[] { "list" } + : new string[] { "list", "-p", projectPath }; + secretManager.RunInternal(args); + Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); + } + + [Fact] + public void SetSecret_Update_Existing_Secret() + { + var projectPath = _fixture.GetTempSecretProject(); + var secretManager = CreateProgram(); + + secretManager.RunInternal("set", "secret1", "value1", "-p", projectPath); + Assert.Contains("Successfully saved secret1 = value1 to the secret store.", _output.ToString()); + secretManager.RunInternal("set", "secret1", "value2", "-p", projectPath); + Assert.Contains("Successfully saved secret1 = value2 to the secret store.", _output.ToString()); + + _output.Clear(); + + secretManager.RunInternal("list", "-p", projectPath); + Assert.Contains("secret1 = value2", _output.ToString()); + } + + [Fact] + public void SetSecret_With_Verbose_Flag() + { + string secretId; + var projectPath = _fixture.GetTempSecretProject(out secretId); + var secretManager = CreateProgram(); + + secretManager.RunInternal("-v", "set", "secret1", "value1", "-p", projectPath); + Assert.Contains(string.Format("Project file path {0}.", Path.Combine(projectPath, "TestProject.csproj")), _output.ToString()); + Assert.Contains(string.Format("Secrets file path {0}.", PathHelper.GetSecretsPathFromSecretsId(secretId)), _output.ToString()); + Assert.Contains("Successfully saved secret1 = value1 to the secret store.", _output.ToString()); + _output.Clear(); + + secretManager.RunInternal("-v", "list", "-p", projectPath); + + Assert.Contains(string.Format("Project file path {0}.", Path.Combine(projectPath, "TestProject.csproj")), _output.ToString()); + Assert.Contains(string.Format("Secrets file path {0}.", PathHelper.GetSecretsPathFromSecretsId(secretId)), _output.ToString()); + Assert.Contains("secret1 = value1", _output.ToString()); + } + + [Fact] + public void Remove_Non_Existing_Secret() + { + var projectPath = _fixture.GetTempSecretProject(); + var secretManager = CreateProgram(); + secretManager.RunInternal("remove", "secret1", "-p", projectPath); + Assert.Contains("Cannot find 'secret1' in the secret store.", _output.ToString()); + } + + [Fact] + public void Remove_Is_Case_Insensitive() + { + var projectPath = _fixture.GetTempSecretProject(); + var secretManager = CreateProgram(); + secretManager.RunInternal("set", "SeCreT1", "value", "-p", projectPath); + secretManager.RunInternal("list", "-p", projectPath); + Assert.Contains("SeCreT1 = value", _output.ToString()); + secretManager.RunInternal("remove", "secret1", "-p", projectPath); + + _output.Clear(); + secretManager.RunInternal("list", "-p", projectPath); + + Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); + } + + [Fact] + public void List_Flattens_Nested_Objects() + { + string secretId; + var projectPath = _fixture.GetTempSecretProject(out secretId); + var secretsFile = PathHelper.GetSecretsPathFromSecretsId(secretId); + Directory.CreateDirectory(Path.GetDirectoryName(secretsFile)); + File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abcd郩˙î""} }", Encoding.UTF8); + var secretManager = CreateProgram(); + secretManager.RunInternal("list", "-p", projectPath); + Assert.Contains("AzureAd:ClientSecret = abcd郩˙î", _output.ToString()); + } + + [Fact] + public void List_Json() + { + string id; + var projectPath = _fixture.GetTempSecretProject(out id); + var secretsFile = PathHelper.GetSecretsPathFromSecretsId(id); + Directory.CreateDirectory(Path.GetDirectoryName(secretsFile)); + File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abcd郩˙î""} }", Encoding.UTF8); + var secretManager = new Program(_console, Path.GetDirectoryName(projectPath)); + secretManager.RunInternal("list", "--id", id, "--json"); + var stdout = _output.ToString(); + Assert.Contains("//BEGIN", stdout); + Assert.Contains(@"""AzureAd:ClientSecret"": ""abcd郩˙î""", stdout); + Assert.Contains("//END", stdout); + } + + [Fact] + public void Set_Flattens_Nested_Objects() + { + string secretId; + var projectPath = _fixture.GetTempSecretProject(out secretId); + var secretsFile = PathHelper.GetSecretsPathFromSecretsId(secretId); + Directory.CreateDirectory(Path.GetDirectoryName(secretsFile)); + File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abcd郩˙î""} }", Encoding.UTF8); + var secretManager = CreateProgram(); + secretManager.RunInternal("set", "AzureAd:ClientSecret", "¡™£¢∞", "-p", projectPath); + secretManager.RunInternal("list", "-p", projectPath); + + Assert.Contains("AzureAd:ClientSecret = ¡™£¢∞", _output.ToString()); + var fileContents = File.ReadAllText(secretsFile, Encoding.UTF8); + Assert.Equal(@"{ + ""AzureAd:ClientSecret"": ""¡™£¢∞"" +}", + fileContents, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); + } + + [Fact] + public void List_Empty_Secrets_File() + { + var projectPath = _fixture.GetTempSecretProject(); + var secretManager = CreateProgram(); + secretManager.RunInternal("list", "-p", projectPath); + Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Clear_Secrets(bool fromCurrentDirectory) + { + var projectPath = _fixture.GetTempSecretProject(); + + var dir = fromCurrentDirectory + ? projectPath + : Path.GetTempPath(); + + var secretManager = new Program(_console, dir); + + var secrets = new KeyValuePair[] + { + new KeyValuePair("key1", Guid.NewGuid().ToString()), + new KeyValuePair("Facebook:AppId", Guid.NewGuid().ToString()), + new KeyValuePair(@"key-@\/.~123!#$%^&*())-+==", @"key-@\/.~123!#$%^&*())-+=="), + new KeyValuePair("key2", string.Empty) + }; + + foreach (var secret in secrets) + { + var parameters = fromCurrentDirectory ? + new string[] { "set", secret.Key, secret.Value } : + new string[] { "set", secret.Key, secret.Value, "-p", projectPath }; + secretManager.RunInternal(parameters); + } + + foreach (var keyValue in secrets) + { + Assert.Contains( + string.Format("Successfully saved {0} = {1} to the secret store.", keyValue.Key, keyValue.Value), + _output.ToString()); + } + + // Verify secrets are persisted. + _output.Clear(); + var args = fromCurrentDirectory ? + new string[] { "list" } : + new string[] { "list", "-p", projectPath }; + secretManager.RunInternal(args); + foreach (var keyValue in secrets) + { + Assert.Contains( + string.Format("{0} = {1}", keyValue.Key, keyValue.Value), + _output.ToString()); + } + + // Clear secrets. + _output.Clear(); + args = fromCurrentDirectory ? new string[] { "clear" } : new string[] { "clear", "-p", projectPath }; + secretManager.RunInternal(args); + + args = fromCurrentDirectory ? new string[] { "list" } : new string[] { "list", "-p", projectPath }; + secretManager.RunInternal(args); + Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-user-secrets/test/SetCommandTest.cs b/src/Tools/dotnet-user-secrets/test/SetCommandTest.cs new file mode 100644 index 0000000000..99941fad91 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/SetCommandTest.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Collections.Generic; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests +{ + + public class SetCommandTest + { + private readonly ITestOutputHelper _output; + + public SetCommandTest(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void SetsFromPipedInput() + { + var input = @" +{ + ""Key1"": ""str value"", +""Key2"": 1234, +""Key3"": false +}"; + var testConsole = new TestConsole(_output) + { + IsInputRedirected = true, + In = new StringReader(input) + }; + var secretStore = new TestSecretsStore(_output); + var command = new SetCommand.FromStdInStrategy(); + + command.Execute(new CommandContext(secretStore, new TestReporter(_output), testConsole)); + + Assert.Equal(3, secretStore.Count); + Assert.Equal("str value", secretStore["Key1"]); + Assert.Equal("1234", secretStore["Key2"]); + Assert.Equal("False", secretStore["Key3"]); + } + + [Fact] + public void ParsesNestedObjects() + { + var input = @" + { + ""Key1"": { + ""nested"" : ""value"" + }, + ""array"": [ 1, 2 ] + }"; + + var testConsole = new TestConsole(_output) + { + IsInputRedirected = true, + In = new StringReader(input) + }; + var secretStore = new TestSecretsStore(_output); + var command = new SetCommand.FromStdInStrategy(); + + command.Execute(new CommandContext(secretStore, new TestReporter(_output), testConsole)); + + Assert.Equal(3, secretStore.Count); + Assert.True(secretStore.ContainsKey("Key1:nested")); + Assert.Equal("value", secretStore["Key1:nested"]); + Assert.Equal("1", secretStore["array:0"]); + Assert.Equal("2", secretStore["array:1"]); + } + + [Fact] + public void OnlyPipesInIfNoArgs() + { + var testConsole = new TestConsole(_output) + { + IsInputRedirected = true, + In = new StringReader("") + }; + var options = CommandLineOptions.Parse(new [] { "set", "key", "value" }, testConsole); + Assert.IsType(options.Command); + } + + private class TestSecretsStore : SecretsStore + { + public TestSecretsStore(ITestOutputHelper output) + : base("xyz", new TestReporter(output)) + { + } + + protected override IDictionary Load(string userSecretsId) + { + return new Dictionary(); + } + + public override void Save() + { + // noop + } + } + } +} diff --git a/src/Tools/dotnet-user-secrets/test/TemporaryFileProvider.cs b/src/Tools/dotnet-user-secrets/test/TemporaryFileProvider.cs new file mode 100644 index 0000000000..34c2e8e2ba --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/TemporaryFileProvider.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests +{ + internal class TemporaryFileProvider : IDisposable + { + public TemporaryFileProvider() + { + Root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "tmpfiles", Guid.NewGuid().ToString())).FullName; + } + + public string Root { get; } + + public void Add(string filename, string contents) + { + File.WriteAllText(Path.Combine(Root, filename), contents, Encoding.UTF8); + } + + public void Dispose() + { + Directory.Delete(Root, recursive: true); + } + } +} diff --git a/src/Tools/dotnet-user-secrets/test/UserSecretsTestFixture.cs b/src/Tools/dotnet-user-secrets/test/UserSecretsTestFixture.cs new file mode 100644 index 0000000000..14e62805bc --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/UserSecretsTestFixture.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Extensions.Configuration.UserSecrets.Tests +{ + public class UserSecretsTestFixture : IDisposable + { + private Stack _disposables = new Stack(); + + public const string TestSecretsId = "b918174fa80346bbb7f4a386729c0eff"; + + public UserSecretsTestFixture() + { + _disposables.Push(() => TryDelete(Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(TestSecretsId)))); + } + + public void Dispose() + { + while (_disposables.Count > 0) + { + _disposables.Pop()?.Invoke(); + } + } + + public string GetTempSecretProject() + { + string userSecretsId; + return GetTempSecretProject(out userSecretsId); + } + + private const string ProjectTemplate = @" + + Exe + netcoreapp2.1 + {0} + false + + + + + +"; + + public string GetTempSecretProject(out string userSecretsId) + { + userSecretsId = Guid.NewGuid().ToString(); + return CreateProject(userSecretsId); + } + + public string CreateProject(string userSecretsId) + { + var projectPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "usersecretstest", Guid.NewGuid().ToString())); + var prop = string.IsNullOrEmpty(userSecretsId) + ? string.Empty + : $"{userSecretsId}"; + + File.WriteAllText( + Path.Combine(projectPath.FullName, "TestProject.csproj"), + string.Format(ProjectTemplate, prop)); + + var id = userSecretsId; + _disposables.Push(() => + { + try + { + // may throw if id is bad + var secretsDir = Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(id)); + TryDelete(secretsDir); + } + catch { } + }); + _disposables.Push(() => TryDelete(projectPath.FullName)); + + return projectPath.FullName; + } + + private static void TryDelete(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + catch (Exception) + { + // Ignore failures. + Console.WriteLine("Failed to delete " + directory); + } + } + } +} diff --git a/src/Tools/dotnet-user-secrets/test/dotnet-user-secrets.Tests.csproj b/src/Tools/dotnet-user-secrets/test/dotnet-user-secrets.Tests.csproj new file mode 100644 index 0000000000..0254a866e8 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/dotnet-user-secrets.Tests.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.1 + Microsoft.Extensions.SecretManager.Tools.Tests + + + + + + + + + + + + + + + + diff --git a/src/Tools/dotnet-watch/README.md b/src/Tools/dotnet-watch/README.md new file mode 100644 index 0000000000..ff7102a92e --- /dev/null +++ b/src/Tools/dotnet-watch/README.md @@ -0,0 +1,88 @@ +dotnet-watch +============ +`dotnet-watch` is a file watcher for `dotnet` that restarts the specified application when changes in the source code are detected. + +### How To Use + +The command must be executed in the directory that contains the project to be watched. + + Usage: dotnet watch [options] [[--] ...] + + Options: + -?|-h|--help Show help information + -q|--quiet Suppresses all output except warnings and errors + -v|--verbose Show verbose output + +Add `watch` after `dotnet` and before the command arguments that you want to run: + +| What you want to run | Dotnet watch command | +| ---------------------------------------------- | -------------------------------------------------------- | +| dotnet run | dotnet **watch** run | +| dotnet run --arg1 value1 | dotnet **watch** run --arg1 value | +| dotnet run --framework net451 -- --arg1 value1 | dotnet **watch** run --framework net451 -- --arg1 value1 | +| dotnet test | dotnet **watch** test | + +### Environment variables + +Some configuration options can be passed to `dotnet watch` through environment variables. The available variables are: + +| Variable | Effect | +| ---------------------------------------------- | -------------------------------------------------------- | +| DOTNET_USE_POLLING_FILE_WATCHER | If set to "1" or "true", `dotnet watch` will use a polling file watcher instead of CoreFx's `FileSystemWatcher`. Used when watching files on network shares or Docker mounted volumes. | + +### MSBuild + +dotnet-watch can be configured from the MSBuild project file being watched. + +**Watch items** + +dotnet-watch will watch all items in the **Watch** item group. +By default, this group inclues all items in **Compile** and **EmbeddedResource**. + +More items can be added to watch in a project file by adding items to 'Watch'. + +```xml + + + + +``` + +dotnet-watch will ignore Compile and EmbeddedResource items with the `Watch="false"` attribute. + +Example: + +```xml + + + + + + +``` + +**Project References** + +By default, dotnet-watch will scan the entire graph of project references and watch all files within those projects. + +dotnet-watch will ignore project references with the `Watch="false"` attribute. + +```xml + + + +``` + + +**Advanced configuration** + +dotnet-watch performs a design-time build to find items to watch. +When this build is run, dotnet-watch will set the property `DotNetWatchBuild=true`. + +Example: + +```xml + + + +``` diff --git a/src/Tools/dotnet-watch/src/CommandLineOptions.cs b/src/Tools/dotnet-watch/src/CommandLineOptions.cs new file mode 100644 index 0000000000..e6e23890d5 --- /dev/null +++ b/src/Tools/dotnet-watch/src/CommandLineOptions.cs @@ -0,0 +1,117 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.DotNet.Watcher.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher +{ + internal class CommandLineOptions + { + public string Project { get; private set; } + public bool IsHelp { get; private set; } + public bool IsQuiet { get; private set; } + public bool IsVerbose { get; private set; } + public IList RemainingArguments { get; private set; } + public bool ListFiles { get; private set; } + + public static bool IsPollingEnabled + { + get + { + var envVar = Environment.GetEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER"); + return envVar != null && + (envVar.Equals("1", StringComparison.OrdinalIgnoreCase) || + envVar.Equals("true", StringComparison.OrdinalIgnoreCase)); + } + } + + public static CommandLineOptions Parse(string[] args, IConsole console) + { + Ensure.NotNull(args, nameof(args)); + Ensure.NotNull(console, nameof(console)); + + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + Name = "dotnet watch", + FullName = "Microsoft DotNet File Watcher", + Out = console.Out, + Error = console.Error, + AllowArgumentSeparator = true, + ExtendedHelpText = @" +Environment variables: + + DOTNET_USE_POLLING_FILE_WATCHER + When set to '1' or 'true', dotnet-watch will poll the file system for + changes. This is required for some file systems, such as network shares, + Docker mounted volumes, and other virtual file systems. + + DOTNET_WATCH + dotnet-watch sets this variable to '1' on all child processes launched. + +Remarks: + The special option '--' is used to delimit the end of the options and + the beginning of arguments that will be passed to the child dotnet process. + Its use is optional. When the special option '--' is not used, + dotnet-watch will use the first unrecognized argument as the beginning + of all arguments passed into the child dotnet process. + + For example: dotnet watch -- --verbose run + + Even though '--verbose' is an option dotnet-watch supports, the use of '--' + indicates that '--verbose' should be treated instead as an argument for + dotnet-run. + +Examples: + dotnet watch run + dotnet watch test +" + }; + + app.HelpOption("-?|-h|--help"); + // TODO multiple shouldn't be too hard to support + var optProjects = app.Option("-p|--project ", "The project to watch", + CommandOptionType.SingleValue); + + var optQuiet = app.Option("-q|--quiet", "Suppresses all output except warnings and errors", + CommandOptionType.NoValue); + var optVerbose = app.VerboseOption(); + + var optList = app.Option("--list", "Lists all discovered files without starting the watcher", + CommandOptionType.NoValue); + + app.VersionOptionFromAssemblyAttributes(typeof(Program).GetTypeInfo().Assembly); + + if (app.Execute(args) != 0) + { + return null; + } + + if (optQuiet.HasValue() && optVerbose.HasValue()) + { + throw new CommandParsingException(app, Resources.Error_QuietAndVerboseSpecified); + } + + if (app.RemainingArguments.Count == 0 + && !app.IsShowingInformation + && !optList.HasValue()) + { + app.ShowHelp(); + } + + return new CommandLineOptions + { + Project = optProjects.Value(), + IsQuiet = optQuiet.HasValue(), + IsVerbose = optVerbose.HasValue(), + RemainingArguments = app.RemainingArguments, + IsHelp = app.IsShowingInformation, + ListFiles = optList.HasValue(), + }; + } + } +} diff --git a/src/Tools/dotnet-watch/src/DotNetWatcher.cs b/src/Tools/dotnet-watch/src/DotNetWatcher.cs new file mode 100644 index 0000000000..258ba0ab1e --- /dev/null +++ b/src/Tools/dotnet-watch/src/DotNetWatcher.cs @@ -0,0 +1,102 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.Watcher.Internal; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher +{ + public class DotNetWatcher + { + private readonly IReporter _reporter; + private readonly ProcessRunner _processRunner; + + public DotNetWatcher(IReporter reporter) + { + Ensure.NotNull(reporter, nameof(reporter)); + + _reporter = reporter; + _processRunner = new ProcessRunner(reporter); + } + + public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, + CancellationToken cancellationToken) + { + Ensure.NotNull(processSpec, nameof(processSpec)); + + var cancelledTaskSource = new TaskCompletionSource(); + cancellationToken.Register(state => ((TaskCompletionSource) state).TrySetResult(null), + cancelledTaskSource); + + while (true) + { + var fileSet = await fileSetFactory.CreateAsync(cancellationToken); + + if (fileSet == null) + { + _reporter.Error("Failed to find a list of files to watch"); + return; + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + using (var currentRunCancellationSource = new CancellationTokenSource()) + using (var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + currentRunCancellationSource.Token)) + using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter)) + { + var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token); + var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token); + + var args = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments); + _reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}"); + + _reporter.Output("Started"); + + var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); + + // Regardless of the which task finished first, make sure everything is cancelled + // and wait for dotnet to exit. We don't want orphan processes + currentRunCancellationSource.Cancel(); + + await Task.WhenAll(processTask, fileSetTask); + + if (processTask.Result == 0) + { + _reporter.Output("Exited"); + } + else + { + _reporter.Error($"Exited with error code {processTask.Result}"); + } + + if (finishedTask == cancelledTaskSource.Task || cancellationToken.IsCancellationRequested) + { + return; + } + + if (finishedTask == processTask) + { + _reporter.Warn("Waiting for a file to change before restarting dotnet..."); + + // Now wait for a file to change before restarting process + await fileSetWatcher.GetChangedFileAsync(cancellationToken); + } + + if (!string.IsNullOrEmpty(fileSetTask.Result)) + { + _reporter.Output($"File changed: {fileSetTask.Result}"); + } + } + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/IFileSet.cs b/src/Tools/dotnet-watch/src/IFileSet.cs new file mode 100644 index 0000000000..7554d3f542 --- /dev/null +++ b/src/Tools/dotnet-watch/src/IFileSet.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher +{ + public interface IFileSet : IEnumerable + { + bool Contains(string filePath); + } +} diff --git a/src/Tools/dotnet-watch/src/IFileSetFactory.cs b/src/Tools/dotnet-watch/src/IFileSetFactory.cs new file mode 100644 index 0000000000..6a70c06a4c --- /dev/null +++ b/src/Tools/dotnet-watch/src/IFileSetFactory.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Watcher +{ + public interface IFileSetFactory + { + Task CreateAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/Internal/FileSet.cs b/src/Tools/dotnet-watch/src/Internal/FileSet.cs new file mode 100644 index 0000000000..736ce43677 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileSet.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileSet : IFileSet + { + private readonly HashSet _files; + + public FileSet(IEnumerable files) + { + _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); + } + + public bool Contains(string filePath) => _files.Contains(filePath); + + public int Count => _files.Count; + + public IEnumerator GetEnumerator() => _files.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileSetWatcher.cs b/src/Tools/dotnet-watch/src/Internal/FileSetWatcher.cs new file mode 100644 index 0000000000..3dc56cc452 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileSetWatcher.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileSetWatcher : IDisposable + { + private readonly FileWatcher _fileWatcher; + private readonly IFileSet _fileSet; + + public FileSetWatcher(IFileSet fileSet, IReporter reporter) + { + Ensure.NotNull(fileSet, nameof(fileSet)); + + _fileSet = fileSet; + _fileWatcher = new FileWatcher(reporter); + } + + public async Task GetChangedFileAsync(CancellationToken cancellationToken) + { + foreach (var file in _fileSet) + { + _fileWatcher.WatchDirectory(Path.GetDirectoryName(file)); + } + + var tcs = new TaskCompletionSource(); + cancellationToken.Register(() => tcs.TrySetResult(null)); + + Action callback = path => + { + if (_fileSet.Contains(path)) + { + tcs.TrySetResult(path); + } + }; + + _fileWatcher.OnFileChange += callback; + var changedFile = await tcs.Task; + _fileWatcher.OnFileChange -= callback; + + return changedFile; + } + + public void Dispose() + { + _fileWatcher.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileWatcher.cs b/src/Tools/dotnet-watch/src/Internal/FileWatcher.cs new file mode 100644 index 0000000000..65c2ff9285 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileWatcher.cs @@ -0,0 +1,143 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileWatcher + { + private bool _disposed; + + private readonly IDictionary _watchers; + private readonly IReporter _reporter; + + public FileWatcher() + : this(NullReporter.Singleton) + { } + + public FileWatcher(IReporter reporter) + { + _reporter = reporter ?? throw new ArgumentNullException(nameof(reporter)); + _watchers = new Dictionary(); + } + + public event Action OnFileChange; + + public void WatchDirectory(string directory) + { + EnsureNotDisposed(); + AddDirectoryWatcher(directory); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + foreach (var watcher in _watchers) + { + watcher.Value.OnFileChange -= WatcherChangedHandler; + watcher.Value.OnError -= WatcherErrorHandler; + watcher.Value.Dispose(); + } + + _watchers.Clear(); + } + + private void AddDirectoryWatcher(string directory) + { + directory = EnsureTrailingSlash(directory); + + var alreadyWatched = _watchers + .Where(d => directory.StartsWith(d.Key)) + .Any(); + + if (alreadyWatched) + { + return; + } + + var redundantWatchers = _watchers + .Where(d => d.Key.StartsWith(directory)) + .Select(d => d.Key) + .ToList(); + + if (redundantWatchers.Any()) + { + foreach (var watcher in redundantWatchers) + { + DisposeWatcher(watcher); + } + } + + var newWatcher = FileWatcherFactory.CreateWatcher(directory); + newWatcher.OnFileChange += WatcherChangedHandler; + newWatcher.OnError += WatcherErrorHandler; + newWatcher.EnableRaisingEvents = true; + + _watchers.Add(directory, newWatcher); + } + + private void WatcherErrorHandler(object sender, Exception error) + { + if (sender is IFileSystemWatcher watcher) + { + _reporter.Warn($"The file watcher observing '{watcher.BasePath}' encountered an error: {error.Message}"); + } + } + + private void WatcherChangedHandler(object sender, string changedPath) + { + NotifyChange(changedPath); + } + + private void NotifyChange(string path) + { + if (OnFileChange != null) + { + OnFileChange(path); + } + } + + private void DisposeWatcher(string directory) + { + var watcher = _watchers[directory]; + _watchers.Remove(directory); + + watcher.EnableRaisingEvents = false; + + watcher.OnFileChange -= WatcherChangedHandler; + watcher.OnError -= WatcherErrorHandler; + + watcher.Dispose(); + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(FileWatcher)); + } + } + + private static string EnsureTrailingSlash(string path) + { + if (!string.IsNullOrEmpty(path) && + path[path.Length - 1] != Path.DirectorySeparatorChar) + { + return path + Path.DirectorySeparatorChar; + } + + return path; + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/Internal/FileWatcher/DotnetFileWatcher.cs b/src/Tools/dotnet-watch/src/Internal/FileWatcher/DotnetFileWatcher.cs new file mode 100644 index 0000000000..d1103c41e6 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileWatcher/DotnetFileWatcher.cs @@ -0,0 +1,136 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel; +using System.IO; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal class DotnetFileWatcher : IFileSystemWatcher + { + private readonly Func _watcherFactory; + + private FileSystemWatcher _fileSystemWatcher; + + private readonly object _createLock = new object(); + + public DotnetFileWatcher(string watchedDirectory) + : this(watchedDirectory, DefaultWatcherFactory) + { + } + + internal DotnetFileWatcher(string watchedDirectory, Func fileSystemWatcherFactory) + { + Ensure.NotNull(fileSystemWatcherFactory, nameof(fileSystemWatcherFactory)); + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); + + BasePath = watchedDirectory; + _watcherFactory = fileSystemWatcherFactory; + CreateFileSystemWatcher(); + } + + public event EventHandler OnFileChange; + + public event EventHandler OnError; + + public string BasePath { get; } + + private static FileSystemWatcher DefaultWatcherFactory(string watchedDirectory) + { + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); + + return new FileSystemWatcher(watchedDirectory); + } + + private void WatcherErrorHandler(object sender, ErrorEventArgs e) + { + var exception = e.GetException(); + + // Win32Exception may be triggered when setting EnableRaisingEvents on a file system type + // that is not supported, such as a network share. Don't attempt to recreate the watcher + // in this case as it will cause a StackOverflowException + if (!(exception is Win32Exception)) + { + // Recreate the watcher if it is a recoverable error. + CreateFileSystemWatcher(); + } + + OnError?.Invoke(this, exception); + } + + private void WatcherRenameHandler(object sender, RenamedEventArgs e) + { + NotifyChange(e.OldFullPath); + NotifyChange(e.FullPath); + + if (Directory.Exists(e.FullPath)) + { + foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories)) + { + // Calculated previous path of this moved item. + var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1)); + NotifyChange(oldLocation); + NotifyChange(newLocation); + } + } + } + + private void WatcherChangeHandler(object sender, FileSystemEventArgs e) + { + NotifyChange(e.FullPath); + } + + private void NotifyChange(string fullPath) + { + // Only report file changes + OnFileChange?.Invoke(this, fullPath); + } + + private void CreateFileSystemWatcher() + { + lock (_createLock) + { + bool enableEvents = false; + + if (_fileSystemWatcher != null) + { + enableEvents = _fileSystemWatcher.EnableRaisingEvents; + + _fileSystemWatcher.EnableRaisingEvents = false; + + _fileSystemWatcher.Created -= WatcherChangeHandler; + _fileSystemWatcher.Deleted -= WatcherChangeHandler; + _fileSystemWatcher.Changed -= WatcherChangeHandler; + _fileSystemWatcher.Renamed -= WatcherRenameHandler; + _fileSystemWatcher.Error -= WatcherErrorHandler; + + _fileSystemWatcher.Dispose(); + } + + _fileSystemWatcher = _watcherFactory(BasePath); + _fileSystemWatcher.IncludeSubdirectories = true; + + _fileSystemWatcher.Created += WatcherChangeHandler; + _fileSystemWatcher.Deleted += WatcherChangeHandler; + _fileSystemWatcher.Changed += WatcherChangeHandler; + _fileSystemWatcher.Renamed += WatcherRenameHandler; + _fileSystemWatcher.Error += WatcherErrorHandler; + + _fileSystemWatcher.EnableRaisingEvents = enableEvents; + } + } + + public bool EnableRaisingEvents + { + get => _fileSystemWatcher.EnableRaisingEvents; + set => _fileSystemWatcher.EnableRaisingEvents = value; + } + + public void Dispose() + { + _fileSystemWatcher.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileWatcher/FileWatcherFactory.cs b/src/Tools/dotnet-watch/src/Internal/FileWatcher/FileWatcherFactory.cs new file mode 100644 index 0000000000..9c91176ec1 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileWatcher/FileWatcherFactory.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public static class FileWatcherFactory + { + public static IFileSystemWatcher CreateWatcher(string watchedDirectory) + => CreateWatcher(watchedDirectory, CommandLineOptions.IsPollingEnabled); + + public static IFileSystemWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher) + { + return usePollingWatcher ? + new PollingFileWatcher(watchedDirectory) : + new DotnetFileWatcher(watchedDirectory) as IFileSystemWatcher; + } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileWatcher/IFileSystemWatcher.cs b/src/Tools/dotnet-watch/src/Internal/FileWatcher/IFileSystemWatcher.cs new file mode 100644 index 0000000000..aaf5773449 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileWatcher/IFileSystemWatcher.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public interface IFileSystemWatcher : IDisposable + { + event EventHandler OnFileChange; + + event EventHandler OnError; + + string BasePath { get; } + + bool EnableRaisingEvents { get; set; } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/FileWatcher/PollingFileWatcher.cs b/src/Tools/dotnet-watch/src/Internal/FileWatcher/PollingFileWatcher.cs new file mode 100644 index 0000000000..1b503af774 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/FileWatcher/PollingFileWatcher.cs @@ -0,0 +1,247 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal class PollingFileWatcher : IFileSystemWatcher + { + // The minimum interval to rerun the scan + private static readonly TimeSpan _minRunInternal = TimeSpan.FromSeconds(.5); + + private readonly DirectoryInfo _watchedDirectory; + + private Dictionary _knownEntities = new Dictionary(); + private Dictionary _tempDictionary = new Dictionary(); + private HashSet _changes = new HashSet(); + + private Thread _pollingThread; + private bool _raiseEvents; + + private bool _disposed; + + public PollingFileWatcher(string watchedDirectory) + { + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); + + _watchedDirectory = new DirectoryInfo(watchedDirectory); + BasePath = _watchedDirectory.FullName; + + _pollingThread = new Thread(new ThreadStart(PollingLoop)); + _pollingThread.IsBackground = true; + _pollingThread.Name = nameof(PollingFileWatcher); + + CreateKnownFilesSnapshot(); + + _pollingThread.Start(); + } + + public event EventHandler OnFileChange; + +#pragma warning disable CS0067 // not used + public event EventHandler OnError; +#pragma warning restore + + public string BasePath { get; } + + public bool EnableRaisingEvents + { + get => _raiseEvents; + set + { + EnsureNotDisposed(); + _raiseEvents = value; + } + } + + private void PollingLoop() + { + var stopwatch = Stopwatch.StartNew(); + stopwatch.Start(); + + while (!_disposed) + { + if (stopwatch.Elapsed < _minRunInternal) + { + // Don't run too often + // The min wait time here can be double + // the value of the variable (FYI) + Thread.Sleep(_minRunInternal); + } + + stopwatch.Reset(); + + if (!_raiseEvents) + { + continue; + } + + CheckForChangedFiles(); + } + + stopwatch.Stop(); + } + + private void CreateKnownFilesSnapshot() + { + _knownEntities.Clear(); + + ForeachEntityInDirectory(_watchedDirectory, f => + { + _knownEntities.Add(f.FullName, new FileMeta(f)); + }); + } + + private void CheckForChangedFiles() + { + _changes.Clear(); + + ForeachEntityInDirectory(_watchedDirectory, f => + { + var fullFilePath = f.FullName; + + if (!_knownEntities.ContainsKey(fullFilePath)) + { + // New file + RecordChange(f); + } + else + { + var fileMeta = _knownEntities[fullFilePath]; + + try + { + if (fileMeta.FileInfo.LastWriteTime != f.LastWriteTime) + { + // File changed + RecordChange(f); + } + + _knownEntities[fullFilePath] = new FileMeta(fileMeta.FileInfo, true); + } + catch (FileNotFoundException) + { + _knownEntities[fullFilePath] = new FileMeta(fileMeta.FileInfo, false); + } + } + + _tempDictionary.Add(f.FullName, new FileMeta(f)); + }); + + foreach (var file in _knownEntities) + { + if (!file.Value.FoundAgain) + { + // File deleted + RecordChange(file.Value.FileInfo); + } + } + + NotifyChanges(); + + // Swap the two dictionaries + var swap = _knownEntities; + _knownEntities = _tempDictionary; + _tempDictionary = swap; + + _tempDictionary.Clear(); + } + + private void RecordChange(FileSystemInfo fileInfo) + { + if (fileInfo == null || + _changes.Contains(fileInfo.FullName) || + fileInfo.FullName.Equals(_watchedDirectory.FullName, StringComparison.Ordinal)) + { + return; + } + + _changes.Add(fileInfo.FullName); + if (fileInfo.FullName != _watchedDirectory.FullName) + { + var file = fileInfo as FileInfo; + if (file != null) + { + RecordChange(file.Directory); + } + else + { + var dir = fileInfo as DirectoryInfo; + if (dir != null) + { + RecordChange(dir.Parent); + } + } + } + } + + private void ForeachEntityInDirectory(DirectoryInfo dirInfo, Action fileAction) + { + if (!dirInfo.Exists) + { + return; + } + + var entities = dirInfo.EnumerateFileSystemInfos("*.*"); + foreach (var entity in entities) + { + fileAction(entity); + + var subdirInfo = entity as DirectoryInfo; + if (subdirInfo != null) + { + ForeachEntityInDirectory(subdirInfo, fileAction); + } + } + } + + private void NotifyChanges() + { + foreach (var path in _changes) + { + if (_disposed || !_raiseEvents) + { + break; + } + + if (OnFileChange != null) + { + OnFileChange(this, path); + } + } + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(PollingFileWatcher)); + } + } + + public void Dispose() + { + EnableRaisingEvents = false; + _disposed = true; + } + + private struct FileMeta + { + public FileMeta(FileSystemInfo fileInfo, bool foundAgain = false) + { + FileInfo = fileInfo; + FoundAgain = foundAgain; + } + + public FileSystemInfo FileInfo; + + public bool FoundAgain; + } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs new file mode 100644 index 0000000000..3cdf453067 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs @@ -0,0 +1,188 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class MsBuildFileSetFactory : IFileSetFactory + { + private const string TargetName = "GenerateWatchList"; + private const string WatchTargetsFileName = "DotNetWatch.targets"; + private readonly IReporter _reporter; + private readonly string _projectFile; + private readonly OutputSink _outputSink; + private readonly ProcessRunner _processRunner; + private readonly bool _waitOnError; + private readonly IReadOnlyList _buildFlags; + + public MsBuildFileSetFactory(IReporter reporter, + string projectFile, + bool waitOnError, + bool trace) + : this(reporter, projectFile, new OutputSink(), trace) + { + _waitOnError = waitOnError; + } + + // output sink is for testing + internal MsBuildFileSetFactory(IReporter reporter, + string projectFile, + OutputSink outputSink, + bool trace) + { + Ensure.NotNull(reporter, nameof(reporter)); + Ensure.NotNullOrEmpty(projectFile, nameof(projectFile)); + Ensure.NotNull(outputSink, nameof(outputSink)); + + _reporter = reporter; + _projectFile = projectFile; + _outputSink = outputSink; + _processRunner = new ProcessRunner(reporter); + _buildFlags = InitializeArgs(FindTargetsFile(), trace); + } + + public async Task CreateAsync(CancellationToken cancellationToken) + { + var watchList = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var projectDir = Path.GetDirectoryName(_projectFile); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var capture = _outputSink.StartCapture(); + // TODO adding files doesn't currently work. Need to provide a way to detect new files + // find files + var processSpec = new ProcessSpec + { + Executable = DotNetMuxer.MuxerPathOrDefault(), + WorkingDirectory = projectDir, + Arguments = new[] + { + "msbuild", + _projectFile, + $"/p:_DotNetWatchListFile={watchList}" + }.Concat(_buildFlags), + OutputCapture = capture + }; + + _reporter.Verbose($"Running MSBuild target '{TargetName}' on '{_projectFile}'"); + + var exitCode = await _processRunner.RunAsync(processSpec, cancellationToken); + + if (exitCode == 0 && File.Exists(watchList)) + { + var fileset = new FileSet( + File.ReadAllLines(watchList) + .Select(l => l?.Trim()) + .Where(l => !string.IsNullOrEmpty(l))); + + _reporter.Verbose($"Watching {fileset.Count} file(s) for changes"); +#if DEBUG + + foreach (var file in fileset) + { + _reporter.Verbose($" -> {file}"); + } + + Debug.Assert(fileset.All(Path.IsPathRooted), "All files should be rooted paths"); +#endif + + return fileset; + } + + _reporter.Error($"Error(s) finding watch items project file '{Path.GetFileName(_projectFile)}'"); + + _reporter.Output($"MSBuild output from target '{TargetName}':"); + _reporter.Output(string.Empty); + + foreach (var line in capture.Lines) + { + _reporter.Output($" {line}"); + } + + _reporter.Output(string.Empty); + + if (!_waitOnError) + { + return null; + } + else + { + _reporter.Warn("Fix the error to continue or press Ctrl+C to exit."); + + var fileSet = new FileSet(new[] { _projectFile }); + + using (var watcher = new FileSetWatcher(fileSet, _reporter)) + { + await watcher.GetChangedFileAsync(cancellationToken); + + _reporter.Output($"File changed: {_projectFile}"); + } + } + } + } + finally + { + if (File.Exists(watchList)) + { + File.Delete(watchList); + } + } + } + + private IReadOnlyList InitializeArgs(string watchTargetsFile, bool trace) + { + var args = new List + { + "/nologo", + "/v:n", + "/t:" + TargetName, + "/p:DotNetWatchBuild=true", // extensibility point for users + "/p:DesignTimeBuild=true", // don't do expensive things + "/p:CustomAfterMicrosoftCommonTargets=" + watchTargetsFile, + "/p:CustomAfterMicrosoftCommonCrossTargetingTargets=" + watchTargetsFile, + }; + + if (trace) + { + // enables capturing markers to know which projects have been visited + args.Add("/p:_DotNetWatchTraceOutput=true"); + } + + return args; + } + + private string FindTargetsFile() + { + var assemblyDir = Path.GetDirectoryName(typeof(MsBuildFileSetFactory).Assembly.Location); + var searchPaths = new[] + { + Path.Combine(AppContext.BaseDirectory, "assets"), + Path.Combine(assemblyDir, "assets"), + AppContext.BaseDirectory, + assemblyDir, + }; + + var targetPath = searchPaths.Select(p => Path.Combine(p, WatchTargetsFileName)).FirstOrDefault(File.Exists); + if (targetPath == null) + { + _reporter.Error("Fatal error: could not find DotNetWatch.targets"); + return null; + } + return targetPath; + } + } +} diff --git a/src/Tools/dotnet-watch/src/Internal/MsBuildProjectFinder.cs b/src/Tools/dotnet-watch/src/Internal/MsBuildProjectFinder.cs new file mode 100644 index 0000000000..b6cc515aec --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/MsBuildProjectFinder.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using Microsoft.DotNet.Watcher.Tools; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal class MsBuildProjectFinder + { + /// + /// Finds a compatible MSBuild project. + /// The base directory to search + /// The filename of the project. Can be null. + /// + public static string FindMsBuildProject(string searchBase, string project) + { + Ensure.NotNullOrEmpty(searchBase, nameof(searchBase)); + + var projectPath = project ?? searchBase; + + if (!Path.IsPathRooted(projectPath)) + { + projectPath = Path.Combine(searchBase, projectPath); + } + + if (Directory.Exists(projectPath)) + { + var projects = Directory.EnumerateFileSystemEntries(projectPath, "*.*proj", SearchOption.TopDirectoryOnly) + .Where(f => !".xproj".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (projects.Count > 1) + { + throw new FileNotFoundException(Resources.FormatError_MultipleProjectsFound(projectPath)); + } + + if (projects.Count == 0) + { + throw new FileNotFoundException(Resources.FormatError_NoProjectsFound(projectPath)); + } + + return projects[0]; + } + + if (!File.Exists(projectPath)) + { + throw new FileNotFoundException(Resources.FormatError_ProjectPath_NotFound(projectPath)); + } + + return projectPath; + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/Internal/OutputCapture.cs b/src/Tools/dotnet-watch/src/Internal/OutputCapture.cs new file mode 100644 index 0000000000..08e051f732 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/OutputCapture.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class OutputCapture + { + private readonly List _lines = new List(); + public IEnumerable Lines => _lines; + public void AddLine(string line) => _lines.Add(line); + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/Internal/OutputSink.cs b/src/Tools/dotnet-watch/src/Internal/OutputSink.cs new file mode 100644 index 0000000000..eb176564ef --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/OutputSink.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class OutputSink + { + public OutputCapture Current { get; private set; } + public OutputCapture StartCapture() + { + return (Current = new OutputCapture()); + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs new file mode 100644 index 0000000000..a5f7cac8ef --- /dev/null +++ b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs @@ -0,0 +1,154 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class ProcessRunner + { + private readonly IReporter _reporter; + + public ProcessRunner(IReporter reporter) + { + Ensure.NotNull(reporter, nameof(reporter)); + + _reporter = reporter; + } + + // May not be necessary in the future. See https://github.com/dotnet/corefx/issues/12039 + public async Task RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken) + { + Ensure.NotNull(processSpec, nameof(processSpec)); + + int exitCode; + + var stopwatch = new Stopwatch(); + + using (var process = CreateProcess(processSpec)) + using (var processState = new ProcessState(process)) + { + cancellationToken.Register(() => processState.TryKill()); + + stopwatch.Start(); + process.Start(); + _reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}"); + + if (processSpec.IsOutputCaptured) + { + await Task.WhenAll( + processState.Task, + ConsumeStreamAsync(process.StandardOutput, processSpec.OutputCapture.AddLine), + ConsumeStreamAsync(process.StandardError, processSpec.OutputCapture.AddLine) + ); + } + else + { + await processState.Task; + } + + exitCode = process.ExitCode; + stopwatch.Stop(); + _reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms"); + } + + return exitCode; + } + + private Process CreateProcess(ProcessSpec processSpec) + { + var process = new Process + { + EnableRaisingEvents = true, + StartInfo = + { + FileName = processSpec.Executable, + Arguments = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments), + UseShellExecute = false, + WorkingDirectory = processSpec.WorkingDirectory, + RedirectStandardOutput = processSpec.IsOutputCaptured, + RedirectStandardError = processSpec.IsOutputCaptured, + } + }; + + foreach (var env in processSpec.EnvironmentVariables) + { + process.StartInfo.Environment.Add(env.Key, env.Value); + } + + return process; + } + + private static async Task ConsumeStreamAsync(StreamReader reader, Action consume) + { + string line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + consume?.Invoke(line); + } + } + + private class ProcessState : IDisposable + { + private readonly Process _process; + private readonly TaskCompletionSource _tcs = new TaskCompletionSource(); + private volatile bool _disposed; + + public ProcessState(Process process) + { + _process = process; + _process.Exited += OnExited; + Task = _tcs.Task.ContinueWith(_ => + { + // We need to use two WaitForExit calls to ensure that all of the output/events are processed. Previously + // this code used Process.Exited, which could result in us missing some output due to the ordering of + // events. + // + // See the remarks here: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit#System_Diagnostics_Process_WaitForExit_System_Int32_ + if (!process.WaitForExit(Int32.MaxValue)) + { + throw new TimeoutException(); + } + + process.WaitForExit(); + }); + } + + public Task Task { get; } + + public void TryKill() + { + try + { + if (!_process.HasExited) + { + _process.KillTree(); + } + } + catch + { } + } + + private void OnExited(object sender, EventArgs args) + => _tcs.TrySetResult(null); + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + TryKill(); + _process.Exited -= OnExited; + _process.Dispose(); + } + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs b/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs new file mode 100644 index 0000000000..b2453276ef --- /dev/null +++ b/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher +{ + public class PrefixConsoleReporter : ConsoleReporter + { + private object _lock = new object(); + + public PrefixConsoleReporter(IConsole console, bool verbose, bool quiet) + : base(console, verbose, quiet) + { } + + protected override void WriteLine(TextWriter writer, string message, ConsoleColor? color) + { + const string prefix = "watch : "; + + lock (_lock) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + writer.Write(prefix); + Console.ResetColor(); + + base.WriteLine(writer, message, color); + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/ProcessSpec.cs b/src/Tools/dotnet-watch/src/ProcessSpec.cs new file mode 100644 index 0000000000..ad5eb262b3 --- /dev/null +++ b/src/Tools/dotnet-watch/src/ProcessSpec.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; +using Microsoft.DotNet.Watcher.Internal; + +namespace Microsoft.DotNet.Watcher +{ + public class ProcessSpec + { + public string Executable { get; set; } + public string WorkingDirectory { get; set; } + public IDictionary EnvironmentVariables { get; } = new Dictionary(); + public IEnumerable Arguments { get; set; } + public OutputCapture OutputCapture { get; set; } + + public string ShortDisplayName() + => Path.GetFileNameWithoutExtension(Executable); + + public bool IsOutputCaptured => OutputCapture != null; + } +} diff --git a/src/Tools/dotnet-watch/src/Program.cs b/src/Tools/dotnet-watch/src/Program.cs new file mode 100644 index 0000000000..7e8200b102 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Program.cs @@ -0,0 +1,216 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.Watcher.Internal; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher +{ + public class Program : IDisposable + { + private readonly IConsole _console; + private readonly string _workingDir; + private readonly CancellationTokenSource _cts; + private IReporter _reporter; + + public Program(IConsole console, string workingDir) + { + Ensure.NotNull(console, nameof(console)); + Ensure.NotNullOrEmpty(workingDir, nameof(workingDir)); + + _console = console; + _workingDir = workingDir; + _cts = new CancellationTokenSource(); + _console.CancelKeyPress += OnCancelKeyPress; + _reporter = CreateReporter(verbose: true, quiet: false, console: _console); + } + + public static async Task Main(string[] args) + { + try + { + DebugHelper.HandleDebugSwitch(ref args); + using (var program = new Program(PhysicalConsole.Singleton, Directory.GetCurrentDirectory())) + { + return await program.RunAsync(args); + } + } + catch (Exception ex) + { + Console.Error.WriteLine("Unexpected error:"); + Console.Error.WriteLine(ex.ToString()); + return 1; + } + } + + public async Task RunAsync(string[] args) + { + CommandLineOptions options; + try + { + options = CommandLineOptions.Parse(args, _console); + } + catch (CommandParsingException ex) + { + _reporter.Error(ex.Message); + return 1; + } + + if (options == null) + { + // invalid args syntax + return 1; + } + + if (options.IsHelp) + { + return 2; + } + + // update reporter as configured by options + _reporter = CreateReporter(options.IsVerbose, options.IsQuiet, _console); + + try + { + if (_cts.IsCancellationRequested) + { + return 1; + } + + if (options.ListFiles) + { + return await ListFilesAsync(_reporter, + options.Project, + _cts.Token); + } + else + { + return await MainInternalAsync(_reporter, + options.Project, + options.RemainingArguments, + _cts.Token); + } + } + catch (Exception ex) + { + if (ex is TaskCanceledException || ex is OperationCanceledException) + { + // swallow when only exception is the CTRL+C forced an exit + return 0; + } + + _reporter.Error(ex.ToString()); + _reporter.Error("An unexpected error occurred"); + return 1; + } + } + + private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs args) + { + // suppress CTRL+C on the first press + args.Cancel = !_cts.IsCancellationRequested; + + if (args.Cancel) + { + _reporter.Output("Shutdown requested. Press Ctrl+C again to force exit."); + } + + _cts.Cancel(); + } + + private async Task MainInternalAsync( + IReporter reporter, + string project, + ICollection args, + CancellationToken cancellationToken) + { + // TODO multiple projects should be easy enough to add here + string projectFile; + try + { + projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project); + } + catch (FileNotFoundException ex) + { + reporter.Error(ex.Message); + return 1; + } + + var fileSetFactory = new MsBuildFileSetFactory(reporter, + projectFile, + waitOnError: true, + trace: false); + var processInfo = new ProcessSpec + { + Executable = DotNetMuxer.MuxerPathOrDefault(), + WorkingDirectory = Path.GetDirectoryName(projectFile), + Arguments = args, + EnvironmentVariables = + { + ["DOTNET_WATCH"] = "1" + }, + }; + + if (CommandLineOptions.IsPollingEnabled) + { + _reporter.Output("Polling file watcher is enabled"); + } + + await new DotNetWatcher(reporter) + .WatchAsync(processInfo, fileSetFactory, cancellationToken); + + return 0; + } + + private async Task ListFilesAsync( + IReporter reporter, + string project, + CancellationToken cancellationToken) + { + // TODO multiple projects should be easy enough to add here + string projectFile; + try + { + projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project); + } + catch (FileNotFoundException ex) + { + reporter.Error(ex.Message); + return 1; + } + + var fileSetFactory = new MsBuildFileSetFactory(reporter, + projectFile, + waitOnError: false, + trace: false); + var files = await fileSetFactory.CreateAsync(cancellationToken); + + if (files == null) + { + return 1; + } + + foreach (var file in files) + { + _console.Out.WriteLine(file); + } + + return 0; + } + + private static IReporter CreateReporter(bool verbose, bool quiet, IConsole console) + => new PrefixConsoleReporter(console, verbose || CliContext.IsGlobalVerbose(), quiet); + + public void Dispose() + { + _console.CancelKeyPress -= OnCancelKeyPress; + _cts.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/src/Properties/AssemblyInfo.cs b/src/Tools/dotnet-watch/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..70797aee7c --- /dev/null +++ b/src/Tools/dotnet-watch/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.DotNet.Watcher.Tools.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Tools/dotnet-watch/src/Properties/Resources.Designer.cs b/src/Tools/dotnet-watch/src/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..ee248b1342 --- /dev/null +++ b/src/Tools/dotnet-watch/src/Properties/Resources.Designer.cs @@ -0,0 +1,94 @@ +// +namespace Microsoft.DotNet.Watcher.Tools +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.DotNet.Watcher.Tools.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// The project file '{path}' does not exist. + /// + internal static string Error_ProjectPath_NotFound + { + get { return GetString("Error_ProjectPath_NotFound"); } + } + + /// + /// The project file '{path}' does not exist. + /// + internal static string FormatError_ProjectPath_NotFound(object path) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path); + } + + /// + /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + /// + internal static string Error_MultipleProjectsFound + { + get { return GetString("Error_MultipleProjectsFound"); } + } + + /// + /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + /// + internal static string FormatError_MultipleProjectsFound(object projectPath) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath); + } + + /// + /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + /// + internal static string Error_NoProjectsFound + { + get { return GetString("Error_NoProjectsFound"); } + } + + /// + /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + /// + internal static string FormatError_NoProjectsFound(object projectPath) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath); + } + + /// + /// Cannot specify both '--quiet' and '--verbose' options. + /// + internal static string Error_QuietAndVerboseSpecified + { + get { return GetString("Error_QuietAndVerboseSpecified"); } + } + + /// + /// Cannot specify both '--quiet' and '--verbose' options. + /// + internal static string FormatError_QuietAndVerboseSpecified() + { + return GetString("Error_QuietAndVerboseSpecified"); + } + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Tools/dotnet-watch/src/Resources.resx b/src/Tools/dotnet-watch/src/Resources.resx new file mode 100644 index 0000000000..b66821626b --- /dev/null +++ b/src/Tools/dotnet-watch/src/Resources.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The project file '{path}' does not exist. + + + Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. + + + Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. + + + Cannot specify both '--quiet' and '--verbose' options. + + \ No newline at end of file diff --git a/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets new file mode 100644 index 0000000000..5ce4f08672 --- /dev/null +++ b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets @@ -0,0 +1,68 @@ + + + + + + + + + <_CollectWatchItemsDependsOn Condition=" '$(TargetFrameworks)' != '' AND '$(TargetFramework)' == '' "> + _CollectWatchItemsPerFramework; + + <_CollectWatchItemsDependsOn Condition=" '$(TargetFramework)' != '' "> + _CoreCollectWatchItems; + + + + + + + + <_TargetFramework Include="$(TargetFrameworks)" /> + + + + + + + + + + + + + + + + + + <_WatchProjects Include="%(ProjectReference.Identity)" Condition="'%(ProjectReference.Watch)' != 'false'" /> + + + + + + + + diff --git a/src/Tools/dotnet-watch/src/dotnet-watch.csproj b/src/Tools/dotnet-watch/src/dotnet-watch.csproj new file mode 100644 index 0000000000..4aff804186 --- /dev/null +++ b/src/Tools/dotnet-watch/src/dotnet-watch.csproj @@ -0,0 +1,24 @@ + + + + netcoreapp2.1 + exe + Command line tool to watch for source file changes during development and restart the dotnet command. + Microsoft.DotNet.Watcher.Tools + dotnet;watch + true + + win-x64;win-x86 + + + + + + + + + + + + + diff --git a/src/Tools/dotnet-watch/test/AppWithDepsTests.cs b/src/Tools/dotnet-watch/test/AppWithDepsTests.cs new file mode 100644 index 0000000000..8ec43cf3a5 --- /dev/null +++ b/src/Tools/dotnet-watch/test/AppWithDepsTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class AppWithDepsTests : IDisposable + { + private readonly AppWithDeps _app; + + public AppWithDepsTests(ITestOutputHelper logger) + { + _app = new AppWithDeps(logger); + } + + [Fact] + public async Task ChangeFileInDependency() + { + await _app.StartWatcherAsync(); + + var fileToChange = Path.Combine(_app.DependencyFolder, "Foo.cs"); + var programCs = File.ReadAllText(fileToChange); + File.WriteAllText(fileToChange, programCs); + + await _app.HasRestarted(); + } + + public void Dispose() + { + _app.Dispose(); + } + + private class AppWithDeps : WatchableApp + { + private const string Dependency = "Dependency"; + + public AppWithDeps(ITestOutputHelper logger) + : base("AppWithDeps", logger) + { + Scenario.AddTestProjectFolder(Dependency); + + DependencyFolder = Path.Combine(Scenario.WorkFolder, Dependency); + } + + public string DependencyFolder { get; private set; } + } + } +} diff --git a/src/Tools/dotnet-watch/test/AssertEx.cs b/src/Tools/dotnet-watch/test/AssertEx.cs new file mode 100644 index 0000000000..4d897058fd --- /dev/null +++ b/src/Tools/dotnet-watch/test/AssertEx.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public static class AssertEx + { + public static void EqualFileList(string root, IEnumerable expectedFiles, IEnumerable actualFiles) + { + var expected = expectedFiles.Select(p => Path.Combine(root, p)); + EqualFileList(expected, actualFiles); + } + + public static void EqualFileList(IEnumerable expectedFiles, IEnumerable actualFiles) + { + string normalize(string p) => p.Replace('\\', '/'); + var expected = new HashSet(expectedFiles.Select(normalize)); + var actual = new HashSet(actualFiles.Where(p => !string.IsNullOrEmpty(p)).Select(normalize)); + if (!expected.SetEquals(actual)) + { + throw new AssertActualExpectedException( + expected: "\n" + string.Join("\n", expected), + actual: "\n" + string.Join("\n", actual), + userMessage: "File sets should be equal"); + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/AwaitableProcess.cs b/src/Tools/dotnet-watch/test/AwaitableProcess.cs new file mode 100644 index 0000000000..91b53133eb --- /dev/null +++ b/src/Tools/dotnet-watch/test/AwaitableProcess.cs @@ -0,0 +1,141 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.CommandLineUtils; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class AwaitableProcess : IDisposable + { + private Process _process; + private readonly ProcessSpec _spec; + private BufferBlock _source; + private ITestOutputHelper _logger; + + public AwaitableProcess(ProcessSpec spec, ITestOutputHelper logger) + { + _spec = spec; + _logger = logger; + _source = new BufferBlock(); + } + + public void Start() + { + if (_process != null) + { + throw new InvalidOperationException("Already started"); + } + + _process = new Process + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo + { + UseShellExecute = false, + FileName = _spec.Executable, + WorkingDirectory = _spec.WorkingDirectory, + Arguments = ArgumentEscaper.EscapeAndConcatenate(_spec.Arguments), + RedirectStandardOutput = true, + RedirectStandardError = true, + Environment = + { + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "true" + } + } + }; + + _process.OutputDataReceived += OnData; + _process.ErrorDataReceived += OnData; + _process.Exited += OnExit; + + _process.Start(); + _process.BeginErrorReadLine(); + _process.BeginOutputReadLine(); + _logger.WriteLine($"{DateTime.Now}: process start: '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}'"); + } + + public async Task GetOutputLineAsync(string message, TimeSpan timeout) + { + _logger.WriteLine($"Waiting for output line [msg == '{message}']. Will wait for {timeout.TotalSeconds} sec."); + return await GetOutputLineAsync(m => message == m).TimeoutAfter(timeout); + } + + public async Task GetOutputLineStartsWithAsync(string message, TimeSpan timeout) + { + _logger.WriteLine($"Waiting for output line [msg.StartsWith('{message}')]. Will wait for {timeout.TotalSeconds} sec."); + return await GetOutputLineAsync(m => m.StartsWith(message)).TimeoutAfter(timeout); + } + + private async Task GetOutputLineAsync(Predicate predicate) + { + while (!_source.Completion.IsCompleted) + { + while (await _source.OutputAvailableAsync()) + { + var next = await _source.ReceiveAsync(); + _logger.WriteLine($"{DateTime.Now}: recv: '{next}'"); + if (predicate(next)) + { + return next; + } + } + } + + return null; + } + + public async Task> GetAllOutputLines() + { + var lines = new List(); + while (!_source.Completion.IsCompleted) + { + while (await _source.OutputAvailableAsync()) + { + var next = await _source.ReceiveAsync(); + _logger.WriteLine($"{DateTime.Now}: recv: '{next}'"); + lines.Add(next); + } + } + return lines; + } + + private void OnData(object sender, DataReceivedEventArgs args) + { + var line = args.Data ?? string.Empty; + _logger.WriteLine($"{DateTime.Now}: post: '{line}'"); + _source.Post(line); + } + + private void OnExit(object sender, EventArgs args) + { + // Wait to ensure the process has exited and all output consumed + _process.WaitForExit(); + _source.Complete(); + } + + public void Dispose() + { + _source.Complete(); + + if (_process != null) + { + if (!_process.HasExited) + { + _process.KillTree(); + } + + _process.ErrorDataReceived -= OnData; + _process.OutputDataReceived -= OnData; + _process.Exited -= OnExit; + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/CommandLineOptionsTests.cs b/src/Tools/dotnet-watch/test/CommandLineOptionsTests.cs new file mode 100644 index 0000000000..129d1219fa --- /dev/null +++ b/src/Tools/dotnet-watch/test/CommandLineOptionsTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public class CommandLineOptionsTests + { + private readonly IConsole _console; + private readonly StringBuilder _stdout = new StringBuilder(); + + public CommandLineOptionsTests(ITestOutputHelper output) + { + _console = new TestConsole(output) + { + Out = new StringWriter(_stdout), + }; + } + + [Theory] + [InlineData(new object[] { new[] { "-h" } })] + [InlineData(new object[] { new[] { "-?" } })] + [InlineData(new object[] { new[] { "--help" } })] + [InlineData(new object[] { new[] { "--help", "--bogus" } })] + [InlineData(new object[] { new[] { "--" } })] + [InlineData(new object[] { new string[0] })] + public void HelpArgs(string[] args) + { + var options = CommandLineOptions.Parse(args, _console); + + Assert.True(options.IsHelp); + Assert.Contains("Usage: dotnet watch ", _stdout.ToString()); + } + + [Theory] + [InlineData(new[] { "run" }, new[] { "run" })] + [InlineData(new[] { "run", "--", "subarg" }, new[] { "run", "--", "subarg" })] + [InlineData(new[] { "--", "run", "--", "subarg" }, new[] { "run", "--", "subarg" })] + [InlineData(new[] { "--unrecognized-arg" }, new[] { "--unrecognized-arg" })] + public void ParsesRemainingArgs(string[] args, string[] expected) + { + var options = CommandLineOptions.Parse(args, _console); + + Assert.Equal(expected, options.RemainingArguments.ToArray()); + Assert.False(options.IsHelp); + Assert.Empty(_stdout.ToString()); + } + + [Fact] + public void CannotHaveQuietAndVerbose() + { + var ex = Assert.Throws(() => CommandLineOptions.Parse(new[] { "--quiet", "--verbose" }, _console)); + Assert.Equal(Resources.Error_QuietAndVerboseSpecified, ex.Message); + } + } +} diff --git a/src/Tools/dotnet-watch/test/ConsoleReporterTests.cs b/src/Tools/dotnet-watch/test/ConsoleReporterTests.cs new file mode 100644 index 0000000000..34cfd42850 --- /dev/null +++ b/src/Tools/dotnet-watch/test/ConsoleReporterTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using Microsoft.Extensions.Tools.Internal; +using Xunit; + +namespace Microsoft.Extensions.Tools.Tests +{ + public class ReporterTests + { + private static readonly string EOL = Environment.NewLine; + + [Fact] + public void WritesToStandardStreams() + { + var testConsole = new TestConsole(); + var reporter = new ConsoleReporter(testConsole, verbose: true, quiet: false); + + // stdout + reporter.Verbose("verbose"); + Assert.Equal("verbose" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + reporter.Output("out"); + Assert.Equal("out" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + reporter.Warn("warn"); + Assert.Equal("warn" + EOL, testConsole.GetOutput()); + testConsole.Clear(); + + // stderr + reporter.Error("error"); + Assert.Equal("error" + EOL, testConsole.GetError()); + testConsole.Clear(); + } + + private class TestConsole : IConsole + { + private readonly StringBuilder _out; + private readonly StringBuilder _error; + + public TestConsole() + { + _out = new StringBuilder(); + _error = new StringBuilder(); + Out = new StringWriter(_out); + Error = new StringWriter(_error); + } + + event ConsoleCancelEventHandler IConsole.CancelKeyPress + { + add { } + remove { } + } + + public string GetOutput() => _out.ToString(); + public string GetError() => _error.ToString(); + + public void Clear() + { + _out.Clear(); + _error.Clear(); + } + + public void ResetColor() + { + ForegroundColor = default(ConsoleColor); + } + + public TextWriter Out { get; } + public TextWriter Error { get; } + public TextReader In { get; } + public bool IsInputRedirected { get; } + public bool IsOutputRedirected { get; } + public bool IsErrorRedirected { get; } + public ConsoleColor ForegroundColor { get; set; } + } + } +} diff --git a/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs b/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs new file mode 100644 index 0000000000..d0dc735247 --- /dev/null +++ b/src/Tools/dotnet-watch/test/DotNetWatcherTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class DotNetWatcherTests : IDisposable + { + private readonly KitchenSinkApp _app; + + public DotNetWatcherTests(ITestOutputHelper logger) + { + _app = new KitchenSinkApp(logger); + } + + [Fact] + public async Task RunsWithDotnetWatchEnvVariable() + { + Assert.True(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_WATCH")), "DOTNET_WATCH cannot be set already when this test is running"); + + await _app.StartWatcherAsync(); + const string messagePrefix = "DOTNET_WATCH = "; + var message = await _app.Process.GetOutputLineStartsWithAsync(messagePrefix, TimeSpan.FromMinutes(2)); + var envValue = message.Substring(messagePrefix.Length); + Assert.Equal("1", envValue); + } + + public void Dispose() + { + _app.Dispose(); + } + + private class KitchenSinkApp : WatchableApp + { + public KitchenSinkApp(ITestOutputHelper logger) + : base("KitchenSink", logger) + { + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/FileWatcherTests.cs b/src/Tools/dotnet-watch/test/FileWatcherTests.cs new file mode 100644 index 0000000000..7cd4bd15aa --- /dev/null +++ b/src/Tools/dotnet-watch/test/FileWatcherTests.cs @@ -0,0 +1,417 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.DotNet.Watcher.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class FileWatcherTests + { + public FileWatcherTests(ITestOutputHelper output) + { + _output = output; + } + + private const int DefaultTimeout = 10 * 1000; // 10 sec + private readonly ITestOutputHelper _output; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NewFile(bool usePolling) + { + UsingTempDirectory(dir => + { + using (var changedEv = new ManualResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + var filesChanged = new HashSet(); + + watcher.OnFileChange += (_, f) => + { + filesChanged.Add(f); + changedEv.Set(); + }; + watcher.EnableRaisingEvents = true; + + var testFileFullPath = Path.Combine(dir, "foo"); + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + Assert.Equal(testFileFullPath, filesChanged.Single()); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ChangeFile(bool usePolling) + { + UsingTempDirectory(dir => + { + var testFileFullPath = Path.Combine(dir, "foo"); + File.WriteAllText(testFileFullPath, string.Empty); + + using (var changedEv = new ManualResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + var filesChanged = new HashSet(); + + EventHandler handler = null; + handler = (_, f) => + { + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= handler; + + filesChanged.Add(f); + changedEv.Set(); + }; + + watcher.OnFileChange += handler; + watcher.EnableRaisingEvents = true; + + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + Assert.Equal(testFileFullPath, filesChanged.Single()); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveFile(bool usePolling) + { + UsingTempDirectory(dir => + { + var srcFile = Path.Combine(dir, "foo"); + var dstFile = Path.Combine(dir, "foo2"); + + File.WriteAllText(srcFile, string.Empty); + + using (var changedEv = new ManualResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + var filesChanged = new HashSet(); + + EventHandler handler = null; + handler = (_, f) => + { + filesChanged.Add(f); + + if (filesChanged.Count >= 2) + { + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= handler; + + changedEv.Set(); + } + }; + + watcher.OnFileChange += handler; + watcher.EnableRaisingEvents = true; + + File.Move(srcFile, dstFile); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + Assert.Contains(srcFile, filesChanged); + Assert.Contains(dstFile, filesChanged); + } + }); + } + + [Fact] + public void FileInSubdirectory() + { + UsingTempDirectory(dir => + { + var subdir = Path.Combine(dir, "subdir"); + Directory.CreateDirectory(subdir); + + var testFileFullPath = Path.Combine(subdir, "foo"); + File.WriteAllText(testFileFullPath, string.Empty); + + using (var changedEv = new ManualResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, true)) + { + var filesChanged = new HashSet(); + + EventHandler handler = null; + handler = (_, f) => + { + filesChanged.Add(f); + + if (filesChanged.Count >= 2) + { + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= handler; + changedEv.Set(); + } + }; + + watcher.OnFileChange += handler; + watcher.EnableRaisingEvents = true; + + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + Assert.Contains(subdir, filesChanged); + Assert.Contains(testFileFullPath, filesChanged); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NoNotificationIfDisabled(bool usePolling) + { + UsingTempDirectory(dir => + { + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + using (var changedEv = new ManualResetEvent(false)) + { + watcher.OnFileChange += (_, f) => changedEv.Set(); + + // Disable + watcher.EnableRaisingEvents = false; + + var testFileFullPath = Path.Combine(dir, "foo"); + + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.False(changedEv.WaitOne(DefaultTimeout / 2)); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DisposedNoEvents(bool usePolling) + { + UsingTempDirectory(dir => + { + using (var changedEv = new ManualResetEvent(false)) + { + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + watcher.OnFileChange += (_, f) => changedEv.Set(); + watcher.EnableRaisingEvents = true; + } + + var testFileFullPath = Path.Combine(dir, "foo"); + + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.False(changedEv.WaitOne(DefaultTimeout / 2)); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MultipleFiles(bool usePolling) + { + UsingTempDirectory(dir => + { + File.WriteAllText(Path.Combine(dir, "foo1"), string.Empty); + File.WriteAllText(Path.Combine(dir, "foo2"), string.Empty); + File.WriteAllText(Path.Combine(dir, "foo3"), string.Empty); + File.WriteAllText(Path.Combine(dir, "foo4"), string.Empty); + File.WriteAllText(Path.Combine(dir, "foo4"), string.Empty); + + var testFileFullPath = Path.Combine(dir, "foo3"); + + using (var changedEv = new ManualResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + var filesChanged = new HashSet(); + + EventHandler handler = null; + handler = (_, f) => + { + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= handler; + filesChanged.Add(f); + changedEv.Set(); + }; + + watcher.OnFileChange += handler; + watcher.EnableRaisingEvents = true; + + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + + File.WriteAllText(testFileFullPath, string.Empty); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + Assert.Equal(testFileFullPath, filesChanged.Single()); + } + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MultipleTriggers(bool usePolling) + { + var filesChanged = new HashSet(); + + UsingTempDirectory(dir => + { + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + watcher.EnableRaisingEvents = true; + + for (var i = 0; i < 5; i++) + { + AssertFileChangeRaisesEvent(dir, watcher); + } + + watcher.EnableRaisingEvents = false; + } + }); + } + + private void AssertFileChangeRaisesEvent(string directory, IFileSystemWatcher watcher) + { + var semaphoreSlim = new SemaphoreSlim(0); + var expectedPath = Path.Combine(directory, Path.GetRandomFileName()); + EventHandler handler = (object _, string f) => + { + _output.WriteLine("File changed: " + f); + try + { + if (string.Equals(f, expectedPath, StringComparison.OrdinalIgnoreCase)) + { + semaphoreSlim.Release(); + } + } + catch (ObjectDisposedException) + { + // There's a known race condition here: + // even though we tell the watcher to stop raising events and we unsubscribe the handler + // there might be in-flight events that will still process. Since we dispose the reset + // event, this code will fail if the handler executes after Dispose happens. + } + }; + + File.AppendAllText(expectedPath, " "); + + watcher.OnFileChange += handler; + try + { + // On Unix the file write time is in 1s increments; + // if we don't wait, there's a chance that the polling + // watcher will not detect the change + Thread.Sleep(1000); + File.AppendAllText(expectedPath, " "); + Assert.True(semaphoreSlim.Wait(DefaultTimeout), "Expected a file change event for " + expectedPath); + } + finally + { + watcher.OnFileChange -= handler; + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DeleteSubfolder(bool usePolling) + { + UsingTempDirectory(dir => + { + var subdir = Path.Combine(dir, "subdir"); + Directory.CreateDirectory(subdir); + + var f1 = Path.Combine(subdir, "foo1"); + var f2 = Path.Combine(subdir, "foo2"); + var f3 = Path.Combine(subdir, "foo3"); + + File.WriteAllText(f1, string.Empty); + File.WriteAllText(f2, string.Empty); + File.WriteAllText(f3, string.Empty); + + using (var changedEv = new AutoResetEvent(false)) + using (var watcher = FileWatcherFactory.CreateWatcher(dir, usePolling)) + { + var filesChanged = new HashSet(); + + EventHandler handler = null; + handler = (_, f) => + { + filesChanged.Add(f); + + if (filesChanged.Count >= 4) + { + watcher.EnableRaisingEvents = false; + watcher.OnFileChange -= handler; + changedEv.Set(); + } + }; + + watcher.OnFileChange += handler; + watcher.EnableRaisingEvents = true; + + Directory.Delete(subdir, recursive: true); + + Assert.True(changedEv.WaitOne(DefaultTimeout)); + + Assert.Contains(f1, filesChanged); + Assert.Contains(f2, filesChanged); + Assert.Contains(f3, filesChanged); + Assert.Contains(subdir, filesChanged); + } + }); + } + + private static void UsingTempDirectory(Action action) + { + var tempFolder = Path.Combine(Path.GetTempPath(), $"{nameof(FileWatcherTests)}-{Guid.NewGuid().ToString("N")}"); + if (Directory.Exists(tempFolder)) + { + Directory.Delete(tempFolder, recursive: true); + } + + Directory.CreateDirectory(tempFolder); + + try + { + action(tempFolder); + } + finally + { + Directory.Delete(tempFolder, recursive: true); + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/GlobbingAppTests.cs b/src/Tools/dotnet-watch/test/GlobbingAppTests.cs new file mode 100644 index 0000000000..71b5d068bf --- /dev/null +++ b/src/Tools/dotnet-watch/test/GlobbingAppTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.DotNet.Watcher.Tools.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class GlobbingAppTests : IDisposable + { + private GlobbingApp _app; + public GlobbingAppTests(ITestOutputHelper logger) + { + _app = new GlobbingApp(logger); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ChangeCompiledFile(bool usePollingWatcher) + { + _app.UsePollingWatcher = usePollingWatcher; + await _app.StartWatcherAsync(); + + var types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(2, types); + + var fileToChange = Path.Combine(_app.SourceDirectory, "include", "Foo.cs"); + var programCs = File.ReadAllText(fileToChange); + File.WriteAllText(fileToChange, programCs); + + await _app.HasRestarted(); + types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(2, types); + } + + [Fact] + public async Task DeleteCompiledFile() + { + await _app.StartWatcherAsync(); + + var types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(2, types); + + var fileToChange = Path.Combine(_app.SourceDirectory, "include", "Foo.cs"); + File.Delete(fileToChange); + + await _app.HasRestarted(); + types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(1, types); + } + + [Fact] + public async Task DeleteSourceFolder() + { + await _app.StartWatcherAsync(); + + var types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(2, types); + + var folderToDelete = Path.Combine(_app.SourceDirectory, "include"); + Directory.Delete(folderToDelete, recursive: true); + + await _app.HasRestarted(); + types = await _app.GetCompiledAppDefinedTypes(); + Assert.Equal(1, types); + } + + [Fact] + public async Task RenameCompiledFile() + { + await _app.StartWatcherAsync(); + + var oldFile = Path.Combine(_app.SourceDirectory, "include", "Foo.cs"); + var newFile = Path.Combine(_app.SourceDirectory, "include", "Foo_new.cs"); + File.Move(oldFile, newFile); + + await _app.HasRestarted(); + } + + [Fact] + public async Task ChangeExcludedFile() + { + await _app.StartWatcherAsync(); + + var changedFile = Path.Combine(_app.SourceDirectory, "exclude", "Baz.cs"); + File.WriteAllText(changedFile, ""); + + var restart = _app.HasRestarted(); + var finished = await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(10)), restart); + Assert.NotSame(restart, finished); + } + + [Fact] + public async Task ListsFiles() + { + await _app.PrepareAsync(); + _app.Start(new [] { "--list" }); + var lines = await _app.Process.GetAllOutputLines(); + + AssertEx.EqualFileList( + _app.Scenario.WorkFolder, + new[] + { + "GlobbingApp/Program.cs", + "GlobbingApp/include/Foo.cs", + "GlobbingApp/GlobbingApp.csproj", + }, + lines); + } + + public void Dispose() + { + _app.Dispose(); + } + + private class GlobbingApp : WatchableApp + { + public GlobbingApp(ITestOutputHelper logger) + : base("GlobbingApp", logger) + { + } + + public async Task GetCompiledAppDefinedTypes() + { + var definedTypesMessage = await Process.GetOutputLineStartsWithAsync("Defined types = ", TimeSpan.FromSeconds(30)); + return int.Parse(definedTypesMessage.Split('=').Last()); + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs b/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs new file mode 100644 index 0000000000..c34111b239 --- /dev/null +++ b/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs @@ -0,0 +1,289 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.DotNet.Watcher.Internal; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + using ItemSpec = TemporaryCSharpProject.ItemSpec; + + public class MsBuildFileSetFactoryTest : IDisposable + { + private readonly IReporter _reporter; + private readonly TemporaryDirectory _tempDir; + public MsBuildFileSetFactoryTest(ITestOutputHelper output) + { + _reporter = new TestReporter(output); + _tempDir = new TemporaryDirectory(); + } + + [Fact] + public async Task FindsCustomWatchItems() + { + _tempDir + .WithCSharpProject("Project1", out var target) + .WithTargetFrameworks("netcoreapp1.0") + .WithItem(new ItemSpec { Name = "Watch", Include = "*.js", Exclude = "gulpfile.js" }) + .Dir() + .WithFile("Program.cs") + .WithFile("app.js") + .WithFile("gulpfile.js"); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "Project1.csproj", + "Program.cs", + "app.js" + }, + fileset + ); + } + + [Fact] + public async Task ExcludesDefaultItemsWithWatchFalseMetadata() + { + _tempDir + .WithCSharpProject("Project1", out var target) + .WithTargetFrameworks("net40") + .WithItem(new ItemSpec { Name = "EmbeddedResource", Update = "*.resx", Watch = false }) + .Dir() + .WithFile("Program.cs") + .WithFile("Strings.resx"); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "Project1.csproj", + "Program.cs", + }, + fileset + ); + } + + [Fact] + public async Task SingleTfm() + { + _tempDir + .SubDir("src") + .SubDir("Project1") + .WithCSharpProject("Project1", out var target) + .WithProperty("BaseIntermediateOutputPath", "obj") + .WithTargetFrameworks("netcoreapp1.0") + .Dir() + .WithFile("Program.cs") + .WithFile("Class1.cs") + .SubDir("obj").WithFile("ignored.cs").Up() + .SubDir("Properties").WithFile("Strings.resx").Up() + .Up() + .Up() + .Create(); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "src/Project1/Project1.csproj", + "src/Project1/Program.cs", + "src/Project1/Class1.cs", + "src/Project1/Properties/Strings.resx", + }, + fileset + ); + } + + [Fact] + public async Task MultiTfm() + { + _tempDir + .SubDir("src") + .SubDir("Project1") + .WithCSharpProject("Project1", out var target) + .WithTargetFrameworks("netcoreapp1.0", "net451") + .WithProperty("EnableDefaultCompileItems", "false") + .WithItem("Compile", "Class1.netcore.cs", "'$(TargetFramework)'=='netcoreapp1.0'") + .WithItem("Compile", "Class1.desktop.cs", "'$(TargetFramework)'=='net451'") + .Dir() + .WithFile("Class1.netcore.cs") + .WithFile("Class1.desktop.cs") + .WithFile("Class1.notincluded.cs"); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "src/Project1/Project1.csproj", + "src/Project1/Class1.netcore.cs", + "src/Project1/Class1.desktop.cs", + }, + fileset + ); + } + + [Fact] + public async Task ProjectReferences_OneLevel() + { + _tempDir + .SubDir("src") + .SubDir("Project2") + .WithCSharpProject("Project2", out var proj2) + .WithTargetFrameworks("netstandard1.1") + .Dir() + .WithFile("Class2.cs") + .Up() + .SubDir("Project1") + .WithCSharpProject("Project1", out var target) + .WithTargetFrameworks("netcoreapp1.0", "net451") + .WithProjectReference(proj2) + .Dir() + .WithFile("Class1.cs"); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "src/Project2/Project2.csproj", + "src/Project2/Class2.cs", + "src/Project1/Project1.csproj", + "src/Project1/Class1.cs", + }, + fileset + ); + } + + [Fact] + public async Task TransitiveProjectReferences_TwoLevels() + { + _tempDir + .SubDir("src") + .SubDir("Project3") + .WithCSharpProject("Project3", out var proj3) + .WithTargetFrameworks("netstandard1.0") + .Dir() + .WithFile("Class3.cs") + .Up() + .SubDir("Project2") + .WithCSharpProject("Project2", out TemporaryCSharpProject proj2) + .WithTargetFrameworks("netstandard1.1") + .WithProjectReference(proj3) + .Dir() + .WithFile("Class2.cs") + .Up() + .SubDir("Project1") + .WithCSharpProject("Project1", out TemporaryCSharpProject target) + .WithTargetFrameworks("netcoreapp1.0", "net451") + .WithProjectReference(proj2) + .Dir() + .WithFile("Class1.cs"); + + var fileset = await GetFileSet(target); + + AssertEx.EqualFileList( + _tempDir.Root, + new[] + { + "src/Project3/Project3.csproj", + "src/Project3/Class3.cs", + "src/Project2/Project2.csproj", + "src/Project2/Class2.cs", + "src/Project1/Project1.csproj", + "src/Project1/Class1.cs", + }, + fileset + ); + } + + [Fact] + public async Task ProjectReferences_Graph() + { + var graph = new TestProjectGraph(_tempDir); + graph.OnCreate(p => p.WithTargetFrameworks("net45")); + var matches = Regex.Matches(@" + A->B B->C C->D D->E + B->E + A->F F->G G->E + F->E + W->U + Y->Z + Y->B + Y->F", + @"(\w)->(\w)"); + + Assert.Equal(13, matches.Count); + foreach (Match m in matches) + { + var target = graph.GetOrCreate(m.Groups[2].Value); + graph.GetOrCreate(m.Groups[1].Value).WithProjectReference(target); + } + + graph.Find("A").WithProjectReference(graph.Find("W"), watch: false); + + var output = new OutputSink(); + var filesetFactory = new MsBuildFileSetFactory(_reporter, graph.GetOrCreate("A").Path, output, trace: true); + + var fileset = await GetFileSet(filesetFactory); + + _reporter.Output(string.Join( + Environment.NewLine, + output.Current.Lines.Select(l => "Sink output: " + l))); + + var includedProjects = new[] { "A", "B", "C", "D", "E", "F", "G" }; + AssertEx.EqualFileList( + _tempDir.Root, + includedProjects + .Select(p => $"{p}/{p}.csproj"), + fileset + ); + + // ensure unreachable projects exist but where not included + Assert.NotNull(graph.Find("W")); + Assert.NotNull(graph.Find("U")); + Assert.NotNull(graph.Find("Y")); + Assert.NotNull(graph.Find("Z")); + + // ensure each project is only visited once for collecting watch items + Assert.All(includedProjects, + projectName => + Assert.Single(output.Current.Lines, + line => line.Contains($"Collecting watch items from '{projectName}'")) + ); + } + + private Task GetFileSet(TemporaryCSharpProject target) + => GetFileSet(new MsBuildFileSetFactory(_reporter, target.Path, waitOnError: false, trace: false)); + + private async Task GetFileSet(MsBuildFileSetFactory filesetFactory) + { + _tempDir.Create(); + return await filesetFactory + .CreateAsync(CancellationToken.None) + .TimeoutAfter(TimeSpan.FromSeconds(30)); + } + + public void Dispose() + { + _tempDir.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/test/NoDepsAppTests.cs b/src/Tools/dotnet-watch/test/NoDepsAppTests.cs new file mode 100644 index 0000000000..8ac2f694e7 --- /dev/null +++ b/src/Tools/dotnet-watch/test/NoDepsAppTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class NoDepsAppTests : IDisposable + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + private readonly WatchableApp _app; + + public NoDepsAppTests(ITestOutputHelper logger) + { + _app = new WatchableApp("NoDepsApp", logger); + } + + [Fact] + public async Task RestartProcessOnFileChange() + { + await _app.StartWatcherAsync(new[] { "--no-exit" }); + var pid = await _app.GetProcessId(); + + // Then wait for it to restart when we change a file + var fileToChange = Path.Combine(_app.SourceDirectory, "Program.cs"); + var programCs = File.ReadAllText(fileToChange); + File.WriteAllText(fileToChange, programCs); + + await _app.HasRestarted(); + var pid2 = await _app.GetProcessId(); + Assert.NotEqual(pid, pid2); + + // first app should have shut down + Assert.Throws(() => Process.GetProcessById(pid)); + } + + [Fact] + public async Task RestartProcessThatTerminatesAfterFileChange() + { + await _app.StartWatcherAsync(); + var pid = await _app.GetProcessId(); + await _app.HasExited(); // process should exit after run + await _app.IsWaitingForFileChange(); + + var fileToChange = Path.Combine(_app.SourceDirectory, "Program.cs"); + var programCs = File.ReadAllText(fileToChange); + File.WriteAllText(fileToChange, programCs); + + await _app.HasRestarted(); + var pid2 = await _app.GetProcessId(); + Assert.NotEqual(pid, pid2); + await _app.HasExited(); // process should exit after run + } + + public void Dispose() + { + _app.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/test/ProgramTests.cs b/src/Tools/dotnet-watch/test/ProgramTests.cs new file mode 100644 index 0000000000..8c116b5595 --- /dev/null +++ b/src/Tools/dotnet-watch/test/ProgramTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public class ProgramTests : IDisposable + { + private readonly TemporaryDirectory _tempDir; + private readonly TestConsole _console; + + public ProgramTests(ITestOutputHelper output) + { + _tempDir = new TemporaryDirectory(); + _console = new TestConsole(output); + } + + [Fact] + public async Task ConsoleCancelKey() + { + _tempDir + .WithCSharpProject("testproj") + .WithTargetFrameworks("netcoreapp1.0") + .Dir() + .WithFile("Program.cs") + .Create(); + + var stdout = new StringBuilder(); + _console.Out = new StringWriter(stdout); + var program = new Program(_console, _tempDir.Root) + .RunAsync(new[] { "run" }); + + await _console.CancelKeyPressSubscribed.TimeoutAfter(TimeSpan.FromSeconds(30)); + _console.ConsoleCancelKey(); + + var exitCode = await program.TimeoutAfter(TimeSpan.FromSeconds(30)); + + Assert.Contains("Shutdown requested. Press Ctrl+C again to force exit.", stdout.ToString()); + Assert.Equal(0, exitCode); + } + + public void Dispose() + { + _tempDir.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/test/Scenario/ProjectToolScenario.cs b/src/Tools/dotnet-watch/test/Scenario/ProjectToolScenario.cs new file mode 100644 index 0000000000..a0a14093ec --- /dev/null +++ b/src/Tools/dotnet-watch/test/Scenario/ProjectToolScenario.cs @@ -0,0 +1,186 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Tools.Internal; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class ProjectToolScenario : IDisposable + { + private static readonly string TestProjectSourceRoot = Path.Combine(AppContext.BaseDirectory, "TestProjects"); + private readonly ITestOutputHelper _logger; + + public ProjectToolScenario() + : this(null) + { + } + + public ProjectToolScenario(ITestOutputHelper logger) + { + WorkFolder = Path.Combine(AppContext.BaseDirectory, "tmp", Path.GetRandomFileName()); + DotNetWatchPath = Path.Combine(AppContext.BaseDirectory, "tool", "dotnet-watch.dll"); + + _logger = logger; + _logger?.WriteLine($"The temporary test folder is {WorkFolder}"); + + CreateTestDirectory(); + } + + public string WorkFolder { get; } + + public string DotNetWatchPath { get; } + + public void AddTestProjectFolder(string projectName) + { + var srcFolder = Path.Combine(TestProjectSourceRoot, projectName); + var destinationFolder = Path.Combine(WorkFolder, Path.GetFileName(projectName)); + _logger?.WriteLine($"Copying project {srcFolder} to {destinationFolder}"); + + Directory.CreateDirectory(destinationFolder); + + foreach (var directory in Directory.GetDirectories(srcFolder, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(directory.Replace(srcFolder, destinationFolder)); + } + + foreach (var file in Directory.GetFiles(srcFolder, "*", SearchOption.AllDirectories)) + { + File.Copy(file, file.Replace(srcFolder, destinationFolder), true); + } + } + + public Task RestoreAsync(string project) + { + _logger?.WriteLine($"Restoring msbuild project in {project}"); + return ExecuteCommandAsync(project, TimeSpan.FromSeconds(120), "restore"); + } + + public Task BuildAsync(string project) + { + _logger?.WriteLine($"Building {project}"); + return ExecuteCommandAsync(project, TimeSpan.FromSeconds(60), "build"); + } + + private async Task ExecuteCommandAsync(string project, TimeSpan timeout, params string[] arguments) + { + var tcs = new TaskCompletionSource(); + project = Path.Combine(WorkFolder, project); + _logger?.WriteLine($"Project directory: '{project}'"); + + var process = new Process + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo + { + FileName = DotNetMuxer.MuxerPathOrDefault(), + Arguments = ArgumentEscaper.EscapeAndConcatenate(arguments), + WorkingDirectory = project, + RedirectStandardOutput = true, + RedirectStandardError = true, + Environment = + { + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "true" + } + }, + }; + + void OnData(object sender, DataReceivedEventArgs args) + => _logger?.WriteLine(args.Data ?? string.Empty); + + void OnExit(object sender, EventArgs args) + { + _logger?.WriteLine($"Process exited {process.Id}"); + tcs.TrySetResult(null); + } + + process.ErrorDataReceived += OnData; + process.OutputDataReceived += OnData; + process.Exited += OnExit; + + process.Start(); + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _logger?.WriteLine($"Started process {process.Id}: {process.StartInfo.FileName} {process.StartInfo.Arguments}"); + + var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout)); + process.CancelErrorRead(); + process.CancelOutputRead(); + + process.ErrorDataReceived -= OnData; + process.OutputDataReceived -= OnData; + process.Exited -= OnExit; + + if (!ReferenceEquals(done, tcs.Task)) + { + if (!process.HasExited) + { + _logger?.WriteLine($"Killing process {process.Id}"); + process.KillTree(); + } + + throw new TimeoutException($"Process timed out after {timeout.TotalSeconds} seconds"); + } + + _logger?.WriteLine($"Process exited {process.Id} with code {process.ExitCode}"); + if (process.ExitCode != 0) + { + + throw new InvalidOperationException($"Exit code {process.ExitCode}"); + } + } + + private void CreateTestDirectory() + { + Directory.CreateDirectory(WorkFolder); + + File.WriteAllText(Path.Combine(WorkFolder, "Directory.Build.props"), ""); + + var restoreSources = GetMetadata("TestSettings:RestoreSources"); + var frameworkVersion = GetMetadata("TestSettings:RuntimeFrameworkVersion"); + + var dbTargets = new XDocument( + new XElement("Project", + new XElement("PropertyGroup", + new XElement("RuntimeFrameworkVersion", frameworkVersion), + new XElement("RestoreSources", restoreSources)))); + dbTargets.Save(Path.Combine(WorkFolder, "Directory.Build.targets")); + } + + private string GetMetadata(string key) + { + return typeof(ProjectToolScenario) + .Assembly + .GetCustomAttributes() + .First(a => string.Equals(a.Key, key, StringComparison.Ordinal)) + .Value; + } + + public void Dispose() + { + try + { + Directory.Delete(WorkFolder, recursive: true); + } + catch + { + Console.WriteLine($"Failed to delete {WorkFolder}. Retrying..."); + Thread.Sleep(TimeSpan.FromSeconds(5)); + Directory.Delete(WorkFolder, recursive: true); + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs b/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs new file mode 100644 index 0000000000..56702afac2 --- /dev/null +++ b/src/Tools/dotnet-watch/test/Scenario/WatchableApp.cs @@ -0,0 +1,128 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests +{ + public class WatchableApp : IDisposable + { + private static readonly TimeSpan DefaultMessageTimeOut = TimeSpan.FromSeconds(30); + + private const string StartedMessage = "Started"; + private const string ExitingMessage = "Exiting"; + private const string WatchExitedMessage = "watch : Exited"; + private const string WaitingForFileChangeMessage = "watch : Waiting for a file to change"; + + private readonly ITestOutputHelper _logger; + private string _appName; + private bool _prepared; + + + public WatchableApp(string appName, ITestOutputHelper logger) + { + _logger = logger; + _appName = appName; + Scenario = new ProjectToolScenario(logger); + Scenario.AddTestProjectFolder(appName); + SourceDirectory = Path.Combine(Scenario.WorkFolder, appName); + } + + public ProjectToolScenario Scenario { get; } + + public AwaitableProcess Process { get; protected set; } + + public string SourceDirectory { get; } + + public Task HasRestarted() + => Process.GetOutputLineAsync(StartedMessage, DefaultMessageTimeOut); + + public async Task HasExited() + { + await Process.GetOutputLineAsync(ExitingMessage, DefaultMessageTimeOut); + await Process.GetOutputLineAsync(WatchExitedMessage, DefaultMessageTimeOut); + } + + public async Task IsWaitingForFileChange() + { + await Process.GetOutputLineStartsWithAsync(WaitingForFileChangeMessage, DefaultMessageTimeOut); + } + + public bool UsePollingWatcher { get; set; } + + public async Task GetProcessId() + { + var line = await Process.GetOutputLineStartsWithAsync("PID =", DefaultMessageTimeOut); + var pid = line.Split('=').Last(); + return int.Parse(pid); + } + + public async Task PrepareAsync() + { + await Scenario.RestoreAsync(_appName); + await Scenario.BuildAsync(_appName); + _prepared = true; + } + + public void Start(IEnumerable arguments, [CallerMemberName] string name = null) + { + if (!_prepared) + { + throw new InvalidOperationException($"Call {nameof(PrepareAsync)} first"); + } + + var args = new List + { + Scenario.DotNetWatchPath, + }; + args.AddRange(arguments); + + var spec = new ProcessSpec + { + Executable = DotNetMuxer.MuxerPathOrDefault(), + Arguments = args, + WorkingDirectory = SourceDirectory, + EnvironmentVariables = + { + ["DOTNET_CLI_CONTEXT_VERBOSE"] = bool.TrueString, + ["DOTNET_USE_POLLING_FILE_WATCHER"] = UsePollingWatcher.ToString(), + }, + }; + + Process = new AwaitableProcess(spec, _logger); + Process.Start(); + } + + public Task StartWatcherAsync([CallerMemberName] string name = null) + => StartWatcherAsync(Array.Empty(), name); + + public async Task StartWatcherAsync(string[] arguments, [CallerMemberName] string name = null) + { + if (!_prepared) + { + await PrepareAsync(); + } + + var args = new[] { "run", "--" }.Concat(arguments); + Start(args, name); + + // Make this timeout long because it depends much on the MSBuild compilation speed. + // Slow machines may take a bit to compile and boot test apps + await Process.GetOutputLineAsync(StartedMessage, TimeSpan.FromMinutes(2)); + } + + public virtual void Dispose() + { + _logger?.WriteLine("Disposing WatchableApp"); + Process?.Dispose(); + Scenario?.Dispose(); + } + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/AppWithDeps.csproj b/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/AppWithDeps.csproj new file mode 100644 index 0000000000..0dcb552112 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/AppWithDeps.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp2.1 + exe + true + + + + + + + diff --git a/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/Program.cs b/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/Program.cs new file mode 100644 index 0000000000..c9338cbc98 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/AppWithDeps/Program.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Threading; + +namespace ConsoleApplication +{ + public class Program + { + private static readonly int processId = Process.GetCurrentProcess().Id; + + public static void Main(string[] args) + { + Console.WriteLine("Started"); + Console.WriteLine($"PID = " + Process.GetCurrentProcess().Id); + Thread.Sleep(Timeout.Infinite); + } + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/Dependency/Dependency.csproj b/src/Tools/dotnet-watch/test/TestProjects/Dependency/Dependency.csproj new file mode 100644 index 0000000000..0dbf97b944 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/Dependency/Dependency.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + true + + + diff --git a/src/Tools/dotnet-watch/test/TestProjects/Dependency/Foo.cs b/src/Tools/dotnet-watch/test/TestProjects/Dependency/Foo.cs new file mode 100644 index 0000000000..3441304e2f --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/Dependency/Foo.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Dependency +{ + public class Foo + { + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/GlobbingApp.csproj b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/GlobbingApp.csproj new file mode 100644 index 0000000000..a01efb4b2f --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/GlobbingApp.csproj @@ -0,0 +1,14 @@ + + + + netcoreapp2.1 + exe + false + true + + + + + + + diff --git a/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/Program.cs b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/Program.cs new file mode 100644 index 0000000000..768257e074 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/Program.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; + +namespace ConsoleApplication +{ + public class Program + { + public static void Main(string[] args) + { + Console.WriteLine("Started"); + Console.WriteLine("PID = " + Process.GetCurrentProcess().Id); + Console.WriteLine("Defined types = " + typeof(Program).GetTypeInfo().Assembly.DefinedTypes.Count()); + Thread.Sleep(Timeout.Infinite); + } + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/exclude/Baz.cs b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/exclude/Baz.cs new file mode 100644 index 0000000000..fdaebd7201 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/exclude/Baz.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace GlobbingApp.exclude +{ + public class Baz + { + "THIS FILE SHOULD NOT BE INCLUDED IN COMPILATION" + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/include/Foo.cs b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/include/Foo.cs new file mode 100644 index 0000000000..d1afb658fc --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/GlobbingApp/include/Foo.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace GlobbingApp.include +{ + public class Foo + { + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/KitchenSink.csproj b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/KitchenSink.csproj new file mode 100644 index 0000000000..72f7d5cae4 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/KitchenSink.csproj @@ -0,0 +1,18 @@ + + + + .net/obj + .net/bin + + + + + + Exe + netcoreapp2.1 + true + + + + + diff --git a/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs new file mode 100644 index 0000000000..5251cdc1e0 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/KitchenSink/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; + +namespace KitchenSink +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Started"); + Console.WriteLine("PID = " + Process.GetCurrentProcess().Id); + Console.WriteLine("DOTNET_WATCH = " + Environment.GetEnvironmentVariable("DOTNET_WATCH")); + } + } +} diff --git a/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/NoDepsApp.csproj b/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/NoDepsApp.csproj new file mode 100644 index 0000000000..b242bd2546 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/NoDepsApp.csproj @@ -0,0 +1,9 @@ + + + + netcoreapp2.1 + exe + true + + + diff --git a/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/Program.cs b/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/Program.cs new file mode 100644 index 0000000000..b503e70ce6 --- /dev/null +++ b/src/Tools/dotnet-watch/test/TestProjects/NoDepsApp/Program.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Threading; + +namespace ConsoleApplication +{ + public class Program + { + public static void Main(string[] args) + { + Console.WriteLine("Started"); + Console.WriteLine($"PID = " + Process.GetCurrentProcess().Id); + if (args.Length > 0 && args[0] == "--no-exit") + { + Thread.Sleep(Timeout.Infinite); + } + Console.WriteLine("Exiting"); + } + } +} diff --git a/src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs b/src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs new file mode 100644 index 0000000000..7156cf85cf --- /dev/null +++ b/src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public class TemporaryCSharpProject + { + private const string Template = + @" + + {0} + Exe + + + {1} + +"; + + private readonly string _filename; + private readonly TemporaryDirectory _directory; + private List _items = new List(); + private List _properties = new List(); + + public TemporaryCSharpProject(string name, TemporaryDirectory directory) + { + Name = name; + _filename = name + ".csproj"; + _directory = directory; + } + + public string Name { get; } + public string Path => System.IO.Path.Combine(_directory.Root, _filename); + + public TemporaryCSharpProject WithTargetFrameworks(params string[] tfms) + { + Debug.Assert(tfms.Length > 0); + var propertySpec = new PropertySpec + { + Value = string.Join(";", tfms) + }; + propertySpec.Name = tfms.Length == 1 + ? "TargetFramework" + : "TargetFrameworks"; + + return WithProperty(propertySpec); + } + + public TemporaryCSharpProject WithProperty(string name, string value) + => WithProperty(new PropertySpec { Name = name, Value = value }); + + public TemporaryCSharpProject WithProperty(PropertySpec property) + { + var sb = new StringBuilder(); + sb.Append('<').Append(property.Name).Append('>') + .Append(property.Value) + .Append("'); + _properties.Add(sb.ToString()); + return this; + } + + public TemporaryCSharpProject WithItem(string itemName, string include, string condition = null) + => WithItem(new ItemSpec { Name = itemName, Include = include, Condition = condition }); + + public TemporaryCSharpProject WithItem(ItemSpec item) + { + var sb = new StringBuilder("<"); + sb.Append(item.Name).Append(" "); + if (item.Include != null) sb.Append(" Include=\"").Append(item.Include).Append('"'); + if (item.Remove != null) sb.Append(" Remove=\"").Append(item.Remove).Append('"'); + if (item.Update != null) sb.Append(" Update=\"").Append(item.Update).Append('"'); + if (item.Exclude != null) sb.Append(" Exclude=\"").Append(item.Exclude).Append('"'); + if (item.Condition != null) sb.Append(" Exclude=\"").Append(item.Condition).Append('"'); + if (!item.Watch) sb.Append(" Watch=\"false\" "); + sb.Append(" />"); + _items.Add(sb.ToString()); + return this; + } + + public TemporaryCSharpProject WithProjectReference(TemporaryCSharpProject reference, bool watch = true) + { + if (ReferenceEquals(this, reference)) + { + throw new InvalidOperationException("Can add project reference to self"); + } + + return WithItem(new ItemSpec { Name = "ProjectReference", Include = reference.Path, Watch = watch }); + } + + public TemporaryDirectory Dir() => _directory; + + public void Create() + { + _directory.CreateFile(_filename, string.Format(Template, string.Join("\r\n", _properties), string.Join("\r\n", _items))); + } + + public class ItemSpec + { + public string Name { get; set; } + public string Include { get; set; } + public string Exclude { get; set; } + public string Update { get; set; } + public string Remove { get; set; } + public bool Watch { get; set; } = true; + public string Condition { get; set; } + } + + public class PropertySpec + { + public string Name { get; set; } + public string Value { get; set; } + } + } +} diff --git a/src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs b/src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs new file mode 100644 index 0000000000..692899817e --- /dev/null +++ b/src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public class TemporaryDirectory : IDisposable + { + private List _projects = new List(); + private List _subdirs = new List(); + private Dictionary _files = new Dictionary(); + private TemporaryDirectory _parent; + + public TemporaryDirectory() + { + Root = Path.Combine(Path.GetTempPath(), "dotnet-watch-tests", Guid.NewGuid().ToString("N")); + } + + private TemporaryDirectory(string path, TemporaryDirectory parent) + { + _parent = parent; + Root = path; + } + + public TemporaryDirectory SubDir(string name) + { + var subdir = new TemporaryDirectory(Path.Combine(Root, name), this); + _subdirs.Add(subdir); + return subdir; + } + + public string Root { get; } + + public TemporaryCSharpProject WithCSharpProject(string name) + { + var project = new TemporaryCSharpProject(name, this); + _projects.Add(project); + return project; + } + + public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project) + { + project = WithCSharpProject(name); + return project; + } + + public TemporaryDirectory WithFile(string name, string contents = "") + { + _files[name] = contents; + return this; + } + + public TemporaryDirectory Up() + { + if (_parent == null) + { + throw new InvalidOperationException("This is the root directory"); + } + return _parent; + } + + public void Create() + { + Directory.CreateDirectory(Root); + + foreach (var dir in _subdirs) + { + dir.Create(); + } + + foreach (var project in _projects) + { + project.Create(); + } + + foreach (var file in _files) + { + CreateFile(file.Key, file.Value); + } + } + + public void CreateFile(string filename, string contents) + { + File.WriteAllText(Path.Combine(Root, filename), contents); + } + + public void Dispose() + { + if (Root == null || !Directory.Exists(Root) || _parent != null) + { + return; + } + + try + { + Directory.Delete(Root, recursive: true); + } + catch + { + Console.Error.WriteLine($"Test cleanup failed to delete '{Root}'"); + } + } + } +} diff --git a/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs b/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs new file mode 100644 index 0000000000..a8e615d3c9 --- /dev/null +++ b/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Tools.Tests +{ + public class TestProjectGraph + { + private readonly TemporaryDirectory _directory; + private Action _onCreate; + private Dictionary _projects = new Dictionary(); + public TestProjectGraph(TemporaryDirectory directory) + { + _directory = directory; + } + + public void OnCreate(Action onCreate) + { + _onCreate = onCreate; + } + + public TemporaryCSharpProject Find(string projectName) + => _projects.ContainsKey(projectName) + ? _projects[projectName] + : null; + + public TemporaryCSharpProject GetOrCreate(string projectName) + { + TemporaryCSharpProject sourceProj; + if (!_projects.TryGetValue(projectName, out sourceProj)) + { + sourceProj = _directory.SubDir(projectName).WithCSharpProject(projectName); + _onCreate?.Invoke(sourceProj); + _projects.Add(projectName, sourceProj); + } + return sourceProj; + } + } +} \ No newline at end of file diff --git a/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj b/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj new file mode 100644 index 0000000000..2bad8c40c4 --- /dev/null +++ b/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj @@ -0,0 +1,39 @@ + + + + netcoreapp2.1 + Microsoft.DotNet.Watcher.Tools.Tests + $(DefaultItemExcludes);TestProjects\**\* + + + + + + + + + + + + + + <_Parameter1>TestSettings:RestoreSources + <_Parameter2>$(RestoreSources) + + + <_Parameter1>TestSettings:RuntimeFrameworkVersion + <_Parameter2>$(RuntimeFrameworkVersion) + + + + + + + + + + + + diff --git a/src/Tools/shared/src/CliContext.cs b/src/Tools/shared/src/CliContext.cs new file mode 100644 index 0000000000..ad766a2e3b --- /dev/null +++ b/src/Tools/shared/src/CliContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Tools.Internal +{ + public static class CliContext + { + /// + /// dotnet --verbose subcommand + /// + /// + public static bool IsGlobalVerbose() + { + bool globalVerbose; + bool.TryParse(Environment.GetEnvironmentVariable("DOTNET_CLI_CONTEXT_VERBOSE"), out globalVerbose); + return globalVerbose; + } + } +} \ No newline at end of file diff --git a/src/Tools/shared/src/CommandLineApplicationExtensions.cs b/src/Tools/shared/src/CommandLineApplicationExtensions.cs new file mode 100644 index 0000000000..6206c861c0 --- /dev/null +++ b/src/Tools/shared/src/CommandLineApplicationExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal static class CommandLineApplicationExtensions + { + public static CommandOption HelpOption(this CommandLineApplication app) + => app.HelpOption("-?|-h|--help"); + + public static CommandOption VerboseOption(this CommandLineApplication app) + => app.Option("-v|--verbose", "Show verbose output", CommandOptionType.NoValue, inherited: true); + + public static void OnExecute(this CommandLineApplication app, Action action) + => app.OnExecute(() => + { + action(); + return 0; + }); + + public static void VersionOptionFromAssemblyAttributes(this CommandLineApplication app, Assembly assembly) + => app.VersionOption("--version", GetInformationalVersion(assembly)); + + private static string GetInformationalVersion(Assembly assembly) + { + var attribute = assembly.GetCustomAttribute(); + + var versionAttribute = attribute == null + ? assembly.GetName().Version.ToString() + : attribute.InformationalVersion; + + return versionAttribute; + } + } +} diff --git a/src/Tools/shared/src/ConsoleReporter.cs b/src/Tools/shared/src/ConsoleReporter.cs new file mode 100644 index 0000000000..ffcd1e8705 --- /dev/null +++ b/src/Tools/shared/src/ConsoleReporter.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.Tools.Internal +{ + public class ConsoleReporter : IReporter + { + private object _writeLock = new object(); + + public ConsoleReporter(IConsole console) + : this(console, verbose: false, quiet: false) + { } + + public ConsoleReporter(IConsole console, bool verbose, bool quiet) + { + Ensure.NotNull(console, nameof(console)); + + Console = console; + IsVerbose = verbose; + IsQuiet = quiet; + } + + protected IConsole Console { get; } + public bool IsVerbose { get; set; } + public bool IsQuiet { get; set; } + + protected virtual void WriteLine(TextWriter writer, string message, ConsoleColor? color) + { + lock (_writeLock) + { + if (color.HasValue) + { + Console.ForegroundColor = color.Value; + } + + writer.WriteLine(message); + + if (color.HasValue) + { + Console.ResetColor(); + } + } + } + + public virtual void Error(string message) + => WriteLine(Console.Error, message, ConsoleColor.Red); + public virtual void Warn(string message) + => WriteLine(Console.Out, message, ConsoleColor.Yellow); + + public virtual void Output(string message) + { + if (IsQuiet) + { + return; + } + WriteLine(Console.Out, message, color: null); + } + + public virtual void Verbose(string message) + { + if (!IsVerbose) + { + return; + } + + WriteLine(Console.Out, message, ConsoleColor.DarkGray); + } + } +} diff --git a/src/Tools/shared/src/DebugHelper.cs b/src/Tools/shared/src/DebugHelper.cs new file mode 100644 index 0000000000..bb7f966d65 --- /dev/null +++ b/src/Tools/shared/src/DebugHelper.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.Extensions.Tools.Internal +{ + public static class DebugHelper + { + [Conditional("DEBUG")] + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = args.Skip(1).ToArray(); + Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); + Console.WriteLine($"Process ID: {Process.GetCurrentProcess().Id}"); + Console.ReadLine(); + } + } + } +} \ No newline at end of file diff --git a/src/Tools/shared/src/Ensure.cs b/src/Tools/shared/src/Ensure.cs new file mode 100644 index 0000000000..5cb8ff7ec7 --- /dev/null +++ b/src/Tools/shared/src/Ensure.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Tools.Internal +{ + internal static class Ensure + { + public static T NotNull(T obj, string paramName) + where T : class + { + if (obj == null) + { + throw new ArgumentNullException(paramName); + } + return obj; + } + + public static string NotNullOrEmpty(string obj, string paramName) + { + if (string.IsNullOrEmpty(obj)) + { + throw new ArgumentException("Value cannot be null or an empty string.", paramName); + } + return obj; + } + } +} diff --git a/src/Tools/shared/src/IConsole.cs b/src/Tools/shared/src/IConsole.cs new file mode 100644 index 0000000000..7216c267a0 --- /dev/null +++ b/src/Tools/shared/src/IConsole.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.Tools.Internal +{ + public interface IConsole + { + event ConsoleCancelEventHandler CancelKeyPress; + TextWriter Out { get; } + TextWriter Error { get; } + TextReader In { get; } + bool IsInputRedirected { get; } + bool IsOutputRedirected { get; } + bool IsErrorRedirected { get; } + ConsoleColor ForegroundColor { get; set; } + void ResetColor(); + } +} diff --git a/src/Tools/shared/src/IReporter.cs b/src/Tools/shared/src/IReporter.cs new file mode 100644 index 0000000000..890dec3f7e --- /dev/null +++ b/src/Tools/shared/src/IReporter.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Tools.Internal +{ + public interface IReporter + { + void Verbose(string message); + void Output(string message); + void Warn(string message); + void Error(string message); + } +} \ No newline at end of file diff --git a/src/Tools/shared/src/NullReporter.cs b/src/Tools/shared/src/NullReporter.cs new file mode 100644 index 0000000000..5d80aeac91 --- /dev/null +++ b/src/Tools/shared/src/NullReporter.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Tools.Internal +{ + public class NullReporter : IReporter + { + private NullReporter() + { } + + public static IReporter Singleton { get; } = new NullReporter(); + + public void Verbose(string message) + { } + + public void Output(string message) + { } + + public void Warn(string message) + { } + + public void Error(string message) + { } + } +} diff --git a/src/Tools/shared/src/PhysicalConsole.cs b/src/Tools/shared/src/PhysicalConsole.cs new file mode 100644 index 0000000000..9a93323d5c --- /dev/null +++ b/src/Tools/shared/src/PhysicalConsole.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.Tools.Internal +{ + public class PhysicalConsole : IConsole + { + private PhysicalConsole() + { + Console.CancelKeyPress += (o, e) => + { + CancelKeyPress?.Invoke(o, e); + }; + } + + public static IConsole Singleton { get; } = new PhysicalConsole(); + + public event ConsoleCancelEventHandler CancelKeyPress; + public TextWriter Error => Console.Error; + public TextReader In => Console.In; + public TextWriter Out => Console.Out; + public bool IsInputRedirected => Console.IsInputRedirected; + public bool IsOutputRedirected => Console.IsOutputRedirected; + public bool IsErrorRedirected => Console.IsErrorRedirected; + public ConsoleColor ForegroundColor + { + get => Console.ForegroundColor; + set => Console.ForegroundColor = value; + } + + public void ResetColor() => Console.ResetColor(); + } +} diff --git a/src/Tools/shared/test/TestConsole.cs b/src/Tools/shared/test/TestConsole.cs new file mode 100644 index 0000000000..b8198238f3 --- /dev/null +++ b/src/Tools/shared/test/TestConsole.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Text; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Tools.Internal +{ + public class TestConsole : IConsole + { + private event ConsoleCancelEventHandler _cancelKeyPress; + private readonly TaskCompletionSource _cancelKeySubscribed = new TaskCompletionSource(); + + public TestConsole(ITestOutputHelper output) + { + var writer = new TestOutputWriter(output); + Error = writer; + Out = writer; + } + + public event ConsoleCancelEventHandler CancelKeyPress + { + add + { + _cancelKeyPress += value; + _cancelKeySubscribed.TrySetResult(true); + } + remove => _cancelKeyPress -= value; + } + + public Task CancelKeyPressSubscribed => _cancelKeySubscribed.Task; + + public TextWriter Error { get; set; } + public TextWriter Out { get; set; } + public TextReader In { get; set; } = new StringReader(string.Empty); + public bool IsInputRedirected { get; set; } = false; + public bool IsOutputRedirected { get; } = false; + public bool IsErrorRedirected { get; } = false; + public ConsoleColor ForegroundColor { get; set; } + + public ConsoleCancelEventArgs ConsoleCancelKey() + { + var ctor = typeof(ConsoleCancelEventArgs) + .GetTypeInfo() + .DeclaredConstructors + .Single(c => c.GetParameters().First().ParameterType == typeof(ConsoleSpecialKey)); + var args = (ConsoleCancelEventArgs)ctor.Invoke(new object[] { ConsoleSpecialKey.ControlC }); + _cancelKeyPress.Invoke(this, args); + return args; + } + + public void ResetColor() + { + } + + private class TestOutputWriter : TextWriter + { + private readonly ITestOutputHelper _output; + private readonly StringBuilder _sb = new StringBuilder(); + + public TestOutputWriter(ITestOutputHelper output) + { + _output = output; + } + + public override Encoding Encoding => Encoding.Unicode; + + public override void Write(char value) + { + if (value == '\r' || value == '\n') + { + if (_sb.Length > 0) + { + _output.WriteLine(_sb.ToString()); + _sb.Clear(); + } + } + else + { + _sb.Append(value); + } + } + } + } +} diff --git a/src/Tools/shared/test/TestReporter.cs b/src/Tools/shared/test/TestReporter.cs new file mode 100644 index 0000000000..e1ed0f5352 --- /dev/null +++ b/src/Tools/shared/test/TestReporter.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Tools.Internal +{ + public class TestReporter : IReporter + { + private readonly ITestOutputHelper _output; + + public TestReporter(ITestOutputHelper output) + { + _output = output; + } + + public void Verbose(string message) + { + _output.WriteLine("verbose: " + message); + } + + public void Output(string message) + { + _output.WriteLine("output: " + message); + } + + public void Warn(string message) + { + _output.WriteLine("warn: " + message); + } + + public void Error(string message) + { + _output.WriteLine("error: " + message); + } + } +} \ No newline at end of file