File: Npm\NpmRunner.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Aspire.Cli.Telemetry;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Npm;
 
/// <summary>
/// Runs npm CLI commands for package management operations.
/// </summary>
internal sealed class NpmRunner(IEnvironment environment, ILogger<NpmRunner> logger, ProfilingTelemetry profilingTelemetry) : INpmRunner
{
    /// <summary>
    /// The canonical public npm registry URL. Commands that resolve, pack, or install
    /// packages pass this explicitly via <c>--registry</c> so resolution and install use
    /// the public feed and cannot inherit a project-level <c>.npmrc</c> that redirects to a
    /// private feed (for example an Azure DevOps Artifacts feed). Such a private feed would
    /// otherwise return 401 for packages (including transitive dependencies) it has not
    /// mirrored, breaking <c>aspire agent init</c>.
    /// See https://github.com/microsoft/aspire/issues/19370.
    /// </summary>
    private const string PublicRegistry = "https://registry.npmjs.org/";
 
    private readonly Lazy<string?> _npmPath = new(() => PathLookupHelper.FindFullPathFromPath("npm"));
 
    /// <inheritdoc />
    public bool IsAvailable => _npmPath.Value is not null;
 
    /// <inheritdoc />
    public async Task<NpmPackageInfo?> ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return null;
        }
 
        logger.LogDebug("Resolving npm package {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, versionRange));
 
        // Use an isolated temp subdirectory so npm doesn't pick up .npmrc or
        // other config files from the shared temp root or the user's CWD.
        var tempDir = CreateIsolatedTempDirectory();
 
        try
        {
            // Resolve version: npm view <package>@<range> version
            var versionOutput = await RunNpmCommandInDirectoryAsync(
                npmPath,
                ["view", NpmPackageInfo.FormatPackageSpecifier(packageName, versionRange), "version", "--registry", PublicRegistry],
                tempDir,
                cancellationToken);
 
            if (versionOutput is null)
            {
                logger.LogDebug("Failed to resolve version for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, versionRange));
                return null;
            }
 
            if (!TryExtractLastVersion(versionOutput, out var versionString))
            {
                logger.LogDebug("Could not extract version from npm output: {Output}", versionOutput.Trim());
                return null;
            }
 
            if (!SemVersion.TryParse(versionString, SemVersionStyles.Any, out var version))
            {
                logger.LogDebug("Could not parse npm version from output: {Output}", versionString);
                return null;
            }
 
            logger.LogDebug("Resolved {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
 
            return new NpmPackageInfo
            {
                Version = version
            };
        }
        finally
        {
            CleanupTempDirectory(tempDir);
        }
    }
 
    /// <inheritdoc />
    public async Task<string?> PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return null;
        }
 
        logger.LogDebug("Packing npm package {PackageSpecifier} to {OutputDirectory}", NpmPackageInfo.FormatPackageSpecifier(packageName, version), outputDirectory);
 
        var output = await RunNpmCommandInDirectoryAsync(
            npmPath,
            ["pack", NpmPackageInfo.FormatPackageSpecifier(packageName, version), "--pack-destination", outputDirectory, "--registry", PublicRegistry],
            outputDirectory,
            cancellationToken);
 
        if (output is null)
        {
            logger.LogDebug("Failed to pack {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
            return null;
        }
 
        // npm pack outputs the filename of the created tarball
        var filename = output.Trim().Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
        if (string.IsNullOrWhiteSpace(filename))
        {
            logger.LogDebug("npm pack returned empty filename");
            return null;
        }
 
        var tarballPath = Path.Combine(outputDirectory, filename);
        if (!File.Exists(tarballPath))
        {
            logger.LogDebug("npm pack output file not found: {Path}", tarballPath);
            return null;
        }
 
        logger.LogDebug("Packed {PackageSpecifier} to {TarballPath}", NpmPackageInfo.FormatPackageSpecifier(packageName, version), tarballPath);
 
        return tarballPath;
    }
 
    /// <inheritdoc />
    public async Task<bool> InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return false;
        }
 
        logger.LogDebug("Installing npm package globally from {TarballPath}", tarballPath);
 
        // Use an isolated temp subdirectory so npm doesn't pick up .npmrc or
        // other config files from the shared temp root or the user's CWD.
        var tempDir = CreateIsolatedTempDirectory();
 
        try
        {
            // The root tarball is provenance-verified, but its transitive dependencies are not.
            // Prevent dependency lifecycle scripts from executing during installation.
            var output = await RunNpmCommandInDirectoryAsync(
                npmPath,
                ["install", "-g", tarballPath, "--ignore-scripts", "--registry", PublicRegistry],
                tempDir,
                cancellationToken);
 
            if (output is null)
            {
                logger.LogDebug("Failed to install npm package globally from {TarballPath}", tarballPath);
                return false;
            }
 
            logger.LogDebug("Successfully installed npm package globally from {TarballPath}", tarballPath);
            return true;
        }
        finally
        {
            CleanupTempDirectory(tempDir);
        }
    }
 
    private string? FindNpmPath()
    {
        var npmPath = _npmPath.Value;
        if (npmPath is null)
        {
            logger.LogDebug("npm is not installed or not found in PATH");
        }
 
        return npmPath;
    }
 
    private static string CreateIsolatedTempDirectory()
    {
        return Directory.CreateTempSubdirectory("aspire-npm-").FullName;
    }
 
    private void CleanupTempDirectory(string tempDir)
    {
        try
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
        catch (IOException ex)
        {
            logger.LogDebug(ex, "Failed to clean up temporary directory: {TempDir}", tempDir);
        }
    }
 
    /// <summary>
    /// Creates a <see cref="ProcessStartInfo"/> configured to run an npm command.
    /// On Windows, .cmd files are invoked via cmd.exe /c for reliable stdout redirection.
    /// </summary>
    internal static ProcessStartInfo CreateNpmProcessStartInfo(string npmPath, string[] args, string workingDirectory, IEnvironment environment)
    {
        var startInfo = new ProcessStartInfo
        {
            // Redirect stdin so the child npm process (and any lifecycle scripts it invokes)
            // does not inherit the CLI's TTY. The caller closes stdin immediately after Start()
            // so any read surfaces as EOF instead of hanging waiting on the terminal. NpmRunner
            // is intended to be fully non-interactive. See https://github.com/microsoft/aspire/issues/16791.
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory
        };
 
        // On Windows, npm resolves to npm.cmd (a batch wrapper). Launching
        // .cmd files via Process.Start with redirected stdout can produce empty
        // output. Use cmd.exe /c to invoke the batch file reliably.
        // Note: cmd.exe /c has special quote-stripping rules that are incompatible
        // with ArgumentList (which individually quotes each argument). We must use
        // the Arguments string property and wrap the entire command in an outer set
        // of quotes so cmd.exe preserves interior quoting correctly.
        if (environment.IsWindows() && npmPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase))
        {
            startInfo.FileName = "cmd.exe";
            startInfo.Arguments = @$"/c """"{npmPath}"" {string.Join(" ", args.Select(a => @$"""{a}"""))}""";
        }
        else
        {
            startInfo.FileName = npmPath;
            foreach (var arg in args)
            {
                startInfo.ArgumentList.Add(arg);
            }
        }
 
        return startInfo;
    }
 
    /// <summary>
    /// Tries to extract the version string from npm view output. When a version range
    /// matches multiple versions, npm returns multi-line output in the format
    /// <c>@scope/pkg@version 'version'</c> per line, sorted ascending. This method
    /// returns the last (highest) version from such output, or the trimmed output
    /// when it contains a single version.
    /// </summary>
    internal static bool TryExtractLastVersion(string npmOutput, [NotNullWhen(true)] out string? version)
    {
        version = null;
 
        var lastLine = npmOutput
            .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
            .LastOrDefault()?
            .Trim();
 
        if (string.IsNullOrEmpty(lastLine))
        {
            return false;
        }
 
        // Multi-version format: "@scope/pkg@version 'version'" — extract the quoted version.
        // Single-version format: just "version" — return as-is.
        var quoteStart = lastLine.IndexOf('\'');
        if (quoteStart >= 0)
        {
            var quoteEnd = lastLine.IndexOf('\'', quoteStart + 1);
            if (quoteEnd > quoteStart)
            {
                version = lastLine[(quoteStart + 1)..quoteEnd];
                return !string.IsNullOrEmpty(version);
            }
        }
 
        version = lastLine;
        return true;
    }
 
    private async Task<string?> RunNpmCommandInDirectoryAsync(string npmPath, string[] args, string workingDirectory, CancellationToken cancellationToken)
    {
        var argsString = string.Join(" ", args);
        logger.LogDebug("Running npm {Args} in {WorkingDirectory}", argsString, workingDirectory);
 
        try
        {
            var startInfo = CreateNpmProcessStartInfo(npmPath, args, workingDirectory, environment);
 
            using var process = new Process { StartInfo = startInfo };
            using var activity = profilingTelemetry.StartNpmCommand(npmPath, args, workingDirectory);
            process.Start();
            // Close stdin so any npm lifecycle script that tries to read terminal input
            // sees EOF instead of blocking on the inherited TTY. See ProcessGuestLauncher
            // and https://github.com/microsoft/aspire/issues/16791.
            try
            {
                process.StandardInput.Close();
            }
            catch (IOException)
            {
                // The child may have already closed its stdin; ignore.
            }
            activity.SetProcessId(process.Id);
 
            var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
 
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            activity.SetProcessExitCode(process.ExitCode);
 
            if (process.ExitCode != 0)
            {
                activity.SetError($"npm exited with code {process.ExitCode}.");
                var errorOutput = await errorTask.ConfigureAwait(false);
                logger.LogDebug("npm {Args} returned non-zero exit code {ExitCode}: {Error}", argsString, process.ExitCode, errorOutput.Trim());
                return null;
            }
 
            return await outputTask.ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
        {
            logger.LogDebug(ex, "Failed to run npm {Args}", argsString);
            return null;
        }
    }
 
}