Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
109 changes: 83 additions & 26 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,13 @@ 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";
Comment thread
sebastienros marked this conversation as resolved.
Resolved
private const string PackageLockFileName = "package-lock.json";
private const string PnpmLockFileName = "pnpm-lock.yaml";

public static bool IsTypeScriptLanguage(LanguageInfo? language)
Expand All @@ -36,35 +36,63 @@ 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 = null)
Comment thread
sebastienros marked this conversation as resolved.
Resolved
{
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 (Directory.Exists(Path.Combine(candidateDirectory.FullName, YarnDirectoryName)))
{
return CreateLockFileResolution(TypeScriptAppHostToolchain.Yarn, YarnDirectoryName, 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 +247,12 @@ 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)
{
var packageJsonPath = Path.Combine(appHostDirectory.FullName, PackageJsonFileName);
if (!File.Exists(packageJsonPath))
{
return SetUnknownToolchain(out toolchain);
return SetUnknownToolchain(out toolchain, out reason);
}

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

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 SetUnknownToolchain(out toolchain, out reason);
}
catch (Exception ex) when (ex is JsonException or IOException
or UnauthorizedAccessException or SecurityException
or NotSupportedException)
{
return SetUnknownToolchain(out toolchain);
return SetUnknownToolchain(out toolchain, out reason);
}
}

Expand All @@ -265,19 +299,42 @@ 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.
Resolved

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.
Resolved
{
var pathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
var parentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentDirectory.FullName));
Comment thread
sebastienros marked this conversation as resolved.
Resolved
if (string.Equals(parentPath, Path.TrimEndingDirectorySeparator(Path.GetFullPath(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}");
}

private static bool SetUnknownToolchain(out TypeScriptAppHostToolchain toolchain, out string reason)
{
toolchain = default;
reason = string.Empty;
Comment thread
sebastienros marked this conversation as resolved.
Resolved
return false;
}
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;

namespace Aspire.Cli.Tests.Projects;

Expand Down Expand Up @@ -32,6 +34,37 @@ public void Resolve_WhenPnpmLockExists_ReturnsPnpm()
Assert.Equal(TypeScriptAppHostToolchain.Pnpm, toolchain);
}

[Fact]
public void Resolve_WhenPackageLockExists_ReturnsNpm()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"workspace\" }");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), string.Empty);
Comment thread
sebastienros marked this conversation as resolved.
Resolved

var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package-lock.json"), "{}");

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory);

Assert.Equal(TypeScriptAppHostToolchain.Npm, resolution.Toolchain);
Assert.Equal($"package-lock.json found in {appHostDirectory.FullName}", resolution.Reason);
}

[Fact]
public void Resolve_WhenPackageLockAndYarnLockExistInSameDirectory_ReturnsYarn()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package-lock.json"), "{}");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), string.Empty);

var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(workspace.WorkspaceRoot);

Assert.Equal(TypeScriptAppHostToolchain.Yarn, resolution.Toolchain);
Assert.Equal($"yarn.lock found in {workspace.WorkspaceRoot.FullName}", resolution.Reason);
}

[Fact]
public void Resolve_WhenYarnDirectoryExists_ReturnsYarn()
{
Expand All @@ -56,12 +89,30 @@ public void Resolve_WhenNothingConfigured_ReturnsNpm()
}

[Fact]
public void Resolve_WhenWorkspaceRootDefinesToolchain_ReturnsParentToolchain()
public void Resolve_WhenMarkerExists_LogsReason()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), string.Empty);

var sink = new TestSink();
var logger = new TestLogger(nameof(TypeScriptAppHostToolchainResolverTests), sink, logLevel => logLevel == LogLevel.Debug);

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger);

Assert.Equal(TypeScriptAppHostToolchain.Yarn, toolchain);
var write = Assert.Single(sink.Writes);
Assert.Equal(LogLevel.Debug, write.LogLevel);
Assert.Equal($"Selected TypeScript AppHost package manager 'yarn' because yarn.lock found in {workspace.WorkspaceRoot.FullName}.", write.Message);
}

[Fact]
public void Resolve_WhenParentDirectoryDefinesToolchain_ReturnsParentToolchain()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.12.1\" }");

var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.12.1\" }");
File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory);
Expand All @@ -70,22 +121,53 @@ public void Resolve_WhenWorkspaceRootDefinesToolchain_ReturnsParentToolchain()
}

[Fact]
public void Resolve_WhenToolchainConfigurationIsBeyondMaxDepth_ReturnsNpm()
public void Resolve_WhenGrandparentDirectoryDefinesToolchain_ReturnsNpm()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"bun@1.2.0\" }");

var currentDirectory = workspace.WorkspaceRoot;
for (var i = 0; i < TypeScriptAppHostToolchainResolver.MaxParentSearchDepth + 1; i++)
{
currentDirectory = currentDirectory.CreateSubdirectory($"level{i}");
}
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");

var toolchain = TypeScriptAppHostToolchainResolver.Resolve(currentDirectory);
var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory);

Assert.Equal(TypeScriptAppHostToolchain.Npm, toolchain);
}

[Fact]
public void ShouldSearchParentDirectory_WhenDirectoryIsRoot_ReturnsFalse()
{
var directory = new DirectoryInfo(Path.GetPathRoot(Path.GetTempPath())!);

var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(directory);

Assert.False(shouldSearch);
}

[Fact]
public void ShouldSearchParentDirectory_WhenDirectoryIsHome_ReturnsFalse()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);

var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
workspace.WorkspaceRoot,
workspace.WorkspaceRoot.FullName);

Assert.False(shouldSearch);
}

[Fact]
public void ShouldSearchParentDirectory_WhenDirectoryIsHomeWithDifferentCasingOnCaseInsensitiveOS_ReturnsFalse()
{
Assert.SkipUnless(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(), "Case-insensitive path comparison only applies to Windows and macOS.");
using var workspace = TemporaryWorkspace.Create(outputHelper);

var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
workspace.WorkspaceRoot,
InvertCasing(workspace.WorkspaceRoot.FullName));

Assert.False(shouldSearch);
}

[Fact]
public void ApplyToRuntimeSpec_WhenBunSelected_UsesBunCommandsAndPreservesExtensionLaunch()
{
Expand Down Expand Up @@ -144,4 +226,9 @@ private static RuntimeSpec CreateBaseRuntimeSpec()
ExtensionLaunchCapability = "node"
};
}

private static string InvertCasing(string value)
{
return new string(value.Select(c => char.IsUpper(c) ? char.ToLowerInvariant(c) : char.ToUpperInvariant(c)).ToArray());
}
}
Loading