File: Npm\NpmRunner.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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 Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Npm;
 
/// <summary>
/// Runs npm CLI commands for package management operations.
/// </summary>
internal sealed class NpmRunner(ILogger<NpmRunner> logger) : INpmRunner
{
    /// <inheritdoc />
    public async Task<NpmPackageInfo?> ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return null;
        }
 
        // Resolve version: npm view <package>@<range> version
        var versionOutput = await RunNpmCommandAsync(
            npmPath,
            ["view", $"{packageName}@{versionRange}", "version"],
            cancellationToken);
 
        if (versionOutput is null)
        {
            return null;
        }
 
        var versionString = versionOutput.Trim();
        if (!SemVersion.TryParse(versionString, SemVersionStyles.Any, out var version))
        {
            logger.LogDebug("Could not parse npm version from output: {Output}", versionString);
            return null;
        }
 
        // Resolve integrity hash: npm view <package>@<version> dist.integrity
        var integrityOutput = await RunNpmCommandAsync(
            npmPath,
            ["view", $"{packageName}@{version}", "dist.integrity"],
            cancellationToken);
 
        if (string.IsNullOrWhiteSpace(integrityOutput))
        {
            logger.LogDebug("Could not resolve integrity hash for {Package}@{Version}", packageName, version);
            return null;
        }
 
        return new NpmPackageInfo
        {
            Version = version,
            Integrity = integrityOutput.Trim()
        };
    }
 
    /// <inheritdoc />
    public async Task<string?> PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return null;
        }
 
        var output = await RunNpmCommandAsync(
            npmPath,
            ["pack", $"{packageName}@{version}", "--pack-destination", outputDirectory],
            cancellationToken);
 
        if (output is null)
        {
            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;
        }
 
        return tarballPath;
    }
 
    /// <inheritdoc />
    public async Task<bool> AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return false;
        }
 
        // npm audit signatures requires a project context (node_modules + package-lock.json).
        // For global tool installs there is no project, so we create a temporary one.
        // The package must be installed from the registry (not a local tarball) because
        // npm audit signatures skips packages with "resolved: file:..." in the lockfile.
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-npm-audit-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Create minimal package.json
            var packageJson = Path.Combine(tempDir, "package.json");
            await File.WriteAllTextAsync(
                packageJson,
                """{"name":"aspire-verify","version":"1.0.0","private":true}""",
                cancellationToken).ConfigureAwait(false);
 
            // Install the package from the registry to get proper attestation metadata
            var installOutput = await RunNpmCommandInDirectoryAsync(
                npmPath,
                ["install", $"{packageName}@{version}", "--ignore-scripts"],
                tempDir,
                cancellationToken);
 
            if (installOutput is null)
            {
                logger.LogDebug("Failed to install {Package}@{Version} into temporary project for audit", packageName, version);
                return false;
            }
 
            // Run npm audit signatures in the temporary project directory
            var auditOutput = await RunNpmCommandInDirectoryAsync(
                npmPath,
                ["audit", "signatures"],
                tempDir,
                cancellationToken);
 
            return auditOutput is not null;
        }
        finally
        {
            try
            {
                if (Directory.Exists(tempDir))
                {
                    Directory.Delete(tempDir, recursive: true);
                }
            }
            catch (IOException ex)
            {
                logger.LogDebug(ex, "Failed to clean up temporary audit directory: {TempDir}", tempDir);
            }
        }
    }
 
    /// <inheritdoc />
    public async Task<bool> InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken)
    {
        var npmPath = FindNpmPath();
        if (npmPath is null)
        {
            return false;
        }
 
        var output = await RunNpmCommandAsync(
            npmPath,
            ["install", "-g", tarballPath],
            cancellationToken);
 
        return output is not null;
    }
 
    private string? FindNpmPath()
    {
        var npmPath = PathLookupHelper.FindFullPathFromPath("npm");
        if (npmPath is null)
        {
            logger.LogDebug("npm is not installed or not found in PATH");
        }
 
        return npmPath;
    }
 
    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 = new ProcessStartInfo(npmPath)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                WorkingDirectory = workingDirectory
            };
 
            foreach (var arg in args)
            {
                startInfo.ArgumentList.Add(arg);
            }
 
            using var process = new Process { StartInfo = startInfo };
            process.Start();
 
            var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
 
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 
            if (process.ExitCode != 0)
            {
                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;
        }
    }
 
    private async Task<string?> RunNpmCommandAsync(string npmPath, string[] args, CancellationToken cancellationToken)
    {
        var argsString = string.Join(" ", args);
        logger.LogDebug("Running npm {Args}", argsString);
 
        try
        {
            var startInfo = new ProcessStartInfo(npmPath)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };
 
            foreach (var arg in args)
            {
                startInfo.ArgumentList.Add(arg);
            }
 
            using var process = new Process { StartInfo = startInfo };
            process.Start();
 
            var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
 
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 
            if (process.ExitCode != 0)
            {
                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;
        }
    }
}