File: Projects\TypeScriptAppHostToolchainResolver.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.Security;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
internal enum TypeScriptAppHostToolchain
{
    Npm,
    Bun,
    Yarn,
    Pnpm
}
 
internal static class TypeScriptAppHostToolchainResolver
{
    private const string PackageJsonFileName = "package.json";
    private const string BunLockFileName = "bun.lock";
    private const string BunBinaryLockFileName = "bun.lockb";
    private const string YarnLockFileName = "yarn.lock";
    private const string YarnClassicLockFileVersionLine = "# yarn lockfile v1";
    private const string YarnConfigFileName = ".yarnrc.yml";
    private const string PackageLockFileName = "package-lock.json";
    private const string PnpmLockFileName = "pnpm-lock.yaml";
 
    public static bool IsTypeScriptLanguage(LanguageInfo? language)
    {
        return language is not null &&
            (language.LanguageId.Value.Equals(KnownLanguageId.TypeScript, StringComparison.OrdinalIgnoreCase) ||
             language.LanguageId.Value.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase));
    }
 
    public static TypeScriptAppHostToolchain Resolve(DirectoryInfo appHostDirectory, IEnvironment environment, ILogger? logger)
    {
        var resolution = ResolveWithReason(appHostDirectory, environment);
        logger?.LogDebug(
            "Selected TypeScript AppHost package manager '{PackageManager}' because {Reason}.",
            GetCommandName(resolution.Toolchain),
            resolution.Reason);
 
        return resolution.Toolchain;
    }
 
    internal static TypeScriptAppHostToolchainResolution ResolveWithReason(DirectoryInfo appHostDirectory, IEnvironment environment)
    {
        foreach (var candidateDirectory in EnumerateCandidateDirectories(appHostDirectory, environment))
        {
            if (TryGetToolchainFromPackageJson(candidateDirectory, out var configuredToolchain, out var reason))
            {
                return new(configuredToolchain, reason);
            }
 
            if (File.Exists(Path.Combine(candidateDirectory.FullName, BunLockFileName)))
            {
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Bun, BunLockFileName, candidateDirectory);
            }
 
            if (File.Exists(Path.Combine(candidateDirectory.FullName, BunBinaryLockFileName)))
            {
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Bun, BunBinaryLockFileName, candidateDirectory);
            }
 
            if (File.Exists(Path.Combine(candidateDirectory.FullName, PnpmLockFileName)))
            {
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Pnpm, PnpmLockFileName, candidateDirectory);
            }
 
            var yarnLockFilePath = Path.Combine(candidateDirectory.FullName, YarnLockFileName);
            if (File.Exists(yarnLockFilePath))
            {
                if (IsYarnClassicLockFile(yarnLockFilePath))
                {
                    throw CreateYarnClassicNotSupportedException($"the Yarn lockfile at {yarnLockFilePath}");
                }
 
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Yarn, YarnLockFileName, candidateDirectory);
            }
 
            if (File.Exists(Path.Combine(candidateDirectory.FullName, YarnConfigFileName)))
            {
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Yarn, YarnConfigFileName, candidateDirectory);
            }
 
            if (File.Exists(Path.Combine(candidateDirectory.FullName, PackageLockFileName)))
            {
                return CreateLockFileResolution(TypeScriptAppHostToolchain.Npm, PackageLockFileName, candidateDirectory);
            }
        }
 
        return new(TypeScriptAppHostToolchain.Npm, $"no package manager marker found in {appHostDirectory.FullName} or an eligible parent directory");
    }
 
    public static string[] GetRequiredCommands(TypeScriptAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            TypeScriptAppHostToolchain.Npm => ["npm", "npx"],
            _ => [GetCommandName(toolchain)]
        };
    }
 
    public static string GetCommandName(TypeScriptAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            TypeScriptAppHostToolchain.Npm => "npm",
            TypeScriptAppHostToolchain.Bun => "bun",
            TypeScriptAppHostToolchain.Yarn => "yarn",
            TypeScriptAppHostToolchain.Pnpm => "pnpm",
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    public static string GetInstallCommand(TypeScriptAppHostToolchain toolchain)
    {
        return $"{GetCommandName(toolchain)} install";
    }
 
    public static string GetDisplayName(TypeScriptAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            TypeScriptAppHostToolchain.Npm => "Node.js",
            TypeScriptAppHostToolchain.Bun => "Bun",
            TypeScriptAppHostToolchain.Yarn => "Yarn",
            TypeScriptAppHostToolchain.Pnpm => "pnpm",
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, TypeScriptAppHostToolchain toolchain)
    {
        if (toolchain == TypeScriptAppHostToolchain.Npm)
        {
            return baseRuntimeSpec;
        }
 
        var tsConfigFileName = GetTsConfigFileName(baseRuntimeSpec);
 
        return new RuntimeSpec
        {
            Language = baseRuntimeSpec.Language,
            DisplayName = $"TypeScript ({GetDisplayName(toolchain)})",
            CodeGenLanguage = baseRuntimeSpec.CodeGenLanguage,
            DetectionPatterns = baseRuntimeSpec.DetectionPatterns,
            Initialize = baseRuntimeSpec.Initialize,
            InstallDependencies = CreateInstallCommand(toolchain),
            PreExecute = CreatePreExecuteCommands(toolchain, tsConfigFileName),
            Execute = CreateExecuteCommand(toolchain, tsConfigFileName),
            WatchExecute = CreateWatchCommand(toolchain, tsConfigFileName),
            PublishExecute = baseRuntimeSpec.PublishExecute,
            ExtensionLaunchCapability = baseRuntimeSpec.ExtensionLaunchCapability,
            CertificateBundleEnvironmentVariable = baseRuntimeSpec.CertificateBundleEnvironmentVariable,
            MigrationFiles = baseRuntimeSpec.MigrationFiles
        };
    }
 
    private static CommandSpec CreateInstallCommand(TypeScriptAppHostToolchain toolchain)
    {
        // pnpm resolves a parent pnpm-workspace.yaml when install runs in a nested package.
        // The generated brownfield AppHost intentionally lives outside the user's workspace
        // package graph, so install only that package instead of requiring edits to the
        // user's workspace file. See https://pnpm.io/workspaces.
        string[] args = toolchain == TypeScriptAppHostToolchain.Pnpm
            ? ["install", "--ignore-workspace"]
            : ["install"];
 
        return new CommandSpec
        {
            Command = GetCommandName(toolchain),
            Args = args
        };
    }
 
    private static CommandSpec[] CreatePreExecuteCommands(TypeScriptAppHostToolchain toolchain, string tsConfigFileName)
    {
        return
        [
            toolchain switch
            {
                TypeScriptAppHostToolchain.Bun => new CommandSpec
                {
                    Command = "bun",
                    Args = ["run", "tsc", "--noEmit", "-p", tsConfigFileName]
                },
                TypeScriptAppHostToolchain.Yarn => new CommandSpec
                {
                    Command = "yarn",
                    Args = ["run", "tsc", "--noEmit", "-p", tsConfigFileName]
                },
                TypeScriptAppHostToolchain.Pnpm => new CommandSpec
                {
                    Command = "pnpm",
                    Args = ["exec", "tsc", "--noEmit", "-p", tsConfigFileName]
                },
                _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
            }
        ];
    }
 
    private static CommandSpec CreateExecuteCommand(TypeScriptAppHostToolchain toolchain, string tsConfigFileName)
    {
        return toolchain switch
        {
            TypeScriptAppHostToolchain.Bun => new CommandSpec
            {
                Command = "bun",
                Args = ["run", "{appHostFile}"]
            },
            TypeScriptAppHostToolchain.Yarn => new CommandSpec
            {
                Command = "yarn",
                Args = ["run", "tsx", "--tsconfig", tsConfigFileName, "{appHostFile}"]
            },
            TypeScriptAppHostToolchain.Pnpm => new CommandSpec
            {
                Command = "pnpm",
                Args = ["exec", "tsx", "--tsconfig", tsConfigFileName, "{appHostFile}"]
            },
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    private static CommandSpec CreateWatchCommand(TypeScriptAppHostToolchain toolchain, string tsConfigFileName)
    {
        return toolchain switch
        {
            TypeScriptAppHostToolchain.Bun => new CommandSpec
            {
                Command = "bun",
                Args =
                [
                    "run",
                    "nodemon",
                    "--signal", "SIGTERM",
                    "--watch", ".",
                    "--ext", "ts,mts",
                    "--ignore", "node_modules/",
                    "--ignore", ".aspire/modules/",
                    "--exec", $"bun run tsc --noEmit -p {tsConfigFileName} && bun run \"{{appHostFile}}\""
                ]
            },
            TypeScriptAppHostToolchain.Yarn => new CommandSpec
            {
                Command = "yarn",
                Args =
                [
                    "exec",
                    "nodemon",
                    "--signal", "SIGTERM",
                    "--watch", ".",
                    "--ext", "ts,mts",
                    "--ignore", "node_modules/",
                    "--ignore", ".aspire/modules/",
                    "--exec", $"yarn run tsc --noEmit -p {tsConfigFileName} && yarn run tsx --tsconfig {tsConfigFileName} \"{{appHostFile}}\""
                ]
            },
            TypeScriptAppHostToolchain.Pnpm => new CommandSpec
            {
                Command = "pnpm",
                Args =
                [
                    "exec",
                    "nodemon",
                    "--signal", "SIGTERM",
                    "--watch", ".",
                    "--ext", "ts,mts",
                    "--ignore", "node_modules/",
                    "--ignore", ".aspire/modules/",
                    "--exec", $"pnpm exec tsc --noEmit -p {tsConfigFileName} && pnpm exec tsx --tsconfig {tsConfigFileName} \"{{appHostFile}}\""
                ]
            },
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    private static string GetTsConfigFileName(RuntimeSpec runtimeSpec)
    {
        var args = runtimeSpec.Execute.Args;
        for (var i = 0; i < args.Length - 1; i++)
        {
            if (args[i].Equals("--tsconfig", StringComparison.Ordinal))
            {
                return args[i + 1];
            }
        }
 
        return "tsconfig.apphost.json";
    }
 
    private static bool TryGetToolchainFromPackageJson(DirectoryInfo appHostDirectory, out TypeScriptAppHostToolchain toolchain, out string reason)
    {
        toolchain = default;
        reason = string.Empty;
 
        var packageJsonPath = Path.Combine(appHostDirectory.FullName, PackageJsonFileName);
        if (!File.Exists(packageJsonPath))
        {
            return false;
        }
 
        try
        {
            var packageJson = JsonNode.Parse(File.ReadAllText(packageJsonPath), documentOptions: ConfigurationHelper.ParseOptions) as JsonObject;
            if (packageJson?["packageManager"] is not JsonValue packageManagerValue ||
                !packageManagerValue.TryGetValue<string>(out var packageManager) ||
                string.IsNullOrWhiteSpace(packageManager))
            {
                return false;
            }
 
            var packageManagerName = packageManager.Split('@', 2)[0];
            if (TryParseToolchain(packageManagerName, out toolchain))
            {
                if (toolchain == TypeScriptAppHostToolchain.Yarn && IsYarnClassicPackageManager(packageManager))
                {
                    throw CreateYarnClassicNotSupportedException($"'{packageManager}' in {packageJsonPath}");
                }
 
                reason = $"packageManager '{packageManager}' found in {packageJsonPath}";
                return true;
            }
 
            return false;
        }
        catch (Exception ex) when (ex is JsonException or IOException
            or UnauthorizedAccessException or SecurityException
            or NotSupportedException)
        {
            return false;
        }
    }
 
    private static bool TryParseToolchain(string packageManagerName, out TypeScriptAppHostToolchain toolchain)
    {
        TypeScriptAppHostToolchain? result = packageManagerName.ToLowerInvariant() switch
        {
            "npm" => TypeScriptAppHostToolchain.Npm,
            "bun" => TypeScriptAppHostToolchain.Bun,
            "yarn" => TypeScriptAppHostToolchain.Yarn,
            "pnpm" => TypeScriptAppHostToolchain.Pnpm,
            _ => null
        };
 
        toolchain = result ?? default;
        return result.HasValue;
    }
 
    private static bool IsYarnClassicPackageManager(string packageManager)
    {
        const string yarnPackageManagerPrefix = "yarn@";
 
        if (!packageManager.StartsWith(yarnPackageManagerPrefix, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }
 
        var version = packageManager[yarnPackageManagerPrefix.Length..];
        return version.Length > 0 &&
            version[0] == '1' &&
            (version.Length == 1 || !char.IsAsciiDigit(version[1]));
    }
 
    private static YarnClassicNotSupportedException CreateYarnClassicNotSupportedException(string upgradeTarget)
    {
        return new YarnClassicNotSupportedException(
            $"Yarn Classic is not supported for TypeScript AppHosts. Upgrade {upgradeTarget} to Yarn 4 or later, or use npm, pnpm, or Bun.");
    }
 
    private static bool IsYarnClassicLockFile(string yarnLockFilePath)
    {
        try
        {
            var linesRead = 0;
            foreach (var line in File.ReadLines(yarnLockFilePath))
            {
                if (line.Trim().Equals(YarnClassicLockFileVersionLine, StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
 
                linesRead++;
                if (linesRead >= 5)
                {
                    return false;
                }
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
            or SecurityException or NotSupportedException)
        {
            return false;
        }
 
        return false;
    }
 
    private static IEnumerable<DirectoryInfo> EnumerateCandidateDirectories(DirectoryInfo appHostDirectory, IEnvironment environment)
    {
        yield return appHostDirectory;
 
        // Only use the immediate parent as a fallback so a project folder can provide
        // workspace-level hints without inheriting unrelated markers from higher directories.
        var parentDirectory = appHostDirectory.Parent;
        if (parentDirectory is not null && ShouldSearchParentDirectory(parentDirectory, environment))
        {
            yield return parentDirectory;
        }
    }
 
    internal static bool ShouldSearchParentDirectory(DirectoryInfo parentDirectory, IEnvironment environment, string? homeDirectory = null)
    {
        var isWindows = environment.IsWindows();
        var isMacOS = environment.IsMacOS();
        var pathComparison = isWindows || isMacOS
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;
 
        // Root and home directories are not project folders. They can contain unrelated user-level
        // files, so package manager markers there should not influence TypeScript AppHost projects.
        var parentPath = Path.TrimEndingDirectorySeparator(parentDirectory.FullName);
        if (string.Equals(parentPath, Path.TrimEndingDirectorySeparator(parentDirectory.Root.FullName), pathComparison))
        {
            return false;
        }
 
        homeDirectory ??= Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        return string.IsNullOrWhiteSpace(homeDirectory) ||
            !string.Equals(parentPath, Path.TrimEndingDirectorySeparator(Path.GetFullPath(homeDirectory)), pathComparison);
    }
 
    private static TypeScriptAppHostToolchainResolution CreateLockFileResolution(TypeScriptAppHostToolchain toolchain, string markerName, DirectoryInfo directory)
    {
        return new(toolchain, $"{markerName} found in {directory.FullName}");
    }
}
 
internal readonly record struct TypeScriptAppHostToolchainResolution(TypeScriptAppHostToolchain Toolchain, string Reason);