Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Projects/GuestAppHostProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,7 +1385,7 @@ private async Task EnsureRuntimeCreatedAsync(
var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(_resolvedLanguage.LanguageId, cancellationToken);
if (TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(_resolvedLanguage))
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _logger);
runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain);
}

Expand Down
110 changes: 81 additions & 29 deletions src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Text.Json.Nodes;
using Aspire.Cli.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Projects;

Expand All @@ -19,14 +20,12 @@ internal enum TypeScriptAppHostToolchain

internal static class TypeScriptAppHostToolchainResolver
{
internal const int MaxParentSearchDepth = 8;

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 YarnConfigFileName = ".yarnrc.yml";
private const string YarnDirectoryName = ".yarn";
private const string PackageLockFileName = "package-lock.json";
private const string PnpmLockFileName = "pnpm-lock.yaml";

public static bool IsTypeScriptLanguage(LanguageInfo? language)
Expand All @@ -36,35 +35,58 @@ public static bool IsTypeScriptLanguage(LanguageInfo? language)
language.LanguageId.Value.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase));
}

public static TypeScriptAppHostToolchain Resolve(DirectoryInfo appHostDirectory)
public static TypeScriptAppHostToolchain Resolve(DirectoryInfo appHostDirectory, ILogger? logger)
{
var resolution = ResolveWithReason(appHostDirectory);
logger?.LogDebug(
"Selected TypeScript AppHost package manager '{PackageManager}' because {Reason}.",
GetCommandName(resolution.Toolchain),
resolution.Reason);

return resolution.Toolchain;
}

internal static TypeScriptAppHostToolchainResolution ResolveWithReason(DirectoryInfo appHostDirectory)
{
foreach (var candidateDirectory in EnumerateCandidateDirectories(appHostDirectory))
{
if (TryGetToolchainFromPackageJson(candidateDirectory, out var configuredToolchain))
if (TryGetToolchainFromPackageJson(candidateDirectory, out var configuredToolchain, out var reason))
{
return new(configuredToolchain, reason);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, BunLockFileName)))
{
return configuredToolchain;
return CreateLockFileResolution(TypeScriptAppHostToolchain.Bun, BunLockFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, BunLockFileName)) ||
File.Exists(Path.Combine(candidateDirectory.FullName, BunBinaryLockFileName)))
if (File.Exists(Path.Combine(candidateDirectory.FullName, BunBinaryLockFileName)))
{
return TypeScriptAppHostToolchain.Bun;
return CreateLockFileResolution(TypeScriptAppHostToolchain.Bun, BunBinaryLockFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, PnpmLockFileName)))
{
return TypeScriptAppHostToolchain.Pnpm;
return CreateLockFileResolution(TypeScriptAppHostToolchain.Pnpm, PnpmLockFileName, candidateDirectory);
}

if (File.Exists(Path.Combine(candidateDirectory.FullName, YarnLockFileName)) ||
File.Exists(Path.Combine(candidateDirectory.FullName, YarnConfigFileName)) ||
Directory.Exists(Path.Combine(candidateDirectory.FullName, YarnDirectoryName)))
if (File.Exists(Path.Combine(candidateDirectory.FullName, YarnLockFileName)))
{
return TypeScriptAppHostToolchain.Yarn;
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 TypeScriptAppHostToolchain.Npm;
return new(TypeScriptAppHostToolchain.Npm, $"no package manager marker found in {appHostDirectory.FullName} or an eligible parent directory");
}

public static string[] GetRequiredCommands(TypeScriptAppHostToolchain toolchain)
Expand Down Expand Up @@ -219,12 +241,15 @@ private static string GetTsConfigFileName(RuntimeSpec runtimeSpec)
return "tsconfig.apphost.json";
}

private static bool TryGetToolchainFromPackageJson(DirectoryInfo appHostDirectory, out TypeScriptAppHostToolchain toolchain)
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 SetUnknownToolchain(out toolchain);
return false;
}

try
Expand All @@ -234,17 +259,23 @@ private static bool TryGetToolchainFromPackageJson(DirectoryInfo appHostDirector
!packageManagerValue.TryGetValue<string>(out var packageManager) ||
string.IsNullOrWhiteSpace(packageManager))
{
return SetUnknownToolchain(out toolchain);
return false;
}

var packageManagerName = packageManager.Split('@', 2)[0];
return TryParseToolchain(packageManagerName, out toolchain);
if (TryParseToolchain(packageManagerName, out toolchain))
{
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 SetUnknownToolchain(out toolchain);
return false;
}
}

Expand All @@ -265,19 +296,40 @@ private static bool TryParseToolchain(string packageManagerName, out TypeScriptA

private static IEnumerable<DirectoryInfo> EnumerateCandidateDirectories(DirectoryInfo appHostDirectory)
{
// Allow nested AppHosts to pick up workspace-level lockfiles/packageManager settings
// without accidentally walking all the way to an unrelated parent directory.
var currentDirectory = appHostDirectory;
for (var depth = 0; currentDirectory is not null && depth <= MaxParentSearchDepth; depth++)
yield return appHostDirectory;
Comment thread
sebastienros marked this conversation as resolved.

// 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))
{
yield return currentDirectory;
currentDirectory = currentDirectory.Parent;
yield return parentDirectory;
}
}

private static bool SetUnknownToolchain(out TypeScriptAppHostToolchain toolchain)
internal static bool ShouldSearchParentDirectory(DirectoryInfo parentDirectory, string? homeDirectory = null)
Comment thread
sebastienros marked this conversation as resolved.
{
toolchain = default;
return false;
var pathComparison = OperatingSystem.IsWindows() || OperatingSystem.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);
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ private async Task<int> InstallDependenciesAsync(
var runtimeSpec = await rpcClient.GetRuntimeSpecAsync(language.LanguageId.Value, cancellationToken);
if (TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(language))
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _logger);
runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain);
}

Expand Down Expand Up @@ -280,14 +280,14 @@ private static bool IsTypeScriptLanguage(LanguageInfo language)
language.LanguageId.Value.Equals(KnownLanguageId.TypeScriptAlias, StringComparison.OrdinalIgnoreCase);
}

private static string GetPackageManagerCommand(DirectoryInfo directory, LanguageInfo language)
private string GetPackageManagerCommand(DirectoryInfo directory, LanguageInfo language)
{
if (!TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(language))
{
return "npm";
}

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _logger);
return TypeScriptAppHostToolchainResolver.GetCommandName(toolchain);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(Cancellation
return [];
}

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, _logger);
var missingResults = new List<EnvironmentCheckResult>();

foreach (var command in TypeScriptAppHostToolchainResolver.GetRequiredCommands(toolchain))
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Utils/MissingJavaScriptToolWarning.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ private static (string InstallCommand, string InstallDisplayName) GetMessagePart
{
if (TypeScriptAppHostToolchainResolver.IsTypeScriptLanguage(language))
{
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, logger: null);
return (TypeScriptAppHostToolchainResolver.GetInstallCommand(toolchain), TypeScriptAppHostToolchainResolver.GetDisplayName(toolchain));
}

Expand Down
Loading
Loading