From 35343c5ae48751e336e0c49914d9abdeb9a110f9 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Apr 2026 16:07:59 -0500 Subject: [PATCH 1/3] Reject Yarn Classic for TypeScript AppHosts Yarn Classic is not supported for TypeScript AppHosts because its command behavior differs from supported package managers and can break Aspire argument forwarding. Detect Yarn Classic through packageManager declarations and Yarn v1 lockfiles, then fail early with guidance to use Yarn 4 or later, npm, pnpm, or Bun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptAppHostToolchainResolver.cs | 63 ++++++++++++++++++- ...TypeScriptAppHostToolchainResolverTests.cs | 52 +++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs index c89cc32d1b5..9745273044f 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); } @@ -302,6 +309,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; } @@ -331,6 +343,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.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs index a307a27d5b4..c765b145d31 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() { From f44463bf6f72e892962f13a1144a5c29323268ca Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 5 May 2026 09:25:15 -0500 Subject: [PATCH 2/3] Update yarn tests to v4 --- .../Helpers/TypeScriptAppHostToolchainTestHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.") From cd80730762e47ff74e67cad92705966afedb3c8b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 5 May 2026 09:45:43 -0500 Subject: [PATCH 3/3] Update tes dockerfiles --- tests/Shared/Docker/Dockerfile.e2e | 4 +++- tests/Shared/Docker/Dockerfile.e2e-polyglot-base | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 && \