Add optional packageManagerName to allow alternative package managers

This commit is contained in:
Justin Robb 2019-07-31 12:56:08 -07:00
parent c703093346
commit 751882cf3c
6 changed files with 61 additions and 29 deletions

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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.AspNetCore.Builder;
@ -40,20 +40,23 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
/// <inheritdoc />
public async Task Build(ISpaBuilder spaBuilder)
{
var pkgManagerName = spaBuilder.Options.PackageManagerName;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
}
var logger = LoggerFinder.GetOrCreateLogger(
spaBuilder.ApplicationBuilder,
nameof(AngularCliBuilder));
var npmScriptRunner = new NpmScriptRunner(
var npmScriptRunner = new NodeScriptRunner(
sourcePath,
_npmScriptName,
"--watch",
null);
null,
pkgManagerName);
npmScriptRunner.AttachToLogger(logger);
using (var stdOutReader = new EventedStreamStringReader(npmScriptRunner.StdOut))

View File

@ -25,6 +25,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
ISpaBuilder spaBuilder,
string npmScriptName)
{
var pkgManagerName = spaBuilder.Options.PackageManagerName;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
@ -39,7 +40,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
// Start Angular CLI and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger);
var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, pkgManagerName, logger);
// Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote
@ -62,13 +63,13 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
}
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
string sourcePath, string npmScriptName, string pkgManagerName, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
var npmScriptRunner = new NpmScriptRunner(
sourcePath, npmScriptName, $"--port {portNumber}", null);
var npmScriptRunner = new NodeScriptRunner(
sourcePath, npmScriptName, $"--port {portNumber}", null, pkgManagerName);
npmScriptRunner.AttachToLogger(logger);
Match openBrowserLine;

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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.Logging;
@ -16,14 +16,14 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
/// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
/// capturing any output written to stdio.
/// </summary>
internal class NpmScriptRunner
internal class NodeScriptRunner
{
public EventedStreamReader StdOut { get; }
public EventedStreamReader StdErr { get; }
private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars)
public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars, string pkgManagerName)
{
if (string.IsNullOrEmpty(workingDirectory))
{
@ -35,18 +35,23 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
throw new ArgumentException("Cannot be null or empty.", nameof(scriptName));
}
var npmExe = "npm";
if (string.IsNullOrEmpty(pkgManagerName))
{
throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerName));
}
var exeToRun = pkgManagerName;
var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, the NPM executable is a .cmd file, so it can't be executed
// On Windows, the node executable is a .cmd file, so it can't be executed
// directly (except with UseShellExecute=true, but that's no good, because
// it prevents capturing stdio). So we need to invoke it via "cmd /c".
npmExe = "cmd";
completeArguments = $"/c npm {completeArguments}";
exeToRun = "cmd";
completeArguments = $"/c {pkgManagerName} {completeArguments}";
}
var processStartInfo = new ProcessStartInfo(npmExe)
var processStartInfo = new ProcessStartInfo(exeToRun)
{
Arguments = completeArguments,
UseShellExecute = false,
@ -64,19 +69,19 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
}
}
var process = LaunchNodeProcess(processStartInfo);
var process = LaunchNodeProcess(processStartInfo, pkgManagerName);
StdOut = new EventedStreamReader(process.StandardOutput);
StdErr = new EventedStreamReader(process.StandardError);
}
public void AttachToLogger(ILogger logger)
{
// When the NPM task emits complete lines, pass them through to the real logger
// When the node task emits complete lines, pass them through to the real logger
StdOut.OnReceivedLine += line =>
{
if (!string.IsNullOrWhiteSpace(line))
{
// NPM tasks commonly emit ANSI colors, but it wouldn't make sense to forward
// Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward
// those to loggers (because a logger isn't necessarily any kind of terminal)
logger.LogInformation(StripAnsiColors(line));
}
@ -106,7 +111,7 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
private static string StripAnsiColors(string line)
=> AnsiColorRegex.Replace(line, string.Empty);
private static Process LaunchNodeProcess(ProcessStartInfo startInfo)
private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName)
{
try
{
@ -119,8 +124,8 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
}
catch (Exception ex)
{
var message = $"Failed to start 'npm'. To resolve this:.\n\n"
+ "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n"
var message = $"Failed to start '{commandName}'. To resolve this:.\n\n"
+ $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n"
+ $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
+ " Make sure the executable is in one of those directories, or update your PATH.\n\n"
+ "[2] See the InnerException for further details of the cause.";

View File

@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
ISpaBuilder spaBuilder,
string npmScriptName)
{
var pkgManagerName = spaBuilder.Options.PackageManagerName;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
@ -38,7 +39,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
// Start create-react-app and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
var portTask = StartCreateReactAppServerAsync(sourcePath, npmScriptName, logger);
var portTask = StartCreateReactAppServerAsync(sourcePath, npmScriptName, pkgManagerName, logger);
// Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote
@ -61,7 +62,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
}
private static async Task<int> StartCreateReactAppServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
string sourcePath, string npmScriptName, string pkgManagerName, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting create-react-app server on port {portNumber}...");
@ -71,8 +72,8 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
{ "PORT", portNumber.ToString() },
{ "BROWSER", "none" }, // We don't want create-react-app to open its own extra browser window pointing to the internal dev server port
};
var npmScriptRunner = new NpmScriptRunner(
sourcePath, npmScriptName, null, envVars);
var npmScriptRunner = new NodeScriptRunner(
sourcePath, npmScriptName, null, envVars, pkgManagerName);
npmScriptRunner.AttachToLogger(logger);
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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.AspNetCore.Builder;

View File

@ -1,11 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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 Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using System;
namespace Microsoft.AspNetCore.SpaServices
{
@ -15,6 +14,7 @@ namespace Microsoft.AspNetCore.SpaServices
public class SpaOptions
{
private PathString _defaultPage = "/index.html";
private string _defaultPackageManagerName = "npm";
/// <summary>
/// Constructs a new instance of <see cref="SpaOptions"/>.
@ -30,6 +30,7 @@ namespace Microsoft.AspNetCore.SpaServices
internal SpaOptions(SpaOptions copyFromOptions)
{
_defaultPage = copyFromOptions.DefaultPage;
_defaultPackageManagerName = copyFromOptions.PackageManagerName;
DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions;
SourcePath = copyFromOptions.SourcePath;
}
@ -69,6 +70,27 @@ namespace Microsoft.AspNetCore.SpaServices
/// </summary>
public string SourcePath { get; set; }
/// <summary>
/// Gets or sets the name of the package manager executible, (e.g npm,
/// yarn) to run the SPA.
///
/// If not set, npm will be assumed as the default package manager
/// executable
/// </summary>
public string PackageManagerName
{
get => _defaultPackageManagerName;
set
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException($"The value for {nameof(PackageManagerName)} cannot be null or empty.");
}
_defaultPackageManagerName = value;
}
}
/// <summary>
/// Gets or sets the maximum duration that a request will wait for the SPA
/// to become ready to serve to the client.