diff --git a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs index 778d26a44dc..8e769f2e958 100644 --- a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs +++ b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs @@ -24,6 +24,7 @@ internal static class TypeScriptAppHostToolchainResolver 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"; @@ -70,8 +71,14 @@ internal static TypeScriptAppHostToolchainResolution ResolveWithReason(Directory return CreateLockFileResolution(TypeScriptAppHostToolchain.Pnpm, PnpmLockFileName, candidateDirectory); } - if (File.Exists(Path.Combine(candidateDirectory.FullName, YarnLockFileName))) + 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); } @@ -265,6 +272,11 @@ private static bool TryGetToolchainFromPackageJson(DirectoryInfo appHostDirector 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; } @@ -294,6 +306,55 @@ private static bool TryParseToolchain(string packageManagerName, out TypeScriptA 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 InvalidOperationException CreateYarnClassicNotSupportedException(string upgradeTarget) + { + return new InvalidOperationException( + $"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 EnumerateCandidateDirectories(DirectoryInfo appHostDirectory) { yield return appHostDirectory; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TypeScriptAppHostToolchainTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TypeScriptAppHostToolchainTestHelpers.cs index c266d4eeb6c..5c5cf1fe770 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TypeScriptAppHostToolchainTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TypeScriptAppHostToolchainTestHelpers.cs @@ -67,7 +67,7 @@ internal static string GetPackageManager(string toolchain) => NormalizeToolchain(toolchain) switch { "bun" => "bun@1.2.0", - "yarn" => "yarn@1.22.22", + "yarn" => "yarn@4.14.1", "pnpm" => "pnpm@10.0.0", "npm" => "npm@10.0.0", _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, "Unsupported TypeScript AppHost toolchain.") diff --git a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs index 684ee975006..67b71519690 100644 --- a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs @@ -34,6 +34,58 @@ public void Resolve_WhenPnpmLockExists_ReturnsPnpm() Assert.Equal(TypeScriptAppHostToolchain.Pnpm, toolchain); } + [Fact] + public void Resolve_WhenPackageManagerIsYarnClassic_Throws() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packageJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"); + File.WriteAllText(packageJsonPath, "{ \"packageManager\": \"yarn@1.22.22\" }"); + + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null)); + + Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade 'yarn@1.22.22' in {packageJsonPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message); + } + + [Fact] + public void Resolve_WhenPackageManagerIsModernYarn_ReturnsYarn() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"yarn@4.14.1\" }"); + + var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null); + + Assert.Equal(TypeScriptAppHostToolchain.Yarn, toolchain); + } + + [Fact] + public void Resolve_WhenYarnLockIsClassic_Throws() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }"); + var yarnLockPath = Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"); + File.WriteAllText(yarnLockPath, "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n"); + + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null)); + + Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade the Yarn lockfile at {yarnLockPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message); + } + + [Fact] + public void Resolve_WhenParentYarnLockIsClassic_Throws() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost"); + var parentDirectory = appHostDirectory.Parent!; + File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }"); + File.WriteAllText(Path.Combine(parentDirectory.FullName, "package.json"), "{ \"name\": \"workspace\" }"); + var yarnLockPath = Path.Combine(parentDirectory.FullName, "yarn.lock"); + File.WriteAllText(yarnLockPath, "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n"); + + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, logger: null)); + + Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade the Yarn lockfile at {yarnLockPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message); + } + [Fact] public void Resolve_WhenPackageLockExists_ReturnsNpm() { diff --git a/tests/Shared/Docker/Dockerfile.e2e b/tests/Shared/Docker/Dockerfile.e2e index 9adb1fd7558..dd379c3bc78 100644 --- a/tests/Shared/Docker/Dockerfile.e2e +++ b/tests/Shared/Docker/Dockerfile.e2e @@ -64,7 +64,9 @@ RUN apt-get update -qq && \ # Install Node.js plus supported TypeScript AppHost toolchains. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y --no-install-recommends nodejs && \ - npm install -g yarn@1.22.22 pnpm@10 && \ + corepack enable yarn && \ + corepack prepare yarn@4.14.1 --activate && \ + npm install -g pnpm@10 && \ curl -fsSL https://bun.sh/install | bash && \ ln -s /root/.bun/bin/bun /usr/local/bin/bun && \ ln -s /root/.bun/bin/bunx /usr/local/bin/bunx && \ diff --git a/tests/Shared/Docker/Dockerfile.e2e-polyglot-base b/tests/Shared/Docker/Dockerfile.e2e-polyglot-base index 33f22ece629..cd0a04b50a5 100644 --- a/tests/Shared/Docker/Dockerfile.e2e-polyglot-base +++ b/tests/Shared/Docker/Dockerfile.e2e-polyglot-base @@ -66,7 +66,9 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ # Install Node.js plus supported TypeScript AppHost toolchains. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y --no-install-recommends nodejs && \ - npm install -g yarn@1.22.22 pnpm@10 && \ + corepack enable yarn && \ + corepack prepare yarn@4.14.1 --activate && \ + npm install -g pnpm@10 && \ curl -fsSL https://bun.sh/install | bash && \ ln -s /root/.bun/bin/bun /usr/local/bin/bun && \ ln -s /root/.bun/bin/bunx /usr/local/bin/bunx && \