From b5fda4ab6b779b21c1601b5543a94e347c5823e5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 20:53:46 -0800 Subject: [PATCH 01/26] Add project reference support for polyglot apphost integrations Allow .aspire/settings.json packages entries to reference local .csproj files instead of NuGet versions. When a value ends in '.csproj', it's treated as a project reference: PrebuiltAppHostServer publishes it via dotnet publish and copies the full transitive DLL closure into the integration libs path. DotNetBasedAppHostServerProject adds ProjectReference elements to the generated csproj. Introduces IntegrationReference record type to represent both NuGet packages and project references, replacing the (Name, Version) tuple throughout IAppHostServerProject.PrepareAsync and related APIs. Fixes #14760 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 23 ++--- .../Commands/Sdk/SdkGenerateCommand.cs | 15 ++-- .../Configuration/AspireJsonConfiguration.cs | 44 ++++++++++ .../Configuration/IntegrationReference.cs | 24 ++++++ src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 22 +++++ .../Projects/AppHostServerProject.cs | 1 + .../Projects/AppHostServerSession.cs | 5 +- .../DotNetBasedAppHostServerProject.cs | 66 ++++++-------- .../Projects/GuestAppHostProject.cs | 41 ++++----- .../Projects/IAppHostServerProject.cs | 5 +- .../Projects/IAppHostServerSession.cs | 6 +- .../Projects/PrebuiltAppHostServer.cs | 86 ++++++++++++++----- .../Scaffolding/ScaffoldingService.cs | 6 +- .../Projects/AppHostServerProjectTests.cs | 39 +++++---- .../Templating/DotNetTemplateFactoryTests.cs | 3 + .../TestAppHostServerSessionFactory.cs | 3 +- .../TestServices/TestDotNetCliRunner.cs | 3 + 17 files changed, 266 insertions(+), 126 deletions(-) create mode 100644 src/Aspire.Cli/Configuration/IntegrationReference.cs diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index ca1e6b6c66f..31e624370c4 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -132,20 +132,23 @@ private async Task DumpCapabilitiesAsync( return ExitCodeConstants.FailedToBuildArtifacts; } - // Build packages list - empty since we only need core capabilities + optional integration - var packages = new List<(string Name, string Version)>(); + // Build integrations list - empty since we only need core capabilities + optional integration + var integrations = new List(); - _logger.LogDebug("Building AppHost server for capability scanning"); + // Add integration project reference if specified + if (integrationProject is not null) + { + integrations.Add(new IntegrationReference( + Path.GetFileNameWithoutExtension(integrationProject.FullName), + Version: null, + ProjectPath: integrationProject.FullName)); + } - // Create project files with the integration project reference if specified - var additionalProjectRefs = integrationProject is not null - ? new[] { integrationProject.FullName } - : null; + _logger.LogDebug("Building AppHost server for capability scanning"); await appHostServerProject.CreateProjectFilesAsync( - packages, - cancellationToken, - additionalProjectReferences: additionalProjectRefs); + integrations, + cancellationToken); var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 57264718cd5..47c22316646 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -134,19 +134,24 @@ private async Task GenerateSdkAsync( var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken); // Build packages list - include the code generator - var packages = new List<(string Name, string Version)>(); + var integrations = new List(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, DotNetBasedAppHostServerProject.DefaultSdkVersion)); + integrations.Add(new IntegrationReference(codeGenPackage, DotNetBasedAppHostServerProject.DefaultSdkVersion, ProjectPath: null)); } + // Add the integration project as a project reference + integrations.Add(new IntegrationReference( + Path.GetFileNameWithoutExtension(integrationProject.FullName), + Version: null, + ProjectPath: integrationProject.FullName)); + _logger.LogDebug("Building AppHost server for SDK generation"); // Create project files with the integration project reference await appHostServerProject.CreateProjectFilesAsync( - packages, - cancellationToken, - additionalProjectReferences: [integrationProject.FullName]); + integrations, + cancellationToken); var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3b25cc8785d..0ea34c8e610 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -171,6 +171,50 @@ public string GetEffectiveSdkVersion(string defaultSdkVersion) return string.IsNullOrWhiteSpace(SdkVersion) ? defaultSdkVersion : SdkVersion; } + /// + /// Gets all integration references (both NuGet packages and project references) + /// including the base Aspire.Hosting package. + /// A value ending in ".csproj" is treated as a project reference; otherwise as a NuGet version. + /// Empty package versions are resolved to the effective SDK version. + /// + /// Default SDK version to use when not configured. + /// The directory containing .aspire/settings.json, used to resolve relative project paths. + /// Enumerable of IntegrationReference objects. + public IEnumerable GetIntegrationReferences(string defaultSdkVersion, string settingsDirectory) + { + var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); + + // Base package always included + yield return new IntegrationReference("Aspire.Hosting", sdkVersion, ProjectPath: null); + + if (Packages is null) + { + yield break; + } + + foreach (var (packageName, value) in Packages) + { + // Skip base packages and SDK-only packages + if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || + string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (value.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + // Project reference — resolve relative path to absolute + var absolutePath = Path.GetFullPath(Path.Combine(settingsDirectory, value)); + yield return new IntegrationReference(packageName, Version: null, ProjectPath: absolutePath); + } + else + { + // NuGet package reference + yield return new IntegrationReference(packageName, string.IsNullOrWhiteSpace(value) ? sdkVersion : value, ProjectPath: null); + } + } + } + /// /// Gets all package references including the base Aspire.Hosting package. /// Empty package versions in settings are resolved to the effective SDK version. diff --git a/src/Aspire.Cli/Configuration/IntegrationReference.cs b/src/Aspire.Cli/Configuration/IntegrationReference.cs new file mode 100644 index 00000000000..286aa37460e --- /dev/null +++ b/src/Aspire.Cli/Configuration/IntegrationReference.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Configuration; + +/// +/// Represents a reference to an Aspire hosting integration, which can be either +/// a NuGet package (with a version) or a local project reference (with a path to a .csproj). +/// +/// The package or assembly name (e.g., "Aspire.Hosting.Redis"). +/// The NuGet package version, or null for project references. +/// The absolute path to the .csproj file, or null for NuGet packages. +internal sealed record IntegrationReference(string Name, string? Version, string? ProjectPath) +{ + /// + /// Returns true if this is a project reference (has a .csproj path). + /// + public bool IsProjectReference => ProjectPath is not null; + + /// + /// Returns true if this is a NuGet package reference (has a version). + /// + public bool IsPackageReference => Version is not null; +} diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 9514676f717..766b61b5fc7 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -39,6 +39,7 @@ internal interface IDotNetCliRunner Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); } internal sealed class DotNetCliRunnerInvocationOptions @@ -713,6 +714,27 @@ public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotN options: options, cancellationToken: cancellationToken); } + + public async Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + using var activity = telemetry.StartDiagnosticActivity(); + + string[] cliArgs = ["publish", projectFilePath.FullName, "-o", outputDirectory.FullName]; + + var env = new Dictionary + { + ["DOTNET_CLI_USE_MSBUILD_SERVER"] = GetMsBuildServerValue() + }; + + return await ExecuteAsync( + args: cliArgs, + env: env, + projectFile: projectFilePath, + workingDirectory: projectFilePath.Directory!, + backchannelCompletionSource: null, + options: options, + cancellationToken: cancellationToken); + } public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 8466bf77766..23ce0936ed1 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -83,6 +83,7 @@ public async Task CreateAsync(string appPath, Cancellatio socketPath, layout, bundleNuGetService, + dotNetCliRunner, packagingService, configurationService, loggerFactory.CreateLogger()); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 133181f30e1..e1376820cb0 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Configuration; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -100,7 +101,7 @@ public AppHostServerSessionFactory( public async Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken) @@ -108,7 +109,7 @@ public async Task CreateAsync( var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken); // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) - var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken); if (!prepareResult.Success) { return new AppHostServerSessionResult( diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 47b8dcbc09e..f63437097cd 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -135,7 +135,7 @@ public void SaveProjectHash(string hash) /// /// Creates the project .csproj content using project references to the local Aspire repository. /// - private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> packages) + private XDocument CreateProjectFile(IEnumerable integrations) { // Determine OS/architecture for DCP package name var (buildOs, buildArch) = GetBuildPlatform(); @@ -183,12 +183,22 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p var addedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); var otherPackages = new List<(string Name, string Version)>(); - foreach (var (name, version) in packages) + foreach (var integration in integrations) { - if (name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + if (integration.IsProjectReference) { - var projectPath = Path.Combine(_repoRoot, "src", name, $"{name}.csproj"); - if (File.Exists(projectPath) && addedProjects.Add(name)) + // Explicit project reference from settings.json + if (addedProjects.Add(integration.Name)) + { + projectRefGroup.Add(new XElement("ProjectReference", + new XAttribute("Include", integration.ProjectPath!), + new XElement("IsAspireProjectResource", "false"))); + } + } + else if (integration.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + { + var projectPath = Path.Combine(_repoRoot, "src", integration.Name, $"{integration.Name}.csproj"); + if (File.Exists(projectPath) && addedProjects.Add(integration.Name)) { projectRefGroup.Add(new XElement("ProjectReference", new XAttribute("Include", projectPath), @@ -197,7 +207,7 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p } else { - otherPackages.Add((name, version)); + otherPackages.Add((integration.Name, integration.Version!)); } } @@ -262,9 +272,8 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p /// Scaffolds the project files. /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( - IEnumerable<(string Name, string Version)> packages, - CancellationToken cancellationToken = default, - IEnumerable? additionalProjectReferences = null) + IEnumerable integrations, + CancellationToken cancellationToken = default) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -288,23 +297,11 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p // Create appsettings.json with ATS assemblies var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var pkg in packages) - { - if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) - { - atsAssemblies.Add(pkg.Name); - } - } - - if (additionalProjectReferences is not null) + foreach (var integration in integrations) { - foreach (var projectPath in additionalProjectReferences) + if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase)) { - var assemblyName = Path.GetFileNameWithoutExtension(projectPath); - if (!atsAssemblies.Contains(assemblyName, StringComparer.OrdinalIgnoreCase)) - { - atsAssemblies.Add(assemblyName); - } + atsAssemblies.Add(integration.Name); } } @@ -364,22 +361,7 @@ await NuGetConfigMerger.CreateOrUpdateAsync( } // Create the project file - var doc = CreateProjectFile(packages); - - // Add additional project references - if (additionalProjectReferences is not null) - { - var additionalProjectRefs = additionalProjectReferences - .Select(path => new XElement("ProjectReference", - new XAttribute("Include", path), - new XElement("IsAspireProjectResource", "false"))) - .ToList(); - - if (additionalProjectRefs.Count > 0) - { - doc.Root!.Add(new XElement("ItemGroup", additionalProjectRefs)); - } - } + var doc = CreateProjectFile(integrations); // Add appsettings.json to output doc.Root!.Add(new XElement("ItemGroup", @@ -433,10 +415,10 @@ await NuGetConfigMerger.CreateOrUpdateAsync( /// public async Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default) { - var (_, channelName) = await CreateProjectFilesAsync(packages, cancellationToken); + var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken); var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); if (!buildSuccess) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 2f2cb3666b4..81ba47c9c32 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -137,21 +137,22 @@ public bool IsUsingProjectReferences(FileInfo appHostFile) } /// - /// Gets all packages including the code generation package for the current language. + /// Gets all integration references including the code generation package for the current language. /// - private async Task> GetAllPackagesAsync( + private async Task> GetAllPackagesAsync( AspireJsonConfiguration config, + DirectoryInfo directory, CancellationToken cancellationToken) { var defaultSdkVersion = GetEffectiveSdkVersion(); - var packages = config.GetAllPackages(defaultSdkVersion).ToList(); + var integrations = config.GetIntegrationReferences(defaultSdkVersion, directory.FullName).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); - packages.Add((codeGenPackage, codeGenVersion)); + integrations.Add(new IntegrationReference(codeGenPackage, codeGenVersion, ProjectPath: null)); } - return packages; + return integrations; } private AspireJsonConfiguration LoadConfiguration(DirectoryInfo directory) @@ -171,10 +172,10 @@ private string GetPrepareSdkVersion(AspireJsonConfiguration config) private static async Task<(bool Success, OutputCollector? Output, string? ChannelName, bool NeedsCodeGen)> PrepareAppHostServerAsync( IAppHostServerProject appHostServerProject, string sdkVersion, - List<(string Name, string Version)> packages, + List integrations, CancellationToken cancellationToken) { - var result = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken); return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } @@ -187,7 +188,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio // Step 1: Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var packages = await GetAllPackagesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); @@ -294,7 +295,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var packages = await GetAllPackagesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( @@ -598,7 +599,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 1: Load config - source of truth for SDK version and packages var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, cancellationToken); + var packages = await GetAllPackagesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) @@ -1009,16 +1010,16 @@ public async Task FindAndStopRunningInstanceAsync(FileInf private async Task GenerateCodeViaRpcAsync( string appPath, IAppHostRpcClient rpcClient, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken) { - var packagesList = packages.ToList(); + var integrationsList = integrations.ToList(); // Use CodeGenerator (e.g., "TypeScript") not LanguageId (e.g., "typescript/nodejs") // The code generator is registered by its Language property, not the runtime ID var codeGenerator = _resolvedLanguage.CodeGenerator; - _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, packagesList.Count); + _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count); // Use the typed RPC method var files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); @@ -1039,7 +1040,7 @@ private async Task GenerateCodeViaRpcAsync( } // Write generation hash for caching - SaveGenerationHash(outputPath, packagesList); + SaveGenerationHash(outputPath, integrationsList); _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path}", files.Count, codeGenerator, outputPath); @@ -1048,24 +1049,24 @@ private async Task GenerateCodeViaRpcAsync( /// /// Saves a hash of the packages to avoid regenerating code unnecessarily. /// - private static void SaveGenerationHash(string generatedPath, List<(string PackageId, string Version)> packages) + private static void SaveGenerationHash(string generatedPath, List integrations) { var hashPath = Path.Combine(generatedPath, ".codegen-hash"); - var hash = ComputePackagesHash(packages); + var hash = ComputePackagesHash(integrations); File.WriteAllText(hashPath, hash); } /// /// Computes a hash of the package list for caching purposes. /// - private static string ComputePackagesHash(List<(string PackageId, string Version)> packages) + private static string ComputePackagesHash(List integrations) { var sb = new System.Text.StringBuilder(); - foreach (var (packageId, version) in packages.OrderBy(p => p.PackageId)) + foreach (var integration in integrations.OrderBy(p => p.Name)) { - sb.Append(packageId); + sb.Append(integration.Name); sb.Append(':'); - sb.Append(version); + sb.Append(integration.Version ?? integration.ProjectPath ?? ""); sb.Append(';'); } var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 351dc2402af..8c19179b308 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Configuration; using Aspire.Cli.Utils; namespace Aspire.Cli.Projects; @@ -38,12 +39,12 @@ internal interface IAppHostServerProject /// For bundle mode: restores integration packages from NuGet. /// /// The Aspire SDK version to use. - /// The integration packages required by the app host. + /// The integration references (NuGet packages and/or project references) required by the app host. /// Cancellation token. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default); /// diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 843ad10b97c..8668a92796c 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -4,6 +4,8 @@ using System.Diagnostics; using Aspire.Cli.Utils; +using Aspire.Cli.Configuration; + namespace Aspire.Cli.Projects; /// @@ -43,7 +45,7 @@ internal interface IAppHostServerSessionFactory /// /// The path to the AppHost project directory. /// The Aspire SDK version to use. - /// The package references to include. + /// The integration references to include. /// Optional environment variables from launch settings. /// Whether to enable debug logging. /// Cancellation token. @@ -51,7 +53,7 @@ internal interface IAppHostServerSessionFactory Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 2a0c1c8c0d3..f0a17097b68 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Text; using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -26,6 +27,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly string _socketPath; private readonly LayoutConfiguration _layout; private readonly BundleNuGetService _nugetService; + private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IPackagingService _packagingService; private readonly IConfigurationService _configurationService; private readonly ILogger _logger; @@ -37,18 +39,12 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject /// /// Initializes a new instance of the PrebuiltAppHostServer class. /// - /// The path to the user's polyglot app host. - /// The socket path for JSON-RPC communication. - /// The bundle layout configuration. - /// The NuGet service for restoring integration packages. - /// The packaging service for channel resolution. - /// The configuration service for reading channel settings. - /// The logger for diagnostic output. public PrebuiltAppHostServer( string appPath, string socketPath, LayoutConfiguration layout, BundleNuGetService nugetService, + IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, ILogger logger) @@ -57,6 +53,7 @@ public PrebuiltAppHostServer( _socketPath = socketPath; _layout = layout; _nugetService = nugetService; + _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; _configurationService = configurationService; _logger = logger; @@ -88,23 +85,25 @@ public string GetServerPath() /// public async Task PrepareAsync( string sdkVersion, - IEnumerable<(string Name, string Version)> packages, + IEnumerable integrations, CancellationToken cancellationToken = default) { - var packageList = packages.ToList(); + var integrationList = integrations.ToList(); + var packageRefs = integrationList.Where(r => r.IsPackageReference).Select(r => (r.Name, r.Version!)).ToList(); + var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList(); try { // Generate appsettings.json with ATS assemblies for the server to scan - await GenerateAppSettingsAsync(packageList, cancellationToken); + await GenerateAppSettingsAsync(integrationList, cancellationToken); // Resolve the configured channel (local settings.json → global config fallback) var channelName = await ResolveChannelNameAsync(cancellationToken); - // Restore integration packages - if (packageList.Count > 0) + // Restore NuGet integration packages + if (packageRefs.Count > 0) { - _logger.LogDebug("Restoring {Count} integration packages", packageList.Count); + _logger.LogDebug("Restoring {Count} integration packages", packageRefs.Count); // Get NuGet sources filtered to the resolved channel var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); @@ -113,13 +112,60 @@ public async Task PrepareAsync( var appHostDirectory = Path.GetDirectoryName(_appPath); _integrationLibsPath = await _nugetService.RestorePackagesAsync( - packageList, + packageRefs, "net10.0", sources: sources, workingDirectory: appHostDirectory, ct: cancellationToken); } + // Build and publish project references + if (projectRefs.Count > 0) + { + _logger.LogDebug("Building {Count} project references", projectRefs.Count); + + // Ensure we have a libs directory to copy into + if (_integrationLibsPath is null) + { + _integrationLibsPath = Path.Combine(Path.GetTempPath(), ".aspire", "project-refs", Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(_integrationLibsPath); + } + + foreach (var projectRef in projectRefs) + { + var projectFile = new FileInfo(projectRef.ProjectPath!); + if (!projectFile.Exists) + { + _logger.LogError("Project reference not found: {Path}", projectRef.ProjectPath); + throw new FileNotFoundException($"Project reference not found: {projectRef.ProjectPath}"); + } + + // Publish to a temp directory to get the full transitive closure + var publishDir = new DirectoryInfo(Path.Combine(Path.GetTempPath(), ".aspire", "publish", Guid.NewGuid().ToString("N")[..12])); + Directory.CreateDirectory(publishDir.FullName); + + _logger.LogDebug("Publishing project reference {Name} from {Path} to {OutputDir}", projectRef.Name, projectRef.ProjectPath, publishDir.FullName); + + var exitCode = await _dotNetCliRunner.PublishProjectAsync( + projectFile, + publishDir, + new DotNetCliRunnerInvocationOptions(), + cancellationToken); + + if (exitCode != 0) + { + throw new InvalidOperationException($"Failed to publish project reference '{projectRef.Name}' at {projectRef.ProjectPath}. Exit code: {exitCode}"); + } + + // Copy all DLLs from publish output into the integration libs path + foreach (var dll in Directory.GetFiles(publishDir.FullName, "*.dll")) + { + var destPath = Path.Combine(_integrationLibsPath, Path.GetFileName(dll)); + File.Copy(dll, destPath, overwrite: true); + } + } + } + return new AppHostServerPrepareResult( Success: true, Output: null, @@ -320,24 +366,24 @@ public async Task PrepareAsync( public string GetInstanceIdentifier() => _appPath; private async Task GenerateAppSettingsAsync( - List<(string Name, string Version)> packages, + List integrations, CancellationToken cancellationToken) { // Build the list of ATS assemblies (for [AspireExport] scanning) // Skip SDK-only packages that don't have runtime DLLs var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var (name, _) in packages) + foreach (var integration in integrations) { // Skip SDK packages that don't produce runtime assemblies - if (name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || - name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) + if (integration.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + integration.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) { continue; } - if (!atsAssemblies.Contains(name, StringComparer.OrdinalIgnoreCase)) + if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase)) { - atsAssemblies.Add(name); + atsAssemblies.Add(integration.Name); } } diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index a60928588dd..431f2470672 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -61,11 +61,11 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat // Include the code generation package for scaffolding and code gen var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken); - var packages = config.GetAllPackages(sdkVersion).ToList(); + var integrations = config.GetIntegrationReferences(sdkVersion, directory.FullName).ToList(); if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); - packages.Add((codeGenPackage, codeGenVersion)); + integrations.Add(new IntegrationReference(codeGenPackage, codeGenVersion, ProjectPath: null)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); @@ -73,7 +73,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat var prepareResult = await _interactionService.ShowStatusAsync( "Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(prepareSdkVersion, packages, cancellationToken), + () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken), emoji: KnownEmojis.Gear); if (!prepareResult.Success) { diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 7ebdea5e7fd..eb7d61fba50 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.InternalTesting; using System.Xml.Linq; +using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; @@ -47,12 +48,12 @@ public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.PostgreSQL", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.PostgreSQL", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0", null) }; // Act @@ -71,9 +72,9 @@ public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null) }; // Act @@ -93,9 +94,9 @@ public async Task CreateProjectFiles_GeneratesProgramCs() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null) }; // Act @@ -114,11 +115,11 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0", null) }; // Act @@ -140,9 +141,9 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() { // Arrange var project = CreateProject(); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null) }; // Act @@ -260,11 +261,11 @@ await File.WriteAllTextAsync(settingsJson, """ var projectModelPath = Path.Combine(appPath, ".aspire_server"); var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, configurationService, logger, projectModelPath); - var packages = new List<(string Name, string Version)> + var packages = new List { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0") + new IntegrationReference("Aspire.Hosting", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.AppHost", "13.1.0", null), + new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null) }; // Act diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 78f7b45af99..aef1bc2fa07 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -532,6 +532,9 @@ public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool n public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); + + public Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + => Task.FromResult(0); } private sealed class TestCertificateService : ICertificateService diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs index 1a5451fda59..fcaa77b92ef 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Configuration; using Aspire.Cli.Projects; using Aspire.Cli.Utils; @@ -14,7 +15,7 @@ internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFac public Task CreateAsync( string appHostPath, string sdkVersion, - IEnumerable<(string PackageId, string Version)> packages, + IEnumerable integrations, Dictionary? launchSettingsEnvVars, bool debug, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index b54eb71d8e4..28d368df67f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -121,4 +121,7 @@ public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referen public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); + + public Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + => Task.FromResult(0); } From b5ae5627b29e2ad8602c937a363e6485685399a1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 21:07:26 -0800 Subject: [PATCH 02/26] Remove GetAllPackages, migrate tests to GetIntegrationReferences Remove the now-unused GetAllPackages methods from AspireJsonConfiguration. Update tests to use GetIntegrationReferences instead. Add test for project reference detection (.csproj paths). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/AspireJsonConfiguration.cs | 44 ------------- .../Projects/GuestAppHostProjectTests.cs | 66 +++++++++++-------- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 0ea34c8e610..4fec3adfb22 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -215,48 +215,4 @@ public IEnumerable GetIntegrationReferences(string default } } - /// - /// Gets all package references including the base Aspire.Hosting package. - /// Empty package versions in settings are resolved to the effective SDK version. - /// - /// Default SDK version to use when not configured. - /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages(string defaultSdkVersion) - { - var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); - - // Base package always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) - yield return ("Aspire.Hosting", sdkVersion); - - if (Packages is null) - { - yield break; - } - - foreach (var (packageName, version) in Packages) - { - // Skip base packages and SDK-only packages - if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || - string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - yield return (packageName, string.IsNullOrWhiteSpace(version) ? sdkVersion : version); - } - } - - /// - /// Gets all package references including the base Aspire.Hosting packages. - /// Uses the SdkVersion for base packages. - /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. - /// - /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages() - { - var sdkVersion = !string.IsNullOrWhiteSpace(SdkVersion) - ? SdkVersion - : throw new InvalidOperationException("SdkVersion must be set to a non-empty value before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); - return GetAllPackages(sdkVersion); - } } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 876be8d1451..aa0b030d4cf 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -123,7 +123,7 @@ public void AspireJsonConfiguration_AddOrUpdatePackage_UpdatesExistingPackage() } [Fact] - public void AspireJsonConfiguration_GetAllPackages_IncludesBasePackages() + public void AspireJsonConfiguration_GetIntegrationReferences_IncludesBasePackages() { // Arrange var config = new AspireJsonConfiguration @@ -137,17 +137,16 @@ public void AspireJsonConfiguration_GetAllPackages_IncludesBasePackages() }; // Act - var packages = config.GetAllPackages().ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - should include base package (Aspire.Hosting) plus explicit packages - // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); - Assert.Equal(2, packages.Count); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0" && !r.IsProjectReference); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0" && !r.IsProjectReference); + Assert.Equal(2, refs.Count); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_ReturnsBasePackagesOnly() + public void AspireJsonConfiguration_GetIntegrationReferences_WithNoExplicitPackages_ReturnsBasePackagesOnly() { // Arrange var config = new AspireJsonConfiguration @@ -157,70 +156,83 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return }; // Act - var packages = config.GetAllPackages().ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - should include base package only (Aspire.Hosting) - // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded - Assert.Single(packages); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Single(refs); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithWhitespaceSdkVersion_Throws() + public void AspireJsonConfiguration_GetIntegrationReferences_WithEmptyVersion_UsesFallbackVersion() { + // Arrange var config = new AspireJsonConfiguration { - SdkVersion = " ", - Language = "typescript" + Language = "typescript", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = string.Empty + } }; - var exception = Assert.Throws(() => config.GetAllPackages().ToList()); + // Act + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); - Assert.Contains("non-empty", exception.Message); + // Assert + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithDefaultSdkVersion_UsesFallbackVersion() + public void AspireJsonConfiguration_GetIntegrationReferences_WithConfiguredSdkVersion_ReturnsConfiguredVersions() { // Arrange var config = new AspireJsonConfiguration { + SdkVersion = "13.1.0", Language = "typescript", + Channel = "daily", Packages = new Dictionary { - ["Aspire.Hosting.Redis"] = string.Empty + ["Aspire.Hosting.Redis"] = "13.1.0" } }; // Act - var packages = config.GetAllPackages("13.1.0").ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList(); // Assert - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0"); } [Fact] - public void AspireJsonConfiguration_GetAllPackages_WithConfiguredSdkVersion_ReturnsConfiguredVersions() + public void AspireJsonConfiguration_GetIntegrationReferences_WithProjectReference_ReturnsProjectRef() { // Arrange var config = new AspireJsonConfiguration { SdkVersion = "13.1.0", Language = "typescript", - Channel = "daily", Packages = new Dictionary { - ["Aspire.Hosting.Redis"] = "13.1.0" + ["Aspire.Hosting.Redis"] = "13.1.0", + ["Aspire.Hosting.MyCustom"] = "../src/Aspire.Hosting.MyCustom/Aspire.Hosting.MyCustom.csproj" } }; // Act - var packages = config.GetAllPackages("13.1.0").ToList(); + var refs = config.GetIntegrationReferences("13.1.0", "/home/user/app").ToList(); // Assert - Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.IsPackageReference); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.IsPackageReference); + var projectRef = Assert.Single(refs, r => r.IsProjectReference); + Assert.Equal("Aspire.Hosting.MyCustom", projectRef.Name); + Assert.Null(projectRef.Version); + Assert.NotNull(projectRef.ProjectPath); + Assert.EndsWith(".csproj", projectRef.ProjectPath); } [Fact] From 769da4419795b70ad163ed5234b031865460d313 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 21:17:19 -0800 Subject: [PATCH 03/26] Use single dotnet publish for PrebuiltAppHostServer integration resolution Replace the two-step approach (BundleNuGetService restore + per-project publish) with a single synthetic .csproj that includes all PackageReferences and ProjectReferences, then dotnet publish once to get the full transitive DLL closure. Remove BundleNuGetService dependency from PrebuiltAppHostServer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/AppHostServerProject.cs | 3 - .../Projects/PrebuiltAppHostServer.cs | 184 ++++++++---------- 2 files changed, 78 insertions(+), 109 deletions(-) diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 23ce0936ed1..7abcde73293 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -6,7 +6,6 @@ using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; -using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -30,7 +29,6 @@ internal sealed class AppHostServerProjectFactory( IPackagingService packagingService, IConfigurationService configurationService, IBundleService bundleService, - BundleNuGetService bundleNuGetService, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -82,7 +80,6 @@ public async Task CreateAsync(string appPath, Cancellatio appPath, socketPath, layout, - bundleNuGetService, dotNetCliRunner, packagingService, configurationService, diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index f0a17097b68..e80b631da3a 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Layout; -using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -26,7 +25,6 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly string _appPath; private readonly string _socketPath; private readonly LayoutConfiguration _layout; - private readonly BundleNuGetService _nugetService; private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IPackagingService _packagingService; private readonly IConfigurationService _configurationService; @@ -43,7 +41,6 @@ public PrebuiltAppHostServer( string appPath, string socketPath, LayoutConfiguration layout, - BundleNuGetService nugetService, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, @@ -52,7 +49,6 @@ public PrebuiltAppHostServer( _appPath = Path.GetFullPath(appPath); _socketPath = socketPath; _layout = layout; - _nugetService = nugetService; _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; _configurationService = configurationService; @@ -89,7 +85,7 @@ public async Task PrepareAsync( CancellationToken cancellationToken = default) { var integrationList = integrations.ToList(); - var packageRefs = integrationList.Where(r => r.IsPackageReference).Select(r => (r.Name, r.Version!)).ToList(); + var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList(); try @@ -100,70 +96,65 @@ public async Task PrepareAsync( // Resolve the configured channel (local settings.json → global config fallback) var channelName = await ResolveChannelNameAsync(cancellationToken); - // Restore NuGet integration packages - if (packageRefs.Count > 0) + if (packageRefs.Count > 0 || projectRefs.Count > 0) { - _logger.LogDebug("Restoring {Count} integration packages", packageRefs.Count); - - // Get NuGet sources filtered to the resolved channel - var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); - - // Pass apphost directory for nuget.config discovery - var appHostDirectory = Path.GetDirectoryName(_appPath); - - _integrationLibsPath = await _nugetService.RestorePackagesAsync( - packageRefs, - "net10.0", - sources: sources, - workingDirectory: appHostDirectory, - ct: cancellationToken); - } - - // Build and publish project references - if (projectRefs.Count > 0) - { - _logger.LogDebug("Building {Count} project references", projectRefs.Count); - - // Ensure we have a libs directory to copy into - if (_integrationLibsPath is null) + // Create a single synthetic project file with all package and project references, + // then publish it once to get the full transitive closure of DLLs. + var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); + Directory.CreateDirectory(restoreDir); + + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs); + var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); + await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + + // Copy nuget.config from the user's apphost directory if present + var appHostDirectory = Path.GetDirectoryName(_appPath)!; + var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); + if (File.Exists(userNugetConfig)) { - _integrationLibsPath = Path.Combine(Path.GetTempPath(), ".aspire", "project-refs", Guid.NewGuid().ToString("N")[..12]); - Directory.CreateDirectory(_integrationLibsPath); + File.Copy(userNugetConfig, Path.Combine(restoreDir, "nuget.config"), overwrite: true); } - foreach (var projectRef in projectRefs) + // Merge channel-specific NuGet sources if a channel is configured + if (channelName is not null) { - var projectFile = new FileInfo(projectRef.ProjectPath!); - if (!projectFile.Exists) + try { - _logger.LogError("Project reference not found: {Path}", projectRef.ProjectPath); - throw new FileNotFoundException($"Project reference not found: {projectRef.ProjectPath}"); + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var channel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (channel is not null) + { + await NuGetConfigMerger.CreateOrUpdateAsync( + new DirectoryInfo(restoreDir), + channel, + cancellationToken: cancellationToken); + } } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge channel NuGet sources, relying on user nuget.config"); + } + } - // Publish to a temp directory to get the full transitive closure - var publishDir = new DirectoryInfo(Path.Combine(Path.GetTempPath(), ".aspire", "publish", Guid.NewGuid().ToString("N")[..12])); - Directory.CreateDirectory(publishDir.FullName); - - _logger.LogDebug("Publishing project reference {Name} from {Path} to {OutputDir}", projectRef.Name, projectRef.ProjectPath, publishDir.FullName); + // Publish to the integration libs path + var publishDir = new DirectoryInfo(Path.Combine(restoreDir, "publish")); + Directory.CreateDirectory(publishDir.FullName); - var exitCode = await _dotNetCliRunner.PublishProjectAsync( - projectFile, - publishDir, - new DotNetCliRunnerInvocationOptions(), - cancellationToken); + _logger.LogDebug("Publishing integration project with {PackageCount} packages and {ProjectCount} project references to {OutputDir}", + packageRefs.Count, projectRefs.Count, publishDir.FullName); - if (exitCode != 0) - { - throw new InvalidOperationException($"Failed to publish project reference '{projectRef.Name}' at {projectRef.ProjectPath}. Exit code: {exitCode}"); - } + var exitCode = await _dotNetCliRunner.PublishProjectAsync( + new FileInfo(projectFilePath), + publishDir, + new DotNetCliRunnerInvocationOptions(), + cancellationToken); - // Copy all DLLs from publish output into the integration libs path - foreach (var dll in Directory.GetFiles(publishDir.FullName, "*.dll")) - { - var destPath = Path.Combine(_integrationLibsPath, Path.GetFileName(dll)); - File.Copy(dll, destPath, overwrite: true); - } + if (exitCode != 0) + { + throw new InvalidOperationException($"Failed to publish integration project. Exit code: {exitCode}"); } + + _integrationLibsPath = publishDir.FullName; } return new AppHostServerPrepareResult( @@ -185,6 +176,36 @@ public async Task PrepareAsync( } } + /// + /// Generates a synthetic .csproj file that references all integration packages and projects. + /// Publishing this project produces the full transitive DLL closure. + /// + private static string GenerateIntegrationProjectFile( + List packageRefs, + List projectRefs) + { + var packageElements = string.Join("\n ", + packageRefs.Select(p => $"""""")); + + var projectElements = string.Join("\n ", + projectRefs.Select(p => $"""""")); + + return $$""" + + + net10.0 + false + + + {{packageElements}} + + + {{projectElements}} + + + """; + } + /// /// Resolves the configured channel name from local settings.json or global config. /// @@ -208,55 +229,6 @@ public async Task PrepareAsync( return channelName; } - /// - /// Gets NuGet sources from the resolved channel, or all explicit channels if no channel is configured. - /// - private async Task?> GetNuGetSourcesAsync(string? channelName, CancellationToken cancellationToken) - { - var sources = new List(); - - try - { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - - IEnumerable explicitChannels; - if (!string.IsNullOrEmpty(channelName)) - { - // Filter to the configured channel - var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); - } - else - { - // No channel configured, use all explicit channels - explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); - } - - foreach (var channel in explicitChannels) - { - if (channel.Mappings is null) - { - continue; - } - - foreach (var mapping in channel.Mappings) - { - if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) - { - sources.Add(mapping.Source); - _logger.LogDebug("Using channel '{Channel}' NuGet source: {Source}", channel.Name, mapping.Source); - } - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); - } - - return sources.Count > 0 ? sources : null; - } - /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, From b3d5a3534c133e00c390c1cc08bae07c42b915f7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 21:20:45 -0800 Subject: [PATCH 04/26] Only use dotnet publish when project references are present Restore BundleNuGetService for the NuGet-only path (no SDK required). Only use dotnet publish with a synthetic project when project references are present in settings.json (requires .NET SDK). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/AppHostServerProject.cs | 3 + .../Projects/PrebuiltAppHostServer.cs | 207 +++++++++++++----- 2 files changed, 152 insertions(+), 58 deletions(-) diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 7abcde73293..23ce0936ed1 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -29,6 +30,7 @@ internal sealed class AppHostServerProjectFactory( IPackagingService packagingService, IConfigurationService configurationService, IBundleService bundleService, + BundleNuGetService bundleNuGetService, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -80,6 +82,7 @@ public async Task CreateAsync(string appPath, Cancellatio appPath, socketPath, layout, + bundleNuGetService, dotNetCliRunner, packagingService, configurationService, diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index e80b631da3a..b48b8382efb 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Layout; +using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -25,6 +26,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly string _appPath; private readonly string _socketPath; private readonly LayoutConfiguration _layout; + private readonly BundleNuGetService _nugetService; private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IPackagingService _packagingService; private readonly IConfigurationService _configurationService; @@ -41,6 +43,7 @@ public PrebuiltAppHostServer( string appPath, string socketPath, LayoutConfiguration layout, + BundleNuGetService nugetService, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, @@ -49,6 +52,7 @@ public PrebuiltAppHostServer( _appPath = Path.GetFullPath(appPath); _socketPath = socketPath; _layout = layout; + _nugetService = nugetService; _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; _configurationService = configurationService; @@ -96,65 +100,18 @@ public async Task PrepareAsync( // Resolve the configured channel (local settings.json → global config fallback) var channelName = await ResolveChannelNameAsync(cancellationToken); - if (packageRefs.Count > 0 || projectRefs.Count > 0) + if (projectRefs.Count > 0) { - // Create a single synthetic project file with all package and project references, - // then publish it once to get the full transitive closure of DLLs. - var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); - Directory.CreateDirectory(restoreDir); - - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs); - var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); - await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); - - // Copy nuget.config from the user's apphost directory if present - var appHostDirectory = Path.GetDirectoryName(_appPath)!; - var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); - if (File.Exists(userNugetConfig)) - { - File.Copy(userNugetConfig, Path.Combine(restoreDir, "nuget.config"), overwrite: true); - } - - // Merge channel-specific NuGet sources if a channel is configured - if (channelName is not null) - { - try - { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - var channel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - if (channel is not null) - { - await NuGetConfigMerger.CreateOrUpdateAsync( - new DirectoryInfo(restoreDir), - channel, - cancellationToken: cancellationToken); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to merge channel NuGet sources, relying on user nuget.config"); - } - } - - // Publish to the integration libs path - var publishDir = new DirectoryInfo(Path.Combine(restoreDir, "publish")); - Directory.CreateDirectory(publishDir.FullName); - - _logger.LogDebug("Publishing integration project with {PackageCount} packages and {ProjectCount} project references to {OutputDir}", - packageRefs.Count, projectRefs.Count, publishDir.FullName); - - var exitCode = await _dotNetCliRunner.PublishProjectAsync( - new FileInfo(projectFilePath), - publishDir, - new DotNetCliRunnerInvocationOptions(), - cancellationToken); - - if (exitCode != 0) - { - throw new InvalidOperationException($"Failed to publish integration project. Exit code: {exitCode}"); - } - - _integrationLibsPath = publishDir.FullName; + // Project references require .NET SDK — create a single synthetic project + // with all package and project references, then dotnet publish once. + _integrationLibsPath = await PublishIntegrationProjectAsync( + packageRefs, projectRefs, channelName, cancellationToken); + } + else if (packageRefs.Count > 0) + { + // NuGet-only — use the bundled NuGet service (no SDK required) + _integrationLibsPath = await RestoreNuGetPackagesAsync( + packageRefs, channelName, cancellationToken); } return new AppHostServerPrepareResult( @@ -176,6 +133,94 @@ await NuGetConfigMerger.CreateOrUpdateAsync( } } + /// + /// Restores NuGet packages using the bundled NuGet service (no .NET SDK required). + /// + private async Task RestoreNuGetPackagesAsync( + List packageRefs, + string? channelName, + CancellationToken cancellationToken) + { + _logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count); + + var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList(); + var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); + var appHostDirectory = Path.GetDirectoryName(_appPath); + + return await _nugetService.RestorePackagesAsync( + packages, + "net10.0", + sources: sources, + workingDirectory: appHostDirectory, + ct: cancellationToken); + } + + /// + /// Creates a synthetic .csproj with all package and project references, + /// then publishes it to get the full transitive DLL closure. Requires .NET SDK. + /// + private async Task PublishIntegrationProjectAsync( + List packageRefs, + List projectRefs, + string? channelName, + CancellationToken cancellationToken) + { + var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); + Directory.CreateDirectory(restoreDir); + + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs); + var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); + await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + + // Copy nuget.config from the user's apphost directory if present + var appHostDirectory = Path.GetDirectoryName(_appPath)!; + var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); + if (File.Exists(userNugetConfig)) + { + File.Copy(userNugetConfig, Path.Combine(restoreDir, "nuget.config"), overwrite: true); + } + + // Merge channel-specific NuGet sources if a channel is configured + if (channelName is not null) + { + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var channel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (channel is not null) + { + await NuGetConfigMerger.CreateOrUpdateAsync( + new DirectoryInfo(restoreDir), + channel, + cancellationToken: cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge channel NuGet sources, relying on user nuget.config"); + } + } + + var publishDir = new DirectoryInfo(Path.Combine(restoreDir, "publish")); + Directory.CreateDirectory(publishDir.FullName); + + _logger.LogDebug("Publishing integration project with {PackageCount} packages and {ProjectCount} project references to {OutputDir}", + packageRefs.Count, projectRefs.Count, publishDir.FullName); + + var exitCode = await _dotNetCliRunner.PublishProjectAsync( + new FileInfo(projectFilePath), + publishDir, + new DotNetCliRunnerInvocationOptions(), + cancellationToken); + + if (exitCode != 0) + { + throw new InvalidOperationException($"Failed to publish integration project. Exit code: {exitCode}"); + } + + return publishDir.FullName; + } + /// /// Generates a synthetic .csproj file that references all integration packages and projects. /// Publishing this project produces the full transitive DLL closure. @@ -229,6 +274,52 @@ private static string GenerateIntegrationProjectFile( return channelName; } + /// + /// Gets NuGet sources from the resolved channel for bundled restore. + /// + private async Task?> GetNuGetSourcesAsync(string? channelName, CancellationToken cancellationToken) + { + var sources = new List(); + + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + + IEnumerable explicitChannels; + if (!string.IsNullOrEmpty(channelName)) + { + var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); + } + else + { + explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); + } + + foreach (var channel in explicitChannels) + { + if (channel.Mappings is null) + { + continue; + } + + foreach (var mapping in channel.Mappings) + { + if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) + { + sources.Add(mapping.Source); + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); + } + + return sources.Count > 0 ? sources : null; + } + /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, From ea28690edc462d40e952359a985de64950a4c07f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 21:31:36 -0800 Subject: [PATCH 05/26] Use dotnet build with CopyLocalLockFileAssemblies instead of publish Build the synthetic integration project with OutDir pointing directly to the libs path, eliminating the need for dotnet publish and the extra copy step. Remove PublishProjectAsync from IDotNetCliRunner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 22 ------------ .../Projects/PrebuiltAppHostServer.cs | 35 +++++++++++-------- .../Templating/DotNetTemplateFactoryTests.cs | 3 -- .../TestServices/TestDotNetCliRunner.cs | 3 -- 4 files changed, 21 insertions(+), 42 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 766b61b5fc7..9514676f717 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -39,7 +39,6 @@ internal interface IDotNetCliRunner Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); } internal sealed class DotNetCliRunnerInvocationOptions @@ -714,27 +713,6 @@ public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotN options: options, cancellationToken: cancellationToken); } - - public async Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - { - using var activity = telemetry.StartDiagnosticActivity(); - - string[] cliArgs = ["publish", projectFilePath.FullName, "-o", outputDirectory.FullName]; - - var env = new Dictionary - { - ["DOTNET_CLI_USE_MSBUILD_SERVER"] = GetMsBuildServerValue() - }; - - return await ExecuteAsync( - args: cliArgs, - env: env, - projectFile: projectFilePath, - workingDirectory: projectFilePath.Directory!, - backchannelCompletionSource: null, - options: options, - cancellationToken: cancellationToken); - } public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index b48b8382efb..57986921bc7 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -104,7 +104,7 @@ public async Task PrepareAsync( { // Project references require .NET SDK — create a single synthetic project // with all package and project references, then dotnet publish once. - _integrationLibsPath = await PublishIntegrationProjectAsync( + _integrationLibsPath = await BuildIntegrationProjectAsync( packageRefs, projectRefs, channelName, cancellationToken); } else if (packageRefs.Count > 0) @@ -157,9 +157,10 @@ private async Task RestoreNuGetPackagesAsync( /// /// Creates a synthetic .csproj with all package and project references, - /// then publishes it to get the full transitive DLL closure. Requires .NET SDK. + /// then builds it to get the full transitive DLL closure via CopyLocalLockFileAssemblies. + /// Requires .NET SDK. /// - private async Task PublishIntegrationProjectAsync( + private async Task BuildIntegrationProjectAsync( List packageRefs, List projectRefs, string? channelName, @@ -168,7 +169,10 @@ private async Task PublishIntegrationProjectAsync( var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); Directory.CreateDirectory(restoreDir); - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs); + var outputDir = Path.Combine(restoreDir, "libs"); + Directory.CreateDirectory(outputDir); + + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir); var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -201,24 +205,21 @@ await NuGetConfigMerger.CreateOrUpdateAsync( } } - var publishDir = new DirectoryInfo(Path.Combine(restoreDir, "publish")); - Directory.CreateDirectory(publishDir.FullName); - - _logger.LogDebug("Publishing integration project with {PackageCount} packages and {ProjectCount} project references to {OutputDir}", - packageRefs.Count, projectRefs.Count, publishDir.FullName); + _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references", + packageRefs.Count, projectRefs.Count); - var exitCode = await _dotNetCliRunner.PublishProjectAsync( + var exitCode = await _dotNetCliRunner.BuildAsync( new FileInfo(projectFilePath), - publishDir, + noRestore: false, new DotNetCliRunnerInvocationOptions(), cancellationToken); if (exitCode != 0) { - throw new InvalidOperationException($"Failed to publish integration project. Exit code: {exitCode}"); + throw new InvalidOperationException($"Failed to build integration project. Exit code: {exitCode}"); } - return publishDir.FullName; + return outputDir; } /// @@ -227,7 +228,8 @@ await NuGetConfigMerger.CreateOrUpdateAsync( /// private static string GenerateIntegrationProjectFile( List packageRefs, - List projectRefs) + List projectRefs, + string outputDir) { var packageElements = string.Join("\n ", packageRefs.Select(p => $"""""")); @@ -240,6 +242,11 @@ private static string GenerateIntegrationProjectFile( net10.0 false + true + false + false + false + {{outputDir}} {{packageElements}} diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index aef1bc2fa07..78f7b45af99 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -532,9 +532,6 @@ public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool n public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); - - public Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - => Task.FromResult(0); } private sealed class TestCertificateService : ICertificateService diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 28d368df67f..b54eb71d8e4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -121,7 +121,4 @@ public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referen public Task InitUserSecretsAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => Task.FromResult(0); - - public Task PublishProjectAsync(FileInfo projectFilePath, DirectoryInfo outputDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - => Task.FromResult(0); } From 7da892da3aacdd0a117a6997fbe782501d225e75 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 21:35:30 -0800 Subject: [PATCH 06/26] Add SDK check for project refs and isolate from parent MSBuild imports Check SDK availability before building project references and provide a clear error message if missing. Write Directory.Packages.props (CPM opt-out), Directory.Build.props, and Directory.Build.targets to the synthetic project directory to prevent parent MSBuild imports from interfering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/AppHostServerProject.cs | 2 ++ .../Projects/PrebuiltAppHostServer.cs | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 23ce0936ed1..7e4a40d38bd 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -31,6 +31,7 @@ internal sealed class AppHostServerProjectFactory( IConfigurationService configurationService, IBundleService bundleService, BundleNuGetService bundleNuGetService, + IDotNetSdkInstaller sdkInstaller, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -84,6 +85,7 @@ public async Task CreateAsync(string appPath, Cancellatio layout, bundleNuGetService, dotNetCliRunner, + sdkInstaller, packagingService, configurationService, loggerFactory.CreateLogger()); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 57986921bc7..639cb1c61f5 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -28,6 +28,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly LayoutConfiguration _layout; private readonly BundleNuGetService _nugetService; private readonly IDotNetCliRunner _dotNetCliRunner; + private readonly IDotNetSdkInstaller _sdkInstaller; private readonly IPackagingService _packagingService; private readonly IConfigurationService _configurationService; private readonly ILogger _logger; @@ -45,6 +46,7 @@ public PrebuiltAppHostServer( LayoutConfiguration layout, BundleNuGetService nugetService, IDotNetCliRunner dotNetCliRunner, + IDotNetSdkInstaller sdkInstaller, IPackagingService packagingService, IConfigurationService configurationService, ILogger logger) @@ -54,6 +56,7 @@ public PrebuiltAppHostServer( _layout = layout; _nugetService = nugetService; _dotNetCliRunner = dotNetCliRunner; + _sdkInstaller = sdkInstaller; _packagingService = packagingService; _configurationService = configurationService; _logger = logger; @@ -102,8 +105,16 @@ public async Task PrepareAsync( if (projectRefs.Count > 0) { - // Project references require .NET SDK — create a single synthetic project - // with all package and project references, then dotnet publish once. + // Project references require .NET SDK — verify it's available + var (sdkAvailable, _, minimumRequired, _) = await _sdkInstaller.CheckAsync(cancellationToken); + if (!sdkAvailable) + { + throw new InvalidOperationException( + $"Project references in settings.json require .NET SDK {minimumRequired} or later. " + + "Install the .NET SDK from https://dotnet.microsoft.com/download or use NuGet package versions instead."); + } + + // Build a synthetic project with all package and project references _integrationLibsPath = await BuildIntegrationProjectAsync( packageRefs, projectRefs, channelName, cancellationToken); } @@ -176,6 +187,24 @@ private async Task BuildIntegrationProjectAsync( var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + // Write a Directory.Packages.props to opt out of Central Package Management + // This prevents any parent Directory.Packages.props from interfering with our inline versions + var directoryPackagesProps = """ + + + false + + + """; + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken); + + // Also write an empty Directory.Build.props/targets to prevent parent imports + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.props"), "", cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.targets"), "", cancellationToken); + // Copy nuget.config from the user's apphost directory if present var appHostDirectory = Path.GetDirectoryName(_appPath)!; var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); From 32c0df9ae760c436f6101678ff0d3617bc5829e6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 22:00:36 -0800 Subject: [PATCH 07/26] Remove DotNetBasedAppHostServerProject casts from SDK commands SdkGenerateCommand and SdkDumpCommand now use IAppHostServerProject.PrepareAsync instead of casting to DotNetBasedAppHostServerProject and calling CreateProjectFilesAsync/BuildAsync directly. This means they work in both dev mode and bundle mode with project references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 27 +++++++------------ .../Commands/Sdk/SdkGenerateCommand.cs | 27 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 31e624370c4..9d36bc5e76d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -122,20 +122,11 @@ private async Task DumpCapabilitiesAsync( try { - // TODO: Support bundle mode by using DLL references instead of project references. - // In bundle mode, we'd need to add integration DLLs to the probing path rather than - // using additionalProjectReferences. For now, SDK dump only works with .NET SDK. - var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); - if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) - { - InteractionService.DisplayError("SDK dump is only available with .NET SDK installed."); - return ExitCodeConstants.FailedToBuildArtifacts; - } + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); - // Build integrations list - empty since we only need core capabilities + optional integration + // Build integrations list - optional integration project reference var integrations = new List(); - // Add integration project reference if specified if (integrationProject is not null) { integrations.Add(new IntegrationReference( @@ -146,18 +137,20 @@ private async Task DumpCapabilitiesAsync( _logger.LogDebug("Building AppHost server for capability scanning"); - await appHostServerProject.CreateProjectFilesAsync( + var prepareResult = await appHostServerProject.PrepareAsync( + DotNetBasedAppHostServerProject.DefaultSdkVersion, integrations, cancellationToken); - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - - if (!buildSuccess) + if (!prepareResult.Success) { InteractionService.DisplayError("Failed to build capability scanner."); - foreach (var (_, line) in buildOutput.GetLines()) + if (prepareResult.Output is not null) { - InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + foreach (var (_, line) in prepareResult.Output.GetLines()) + { + InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + } } return ExitCodeConstants.FailedToBuildArtifacts; } diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 47c22316646..4c6a9ae80fe 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -120,20 +120,12 @@ private async Task GenerateSdkAsync( try { - // TODO: Support bundle mode by using DLL references instead of project references. - // In bundle mode, we'd need to add integration DLLs to the probing path rather than - // using additionalProjectReferences. For now, SDK generation only works with .NET SDK. - var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); - if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) - { - InteractionService.DisplayError("SDK generation is only available with .NET SDK installed."); - return ExitCodeConstants.FailedToBuildArtifacts; - } + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); // Get code generation package for the target language var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken); - // Build packages list - include the code generator + // Build integrations list - include the code generator and the integration project var integrations = new List(); if (codeGenPackage is not null) { @@ -148,19 +140,20 @@ private async Task GenerateSdkAsync( _logger.LogDebug("Building AppHost server for SDK generation"); - // Create project files with the integration project reference - await appHostServerProject.CreateProjectFilesAsync( + var prepareResult = await appHostServerProject.PrepareAsync( + DotNetBasedAppHostServerProject.DefaultSdkVersion, integrations, cancellationToken); - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - - if (!buildSuccess) + if (!prepareResult.Success) { InteractionService.DisplayError("Failed to build SDK generation server."); - foreach (var (_, line) in buildOutput.GetLines()) + if (prepareResult.Output is not null) { - InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + foreach (var (_, line) in prepareResult.Output.GetLines()) + { + InteractionService.DisplayMessage(KnownEmojis.Wrench, line); + } } return ExitCodeConstants.FailedToBuildArtifacts; } From 66a3ab92d3258493c0e639c08d219d46a23b8042 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 22:17:18 -0800 Subject: [PATCH 08/26] Use CLI version for SDK commands, let project deps resolve transitively SDK commands no longer reference DotNetBasedAppHostServerProject.DefaultSdkVersion. The codegen package uses VersionHelper.GetDefaultTemplateVersion() (CLI version), and Aspire.Hosting comes transitively from the integration project reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 2 +- src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 9d36bc5e76d..f0ec3761900 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -138,7 +138,7 @@ private async Task DumpCapabilitiesAsync( _logger.LogDebug("Building AppHost server for capability scanning"); var prepareResult = await appHostServerProject.PrepareAsync( - DotNetBasedAppHostServerProject.DefaultSdkVersion, + VersionHelper.GetDefaultTemplateVersion(), integrations, cancellationToken); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 4c6a9ae80fe..bdaff6da038 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -125,11 +125,12 @@ private async Task GenerateSdkAsync( // Get code generation package for the target language var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken); - // Build integrations list - include the code generator and the integration project + // Build integrations list — the integration project brings Aspire.Hosting transitively; + // we only need to add the codegen package and the project reference itself. var integrations = new List(); if (codeGenPackage is not null) { - integrations.Add(new IntegrationReference(codeGenPackage, DotNetBasedAppHostServerProject.DefaultSdkVersion, ProjectPath: null)); + integrations.Add(new IntegrationReference(codeGenPackage, VersionHelper.GetDefaultTemplateVersion(), ProjectPath: null)); } // Add the integration project as a project reference @@ -141,7 +142,7 @@ private async Task GenerateSdkAsync( _logger.LogDebug("Building AppHost server for SDK generation"); var prepareResult = await appHostServerProject.PrepareAsync( - DotNetBasedAppHostServerProject.DefaultSdkVersion, + VersionHelper.GetDefaultTemplateVersion(), integrations, cancellationToken); From c7753cd82115cf70bf750cdfbe3cab044041711a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 23:08:00 -0800 Subject: [PATCH 09/26] Add unit tests for IntegrationReference and synthetic project generation Tests cover IntegrationReference type properties, AspireJsonConfiguration.GetIntegrationReferences detection of .csproj paths, and PrebuiltAppHostServer.GenerateIntegrationProjectFile output (PackageReference, ProjectReference, OutDir, CopyLocalLockFileAssemblies, analyzer suppression). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 4 +- .../IntegrationReferenceTests.cs | 120 +++++++++++++++++ .../Projects/PrebuiltAppHostServerTests.cs | 121 ++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs create mode 100644 tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 639cb1c61f5..a9b5dfc2903 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -253,9 +253,9 @@ await NuGetConfigMerger.CreateOrUpdateAsync( /// /// Generates a synthetic .csproj file that references all integration packages and projects. - /// Publishing this project produces the full transitive DLL closure. + /// Building this project with CopyLocalLockFileAssemblies produces the full transitive DLL closure. /// - private static string GenerateIntegrationProjectFile( + internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, string outputDir) diff --git a/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs new file mode 100644 index 00000000000..7c3423457b2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; + +namespace Aspire.Cli.Tests.Configuration; + +public class IntegrationReferenceTests +{ + [Fact] + public void PackageReference_HasVersionAndNoProjectPath() + { + var reference = new IntegrationReference("Aspire.Hosting.Redis", "13.2.0", null); + + Assert.True(reference.IsPackageReference); + Assert.False(reference.IsProjectReference); + Assert.Equal("13.2.0", reference.Version); + Assert.Null(reference.ProjectPath); + } + + [Fact] + public void ProjectReference_HasProjectPathAndNoVersion() + { + var reference = new IntegrationReference("MyIntegration", null, "/path/to/MyIntegration.csproj"); + + Assert.True(reference.IsProjectReference); + Assert.False(reference.IsPackageReference); + Assert.Null(reference.Version); + Assert.Equal("/path/to/MyIntegration.csproj", reference.ProjectPath); + } + + [Fact] + public void GetIntegrationReferences_DetectsCsprojAsProjectReference() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "13.2.0", + ["MyIntegration"] = "../src/MyIntegration/MyIntegration.csproj" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/home/user/app").ToList(); + + // Base Aspire.Hosting + Redis (packages) + MyIntegration (project ref) = 3 + Assert.Equal(3, refs.Count); + + var packageRefs = refs.Where(r => r.IsPackageReference).ToList(); + var projectRefs = refs.Where(r => r.IsProjectReference).ToList(); + + Assert.Equal(2, packageRefs.Count); + Assert.Single(projectRefs); + Assert.Equal("MyIntegration", projectRefs[0].Name); + Assert.EndsWith(".csproj", projectRefs[0].ProjectPath!); + } + + [Fact] + public void GetIntegrationReferences_ResolvesRelativeProjectPath() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["MyIntegration"] = "../MyIntegration/MyIntegration.csproj" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/home/user/app").ToList(); + var projectRef = refs.Single(r => r.IsProjectReference); + + // Path should be resolved to absolute + Assert.True(Path.IsPathRooted(projectRef.ProjectPath!)); + } + + [Fact] + public void GetIntegrationReferences_EmptyVersionDefaultsToSdkVersion() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/tmp").ToList(); + var redis = refs.Single(r => r.Name == "Aspire.Hosting.Redis"); + + Assert.Equal("13.2.0", redis.Version); + Assert.True(redis.IsPackageReference); + } + + [Fact] + public void GetIntegrationReferences_SkipsBasePackages() + { + var config = new AspireJsonConfiguration + { + SdkVersion = "13.2.0", + Packages = new Dictionary + { + ["Aspire.Hosting"] = "13.2.0", + ["Aspire.Hosting.AppHost"] = "13.2.0", + ["Aspire.Hosting.Redis"] = "13.2.0" + } + }; + + var refs = config.GetIntegrationReferences("13.2.0", "/tmp").ToList(); + + // Base Aspire.Hosting (auto-added) + Redis = 2 + // Aspire.Hosting from packages dict is skipped (duplicate) + // Aspire.Hosting.AppHost is skipped (SDK-only) + Assert.Equal(2, refs.Count); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting"); + Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis"); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs new file mode 100644 index 00000000000..6dc6bb1676d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Xml.Linq; +using Aspire.Cli.Configuration; +using Aspire.Cli.Projects; + +namespace Aspire.Cli.Tests.Projects; + +public class PrebuiltAppHostServerTests +{ + [Fact] + public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageReferences() + { + var packageRefs = new List + { + new("Aspire.Hosting", "13.2.0", null), + new("Aspire.Hosting.Redis", "13.2.0", null) + }; + var projectRefs = new List(); + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var packageElements = doc.Descendants("PackageReference").ToList(); + Assert.Equal(2, packageElements.Count); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting" && e.Attribute("Version")?.Value == "13.2.0"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "13.2.0"); + + Assert.Empty(doc.Descendants("ProjectReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_WithProjectRefsOnly_ProducesProjectReferences() + { + var packageRefs = new List(); + var projectRefs = new List + { + new("MyIntegration", null, "/path/to/MyIntegration.csproj") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var projectElements = doc.Descendants("ProjectReference").ToList(); + Assert.Single(projectElements); + Assert.Equal("/path/to/MyIntegration.csproj", projectElements[0].Attribute("Include")?.Value); + + Assert.Empty(doc.Descendants("PackageReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_WithMixed_ProducesBothReferenceTypes() + { + var packageRefs = new List + { + new("Aspire.Hosting", "13.2.0", null), + new("Aspire.Hosting.Redis", "13.2.0", null) + }; + var projectRefs = new List + { + new("MyIntegration", null, "/path/to/MyIntegration.csproj") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); + var doc = XDocument.Parse(xml); + + Assert.Equal(2, doc.Descendants("PackageReference").Count()); + Assert.Single(doc.Descendants("ProjectReference")); + } + + [Fact] + public void GenerateIntegrationProjectFile_SetsOutDir() + { + var packageRefs = new List + { + new("Aspire.Hosting", "13.2.0", null) + }; + var projectRefs = new List(); + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/custom/output/path"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var outDir = doc.Descendants(ns + "OutDir").FirstOrDefault()?.Value; + Assert.Equal("/custom/output/path", outDir); + } + + [Fact] + public void GenerateIntegrationProjectFile_HasCopyLocalLockFileAssemblies() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var copyLocal = doc.Descendants(ns + "CopyLocalLockFileAssemblies").FirstOrDefault()?.Value; + Assert.Equal("true", copyLocal); + } + + [Fact] + public void GenerateIntegrationProjectFile_DisablesAnalyzersAndDocGen() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + Assert.Equal("false", doc.Descendants(ns + "EnableNETAnalyzers").FirstOrDefault()?.Value); + Assert.Equal("false", doc.Descendants(ns + "GenerateDocumentationFile").FirstOrDefault()?.Value); + Assert.Equal("false", doc.Descendants(ns + "ProduceReferenceAssembly").FirstOrDefault()?.Value); + } + + [Fact] + public void GenerateIntegrationProjectFile_TargetsNet10() + { + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile([], [], "/tmp/libs"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + Assert.Equal("net10.0", doc.Descendants(ns + "TargetFramework").FirstOrDefault()?.Value); + } +} From 25aa82aa56898879fc297568eed961874a5c9232 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 23:12:14 -0800 Subject: [PATCH 10/26] Add E2E test for project reference integration support Tests the full flow: create a .NET hosting integration with [AspireExport], create a TypeScript AppHost, add the integration as a project reference in settings.json, start the AppHost, and verify the custom addMyService method appears in the generated TypeScript SDK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectReferenceTests.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs new file mode 100644 index 00000000000..6bce1009e03 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end test for polyglot project reference support. +/// Creates a .NET hosting integration project and a TypeScript AppHost that references it +/// via settings.json, then verifies the integration is discovered and code-generated. +/// +public sealed class ProjectReferenceTests(ITestOutputHelper output) +{ + [Fact] + public async Task TypeScriptAppHostWithProjectReferenceIntegration() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var waitingForAppHostCreated = new CellPatternSearcher() + .Find("Created apphost.ts"); + + var waitForStartSuccess = new CellPatternSearcher() + .Find("AppHost started successfully."); + + // Pattern to verify our custom integration was code-generated + var waitForAddMyServiceInCodegen = new CellPatternSearcher() + .Find("addMyService"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireBundleEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Capture the CLI version for use in the integration project + sequenceBuilder + .Type("ASPIRE_VER=$(aspire --version)") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("echo \"Aspire version: $ASPIRE_VER\"") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Create the .NET hosting integration project + sequenceBuilder + .Type("mkdir -p MyIntegration") + .Enter() + .WaitForSuccessPrompt(counter); + + // Write the .csproj — uses the captured CLI version for the Aspire.Hosting package reference + sequenceBuilder + .Type("""cat > MyIntegration/MyIntegration.csproj << CSPROJ""") + .Enter() + .Type("""""") + .Enter() + .Type(" ") + .Enter() + .Type(" net10.0") + .Enter() + .Type(""" \$(NoWarn);ASPIREATS001""") + .Enter() + .Type(" ") + .Enter() + .Type(" ") + .Enter() + .Type(""" """) + .Enter() + .Type(" ") + .Enter() + .Type("") + .Enter() + .Type("CSPROJ") + .Enter() + .WaitForSuccessPrompt(counter); + + // Write the integration source code with [AspireExport] + sequenceBuilder + .Type("""cat > MyIntegration/MyIntegrationExtensions.cs << 'CS'""") + .Enter() + .Type("using Aspire.Hosting;") + .Enter() + .Type("using Aspire.Hosting.ApplicationModel;") + .Enter() + .Type("namespace Aspire.Hosting;") + .Enter() + .Type("public static class MyIntegrationExtensions") + .Enter() + .Type("{") + .Enter() + .Type(""" [AspireExport("addMyService")]""") + .Enter() + .Type(" public static IResourceBuilder AddMyService(") + .Enter() + .Type(" this IDistributedApplicationBuilder builder, string name)") + .Enter() + .Type(""" => builder.AddContainer(name, "myservice", "latest");""") + .Enter() + .Type("}") + .Enter() + .Type("CS") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 3: Create a TypeScript AppHost using aspire init + sequenceBuilder + .Type("aspire init --language typescript --non-interactive") + .Enter() + .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter); + + // Step 4: Update settings.json to add the project reference + // Use jq to add the packages section with the project reference + sequenceBuilder + .Type("""jq '. + {"packages": {"MyIntegration": "../MyIntegration/MyIntegration.csproj"}}' .aspire/settings.json > .aspire/settings.tmp && mv .aspire/settings.tmp .aspire/settings.json""") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("cat .aspire/settings.json") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Start the AppHost and verify it works + sequenceBuilder + .Type("aspire start --non-interactive") + .Enter() + .WaitUntil(s => waitForStartSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Step 6: Verify the custom integration was code-generated + sequenceBuilder + .Type("grep addMyService .modules/aspire.ts") + .Enter() + .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 7: Clean up + sequenceBuilder + .Type("aspire stop --all 2>/dev/null || true") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} From 5a49b29107ca7a008f2426e674d90fe29d7e2cdd Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 23:20:50 -0800 Subject: [PATCH 11/26] Simplify E2E test: use ExecuteCallback for file creation Replace multiline cat heredocs with C# ExecuteCallback to write the integration project files and update settings.json. Reads sdkVersion from the settings.json that aspire init creates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectReferenceTests.cs | 146 ++++++++---------- 1 file changed, 63 insertions(+), 83 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 6bce1009e03..11b8d1306ed 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; @@ -49,107 +50,86 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); } - // Step 1: Capture the CLI version for use in the integration project - sequenceBuilder - .Type("ASPIRE_VER=$(aspire --version)") - .Enter() - .WaitForSuccessPrompt(counter) - .Type("echo \"Aspire version: $ASPIRE_VER\"") - .Enter() - .WaitForSuccessPrompt(counter); - - // Step 2: Create the .NET hosting integration project - sequenceBuilder - .Type("mkdir -p MyIntegration") - .Enter() - .WaitForSuccessPrompt(counter); - - // Write the .csproj — uses the captured CLI version for the Aspire.Hosting package reference - sequenceBuilder - .Type("""cat > MyIntegration/MyIntegration.csproj << CSPROJ""") - .Enter() - .Type("""""") - .Enter() - .Type(" ") - .Enter() - .Type(" net10.0") - .Enter() - .Type(""" \$(NoWarn);ASPIREATS001""") - .Enter() - .Type(" ") - .Enter() - .Type(" ") - .Enter() - .Type(""" """) - .Enter() - .Type(" ") - .Enter() - .Type("") - .Enter() - .Type("CSPROJ") - .Enter() - .WaitForSuccessPrompt(counter); - - // Write the integration source code with [AspireExport] - sequenceBuilder - .Type("""cat > MyIntegration/MyIntegrationExtensions.cs << 'CS'""") - .Enter() - .Type("using Aspire.Hosting;") - .Enter() - .Type("using Aspire.Hosting.ApplicationModel;") - .Enter() - .Type("namespace Aspire.Hosting;") - .Enter() - .Type("public static class MyIntegrationExtensions") - .Enter() - .Type("{") - .Enter() - .Type(""" [AspireExport("addMyService")]""") - .Enter() - .Type(" public static IResourceBuilder AddMyService(") - .Enter() - .Type(" this IDistributedApplicationBuilder builder, string name)") - .Enter() - .Type(""" => builder.AddContainer(name, "myservice", "latest");""") - .Enter() - .Type("}") - .Enter() - .Type("CS") - .Enter() - .WaitForSuccessPrompt(counter); - - // Step 3: Create a TypeScript AppHost using aspire init + // Step 1: Create a TypeScript AppHost first (so we get the sdkVersion in settings.json) sequenceBuilder .Type("aspire init --language typescript --non-interactive") .Enter() .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .WaitForSuccessPrompt(counter); - // Step 4: Update settings.json to add the project reference - // Use jq to add the packages section with the project reference - sequenceBuilder - .Type("""jq '. + {"packages": {"MyIntegration": "../MyIntegration/MyIntegration.csproj"}}' .aspire/settings.json > .aspire/settings.tmp && mv .aspire/settings.tmp .aspire/settings.json""") - .Enter() - .WaitForSuccessPrompt(counter) - .Type("cat .aspire/settings.json") - .Enter() - .WaitForSuccessPrompt(counter); - - // Step 5: Start the AppHost and verify it works + // Step 2: Create the integration project and update settings.json from C# + sequenceBuilder.ExecuteCallback(() => + { + var workDir = workspace.WorkspaceRoot.FullName; + + // Read the sdkVersion from the settings.json that aspire init created + var settingsPath = Path.Combine(workDir, ".aspire", "settings.json"); + var settingsJson = File.ReadAllText(settingsPath); + using var doc = JsonDocument.Parse(settingsJson); + var sdkVersion = doc.RootElement.GetProperty("sdkVersion").GetString()!; + + // Create the .NET hosting integration project + var integrationDir = Path.Combine(workDir, "MyIntegration"); + Directory.CreateDirectory(integrationDir); + + File.WriteAllText(Path.Combine(integrationDir, "MyIntegration.csproj"), $$""" + + + net10.0 + $(NoWarn);ASPIREATS001 + + + + + + """); + + File.WriteAllText(Path.Combine(integrationDir, "MyIntegrationExtensions.cs"), """ + using Aspire.Hosting; + using Aspire.Hosting.ApplicationModel; + + namespace Aspire.Hosting; + + public static class MyIntegrationExtensions + { + [AspireExport("addMyService")] + public static IResourceBuilder AddMyService( + this IDistributedApplicationBuilder builder, string name) + => builder.AddContainer(name, "myservice", "latest"); + } + """); + + // Update settings.json to add the project reference + using var settingsDoc = JsonDocument.Parse(settingsJson); + var settings = new Dictionary(); + foreach (var prop in settingsDoc.RootElement.EnumerateObject()) + { + settings[prop.Name] = prop.Value.Clone(); + } + settings["packages"] = new Dictionary + { + ["MyIntegration"] = "../MyIntegration/MyIntegration.csproj" + }; + + var updatedJson = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(settingsPath, updatedJson); + }); + + // Step 3: Start the AppHost (this triggers the project ref build + codegen) sequenceBuilder .Type("aspire start --non-interactive") .Enter() .WaitUntil(s => waitForStartSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3)) .WaitForSuccessPrompt(counter); - // Step 6: Verify the custom integration was code-generated + // Step 4: Verify the custom integration was code-generated sequenceBuilder .Type("grep addMyService .modules/aspire.ts") .Enter() .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(10)) .WaitForSuccessPrompt(counter); - // Step 7: Clean up + // Step 5: Clean up sequenceBuilder .Type("aspire stop --all 2>/dev/null || true") .Enter() From 57788923a7dd04e3cb99c011347ee748baf98d0c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 23:25:54 -0800 Subject: [PATCH 12/26] Address PR review feedback - Null-safe value handling in GetIntegrationReferences (trim, null check before .csproj detection) - Clean stale libs directory before project ref build to prevent leftover DLLs - Use XDocument for synthetic project generation instead of string interpolation (XML-safe) - Filter SDK-only packages from ATS assembly list in DotNetBasedAppHostServerProject - Restore xmldoc param comments on PrebuiltAppHostServer constructor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/AspireJsonConfiguration.cs | 17 +++-- .../DotNetBasedAppHostServerProject.cs | 7 ++ .../Projects/PrebuiltAppHostServer.cs | 64 ++++++++++++------- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 4fec3adfb22..3559e8e953b 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -201,16 +201,25 @@ public IEnumerable GetIntegrationReferences(string default continue; } - if (value.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + var trimmedValue = value?.Trim(); + + if (string.IsNullOrEmpty(trimmedValue)) + { + // NuGet package reference with no explicit version — fall back to the SDK version + yield return new IntegrationReference(packageName, sdkVersion, ProjectPath: null); + continue; + } + + if (trimmedValue.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) { // Project reference — resolve relative path to absolute - var absolutePath = Path.GetFullPath(Path.Combine(settingsDirectory, value)); + var absolutePath = Path.GetFullPath(Path.Combine(settingsDirectory, trimmedValue)); yield return new IntegrationReference(packageName, Version: null, ProjectPath: absolutePath); } else { - // NuGet package reference - yield return new IntegrationReference(packageName, string.IsNullOrWhiteSpace(value) ? sdkVersion : value, ProjectPath: null); + // NuGet package reference with explicit version + yield return new IntegrationReference(packageName, trimmedValue, ProjectPath: null); } } } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index f63437097cd..36202c8d790 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -299,6 +299,13 @@ private XDocument CreateProjectFile(IEnumerable integratio var atsAssemblies = new List { "Aspire.Hosting" }; foreach (var integration in integrations) { + // Skip SDK-only packages that don't produce runtime assemblies + if (integration.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + integration.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase)) { atsAssemblies.Add(integration.Name); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index a9b5dfc2903..5806070f8eb 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -40,6 +40,15 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject /// /// Initializes a new instance of the PrebuiltAppHostServer class. /// + /// The path to the user's polyglot app host directory. + /// The socket path for JSON-RPC communication. + /// The bundle layout configuration. + /// The NuGet service for restoring integration packages (NuGet-only path). + /// The .NET CLI runner for building project references. + /// The SDK installer for checking .NET SDK availability. + /// The packaging service for channel resolution. + /// The configuration service for reading channel settings. + /// The logger for diagnostic output. public PrebuiltAppHostServer( string appPath, string socketPath, @@ -181,6 +190,12 @@ private async Task BuildIntegrationProjectAsync( Directory.CreateDirectory(restoreDir); var outputDir = Path.Combine(restoreDir, "libs"); + // Clean stale DLLs from previous builds to prevent leftover assemblies + // from removed integrations being picked up by the assembly resolver + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, recursive: true); + } Directory.CreateDirectory(outputDir); var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir); @@ -260,31 +275,34 @@ internal static string GenerateIntegrationProjectFile( List projectRefs, string outputDir) { - var packageElements = string.Join("\n ", - packageRefs.Select(p => $"""""")); + var doc = new System.Xml.Linq.XDocument( + new System.Xml.Linq.XElement("Project", + new System.Xml.Linq.XAttribute("Sdk", "Microsoft.NET.Sdk"), + new System.Xml.Linq.XElement("PropertyGroup", + new System.Xml.Linq.XElement("TargetFramework", "net10.0"), + new System.Xml.Linq.XElement("EnableDefaultItems", "false"), + new System.Xml.Linq.XElement("CopyLocalLockFileAssemblies", "true"), + new System.Xml.Linq.XElement("ProduceReferenceAssembly", "false"), + new System.Xml.Linq.XElement("EnableNETAnalyzers", "false"), + new System.Xml.Linq.XElement("GenerateDocumentationFile", "false"), + new System.Xml.Linq.XElement("OutDir", outputDir)))); + + if (packageRefs.Count > 0) + { + doc.Root!.Add(new System.Xml.Linq.XElement("ItemGroup", + packageRefs.Select(p => new System.Xml.Linq.XElement("PackageReference", + new System.Xml.Linq.XAttribute("Include", p.Name), + new System.Xml.Linq.XAttribute("Version", p.Version!))))); + } - var projectElements = string.Join("\n ", - projectRefs.Select(p => $"""""")); + if (projectRefs.Count > 0) + { + doc.Root!.Add(new System.Xml.Linq.XElement("ItemGroup", + projectRefs.Select(p => new System.Xml.Linq.XElement("ProjectReference", + new System.Xml.Linq.XAttribute("Include", p.ProjectPath!))))); + } - return $$""" - - - net10.0 - false - true - false - false - false - {{outputDir}} - - - {{packageElements}} - - - {{projectElements}} - - - """; + return doc.ToString(); } /// From d2b702efed333f2a248efa4568761b6d7e9f31fb Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 28 Feb 2026 23:48:22 -0800 Subject: [PATCH 13/26] Use using directive for System.Xml.Linq Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 5806070f8eb..de5d2cfe54d 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; +using System.Xml.Linq; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Layout; @@ -275,31 +276,31 @@ internal static string GenerateIntegrationProjectFile( List projectRefs, string outputDir) { - var doc = new System.Xml.Linq.XDocument( - new System.Xml.Linq.XElement("Project", - new System.Xml.Linq.XAttribute("Sdk", "Microsoft.NET.Sdk"), - new System.Xml.Linq.XElement("PropertyGroup", - new System.Xml.Linq.XElement("TargetFramework", "net10.0"), - new System.Xml.Linq.XElement("EnableDefaultItems", "false"), - new System.Xml.Linq.XElement("CopyLocalLockFileAssemblies", "true"), - new System.Xml.Linq.XElement("ProduceReferenceAssembly", "false"), - new System.Xml.Linq.XElement("EnableNETAnalyzers", "false"), - new System.Xml.Linq.XElement("GenerateDocumentationFile", "false"), - new System.Xml.Linq.XElement("OutDir", outputDir)))); + var doc = new XDocument( + new XElement("Project", + new XAttribute("Sdk", "Microsoft.NET.Sdk"), + new XElement("PropertyGroup", + new XElement("TargetFramework", "net10.0"), + new XElement("EnableDefaultItems", "false"), + new XElement("CopyLocalLockFileAssemblies", "true"), + new XElement("ProduceReferenceAssembly", "false"), + new XElement("EnableNETAnalyzers", "false"), + new XElement("GenerateDocumentationFile", "false"), + new XElement("OutDir", outputDir)))); if (packageRefs.Count > 0) { - doc.Root!.Add(new System.Xml.Linq.XElement("ItemGroup", - packageRefs.Select(p => new System.Xml.Linq.XElement("PackageReference", - new System.Xml.Linq.XAttribute("Include", p.Name), - new System.Xml.Linq.XAttribute("Version", p.Version!))))); + doc.Root!.Add(new XElement("ItemGroup", + packageRefs.Select(p => new XElement("PackageReference", + new XAttribute("Include", p.Name), + new XAttribute("Version", p.Version!))))); } if (projectRefs.Count > 0) { - doc.Root!.Add(new System.Xml.Linq.XElement("ItemGroup", - projectRefs.Select(p => new System.Xml.Linq.XElement("ProjectReference", - new System.Xml.Linq.XAttribute("Include", p.ProjectPath!))))); + doc.Root!.Add(new XElement("ItemGroup", + projectRefs.Select(p => new XElement("ProjectReference", + new XAttribute("Include", p.ProjectPath!))))); } return doc.ToString(); From 8317809a81b5f0e164116cefd6ee4e4559f7efa1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 00:17:54 -0800 Subject: [PATCH 14/26] Improve project reference E2E test with describe/wait verification Update apphost.ts to use the custom integration (addMyService), then verify with aspire wait and aspire describe that the resource actually runs. Reduce timeouts to be more reasonable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectReferenceTests.cs | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 11b8d1306ed..777ae38a223 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -12,7 +12,7 @@ namespace Aspire.Cli.EndToEnd.Tests; /// /// End-to-end test for polyglot project reference support. /// Creates a .NET hosting integration project and a TypeScript AppHost that references it -/// via settings.json, then verifies the integration is discovered and code-generated. +/// via settings.json, then verifies the integration is discovered, code-generated, and functional. /// public sealed class ProjectReferenceTests(ITestOutputHelper output) { @@ -38,6 +38,10 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() var waitForAddMyServiceInCodegen = new CellPatternSearcher() .Find("addMyService"); + // Pattern to verify the resource appears in describe output + var waitForMyServiceInDescribe = new CellPatternSearcher() + .Find("my-svc"); + var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -50,14 +54,14 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); } - // Step 1: Create a TypeScript AppHost first (so we get the sdkVersion in settings.json) + // Step 1: Create a TypeScript AppHost (so we get the sdkVersion in settings.json) sequenceBuilder .Type("aspire init --language typescript --non-interactive") .Enter() .WaitUntil(s => waitingForAppHostCreated.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .WaitForSuccessPrompt(counter); - // Step 2: Create the integration project and update settings.json from C# + // Step 2: Create the integration project, update settings.json, and modify apphost.ts sequenceBuilder.ExecuteCallback(() => { var workDir = workspace.WorkspaceRoot.FullName; @@ -113,25 +117,50 @@ public static IResourceBuilder AddMyService( var updatedJson = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(settingsPath, updatedJson); + + // Update apphost.ts to use the custom integration + File.WriteAllText(Path.Combine(workDir, "apphost.ts"), """ + import { createBuilder } from './.modules/aspire.js'; + + const builder = await createBuilder(); + await builder.addMyService("my-svc"); + await builder.build().run(); + """); }); - // Step 3: Start the AppHost (this triggers the project ref build + codegen) + // Step 3: Start the AppHost (triggers project ref build + codegen) sequenceBuilder .Type("aspire start --non-interactive") .Enter() - .WaitUntil(s => waitForStartSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitUntil(s => waitForStartSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .WaitForSuccessPrompt(counter); // Step 4: Verify the custom integration was code-generated sequenceBuilder .Type("grep addMyService .modules/aspire.ts") .Enter() - .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitUntil(s => waitForAddMyServiceInCodegen.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .WaitForSuccessPrompt(counter); + + // Step 5: Wait for the custom resource to be up + sequenceBuilder + .Type("aspire wait my-svc --timeout 60") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(90)); + + // Step 6: Verify the resource appears in describe + sequenceBuilder + .Type("aspire describe my-svc --format json > /tmp/my-svc-describe.json") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(15)) + .Type("cat /tmp/my-svc-describe.json") + .Enter() + .WaitUntil(s => waitForMyServiceInDescribe.Search(s).Count > 0, TimeSpan.FromSeconds(5)) .WaitForSuccessPrompt(counter); - // Step 5: Clean up + // Step 7: Clean up sequenceBuilder - .Type("aspire stop --all 2>/dev/null || true") + .Type("aspire stop") .Enter() .WaitForSuccessPrompt(counter) .Type("exit") From 26b23bce8e4d99ca5a5fb0646d1c3276877364ce Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 00:40:55 -0800 Subject: [PATCH 15/26] Fix E2E test: use redis image instead of nonexistent myservice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 777ae38a223..498669a4072 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -99,7 +99,7 @@ public static class MyIntegrationExtensions [AspireExport("addMyService")] public static IResourceBuilder AddMyService( this IDistributedApplicationBuilder builder, string name) - => builder.AddContainer(name, "myservice", "latest"); + => builder.AddContainer(name, "redis", "latest"); } """); From 35ba2cc276e26758e4195578c3d55555aa7054c6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 00:55:42 -0800 Subject: [PATCH 16/26] Fix E2E test: correct project reference path and force codegen regeneration The MyIntegration directory is inside the workspace (./MyIntegration not ../MyIntegration). Also delete .modules/ before aspire start to force re-codegen with the new integration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 498669a4072..4d1ce53eaee 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -112,12 +112,19 @@ public static IResourceBuilder AddMyService( } settings["packages"] = new Dictionary { - ["MyIntegration"] = "../MyIntegration/MyIntegration.csproj" + ["MyIntegration"] = "./MyIntegration/MyIntegration.csproj" }; var updatedJson = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(settingsPath, updatedJson); + // Delete the generated .modules folder to force re-codegen with the new integration + var modulesDir = Path.Combine(workDir, ".modules"); + if (Directory.Exists(modulesDir)) + { + Directory.Delete(modulesDir, recursive: true); + } + // Update apphost.ts to use the custom integration File.WriteAllText(Path.Combine(workDir, "apphost.ts"), """ import { createBuilder } from './.modules/aspire.js'; From babcde2fb26238a0f73fda925f154dca2385e1da Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 08:24:35 -0800 Subject: [PATCH 17/26] Fix: write nuget.config with channel sources for project ref builds NuGetConfigMerger.CreateOrUpdateAsync is a no-op for implicit/hive channels. Instead, use GetNuGetSourcesAsync to get the actual feed URLs and write a nuget.config directly into the synthetic project directory. This ensures dotnet build can resolve Aspire packages from hive feeds in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index de5d2cfe54d..34565c88c35 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -229,24 +229,36 @@ await File.WriteAllTextAsync( File.Copy(userNugetConfig, Path.Combine(restoreDir, "nuget.config"), overwrite: true); } - // Merge channel-specific NuGet sources if a channel is configured + // Ensure the synthetic project can find NuGet packages from the configured channel + // NuGetConfigMerger only works for explicit channels, so for implicit/hive channels + // we need to get the sources directly and write a nuget.config if (channelName is not null) { try { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - var channel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - if (channel is not null) + var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); + if (sources is not null) { - await NuGetConfigMerger.CreateOrUpdateAsync( - new DirectoryInfo(restoreDir), - channel, - cancellationToken: cancellationToken); + var nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); + var sourceElements = string.Join("\n ", + sources.Select((s, i) => $"""""")); + + var nugetConfig = $""" + + + + + {sourceElements} + + + + """; + await File.WriteAllTextAsync(nugetConfigPath, nugetConfig, cancellationToken); } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to merge channel NuGet sources, relying on user nuget.config"); + _logger.LogWarning(ex, "Failed to configure NuGet sources for integration project build"); } } From 431f132d7575133fbfaa00ea94b94b9e812a75d9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 10:22:56 -0800 Subject: [PATCH 18/26] Fix CheckAsync tuple deconstruction after release/13.2 rebase PR #14811 removed ForceInstall from IDotNetSdkInstaller.CheckAsync, changing from 4-element to 3-element tuple. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 34565c88c35..ca7bc66ebe8 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -116,7 +116,7 @@ public async Task PrepareAsync( if (projectRefs.Count > 0) { // Project references require .NET SDK — verify it's available - var (sdkAvailable, _, minimumRequired, _) = await _sdkInstaller.CheckAsync(cancellationToken); + var (sdkAvailable, _, minimumRequired) = await _sdkInstaller.CheckAsync(cancellationToken); if (!sdkAvailable) { throw new InvalidOperationException( From 60ae7b94e78262ccd5048d5773d276d405cc39e3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 23:13:12 -0800 Subject: [PATCH 19/26] Add build output logging for integration project failures Capture and log stdout/stderr from dotnet build when the synthetic integration project fails, to help diagnose CI failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index ca7bc66ebe8..3d6765bfe77 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -265,14 +265,21 @@ await File.WriteAllTextAsync( _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references", packageRefs.Count, projectRefs.Count); + var buildOutput = new OutputCollector(); var exitCode = await _dotNetCliRunner.BuildAsync( new FileInfo(projectFilePath), noRestore: false, - new DotNetCliRunnerInvocationOptions(), + new DotNetCliRunnerInvocationOptions + { + StandardOutputCallback = buildOutput.AppendOutput, + StandardErrorCallback = buildOutput.AppendError + }, cancellationToken); if (exitCode != 0) { + var outputLines = string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line)); + _logger.LogError("Integration project build failed. Output:\n{BuildOutput}", outputLines); throw new InvalidOperationException($"Failed to build integration project. Exit code: {exitCode}"); } From b213eca44d23850792403272eef65f75711f23d3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 1 Mar 2026 23:34:27 -0800 Subject: [PATCH 20/26] Dump child log on E2E test failure for CI debugging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectReferenceTests.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 4d1ce53eaee..ccdb6ac84c7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -136,12 +136,30 @@ public static IResourceBuilder AddMyService( }); // Step 3: Start the AppHost (triggers project ref build + codegen) + // Detect either success or failure + var waitForStartFailure = new CellPatternSearcher() + .Find("AppHost failed to build"); + sequenceBuilder - .Type("aspire start --non-interactive") + .Type("aspire start --non-interactive 2>&1 | tee /tmp/aspire-start-output.txt") .Enter() - .WaitUntil(s => waitForStartSuccess.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitUntil(s => + { + if (waitForStartFailure.Search(s).Count > 0) + { + // Dump child logs before failing + return true; + } + return waitForStartSuccess.Search(s).Count > 0; + }, TimeSpan.FromMinutes(2)) .WaitForSuccessPrompt(counter); + // If start failed, dump the child log for debugging before the test fails + sequenceBuilder + .Type("CHILD_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1) && if [ -n \"$CHILD_LOG\" ]; then echo '=== CHILD LOG ==='; cat \"$CHILD_LOG\"; echo '=== END CHILD LOG ==='; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + // Step 4: Verify the custom integration was code-generated sequenceBuilder .Type("grep addMyService .modules/aspire.ts") From c31efdfeccb3e7a123a37ce53935ab33a58afadc Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 2 Mar 2026 07:50:38 -0800 Subject: [PATCH 21/26] Fix: use RestoreConfigFile to ensure project references resolve from channel feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the synthetic project has ProjectReferences, MSBuild restores the referenced project's packages from nuget.config nearest to that project's directory — not the synthetic project's directory. Add RestoreConfigFile to the synthetic .csproj so all projects in the build graph use our channel-aware nuget.config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 3d6765bfe77..5c7655e0cd1 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -199,34 +199,16 @@ private async Task BuildIntegrationProjectAsync( } Directory.CreateDirectory(outputDir); - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir); - var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); - await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); - - // Write a Directory.Packages.props to opt out of Central Package Management - // This prevents any parent Directory.Packages.props from interfering with our inline versions - var directoryPackagesProps = """ - - - false - - - """; - await File.WriteAllTextAsync( - Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken); - - // Also write an empty Directory.Build.props/targets to prevent parent imports - await File.WriteAllTextAsync( - Path.Combine(restoreDir, "Directory.Build.props"), "", cancellationToken); - await File.WriteAllTextAsync( - Path.Combine(restoreDir, "Directory.Build.targets"), "", cancellationToken); + // Write nuget.config first so we can reference its path in the project file + string? nugetConfigPath = null; // Copy nuget.config from the user's apphost directory if present var appHostDirectory = Path.GetDirectoryName(_appPath)!; var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); if (File.Exists(userNugetConfig)) { - File.Copy(userNugetConfig, Path.Combine(restoreDir, "nuget.config"), overwrite: true); + nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); + File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); } // Ensure the synthetic project can find NuGet packages from the configured channel @@ -239,7 +221,7 @@ await File.WriteAllTextAsync( var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); if (sources is not null) { - var nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); + nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); var sourceElements = string.Join("\n ", sources.Select((s, i) => $"""""")); @@ -262,6 +244,27 @@ await File.WriteAllTextAsync( } } + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, nugetConfigPath); + var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); + await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + + // Write a Directory.Packages.props to opt out of Central Package Management + var directoryPackagesProps = """ + + + false + + + """; + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken); + + // Also write an empty Directory.Build.props/targets to prevent parent imports + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.props"), "", cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(restoreDir, "Directory.Build.targets"), "", cancellationToken); + _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references", packageRefs.Count, projectRefs.Count); @@ -293,19 +296,28 @@ await File.WriteAllTextAsync( internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, - string outputDir) + string outputDir, + string? nugetConfigPath = null) { + var propertyGroup = new XElement("PropertyGroup", + new XElement("TargetFramework", "net10.0"), + new XElement("EnableDefaultItems", "false"), + new XElement("CopyLocalLockFileAssemblies", "true"), + new XElement("ProduceReferenceAssembly", "false"), + new XElement("EnableNETAnalyzers", "false"), + new XElement("GenerateDocumentationFile", "false"), + new XElement("OutDir", outputDir)); + + // Force all projects in the build graph to use our nuget.config + if (nugetConfigPath is not null) + { + propertyGroup.Add(new XElement("RestoreConfigFile", nugetConfigPath)); + } + var doc = new XDocument( new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"), - new XElement("PropertyGroup", - new XElement("TargetFramework", "net10.0"), - new XElement("EnableDefaultItems", "false"), - new XElement("CopyLocalLockFileAssemblies", "true"), - new XElement("ProduceReferenceAssembly", "false"), - new XElement("EnableNETAnalyzers", "false"), - new XElement("GenerateDocumentationFile", "false"), - new XElement("OutDir", outputDir)))); + propertyGroup)); if (packageRefs.Count > 0) { From 775498535c70d066bc03c01d4e87cf303147c80c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 2 Mar 2026 09:53:58 -0800 Subject: [PATCH 22/26] Fix E2E test: write nuget.config in workspace for project reference resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyIntegration.csproj needs to resolve Aspire.Hosting from the hive during dotnet build. MSBuild walks up from the project directory looking for nuget.config — without one in the workspace, it defaults to nuget.org which doesn't have prerelease versions. Write a nuget.config with hive sources in the workspace root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectReferenceTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index ccdb6ac84c7..169a918f438 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -88,6 +88,38 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() """); + // Write a nuget.config in the workspace root so MyIntegration.csproj can resolve + // Aspire.Hosting from the configured channel's package source (hive or feed). + // Without this, NuGet walks up from MyIntegration/ and finds no config pointing + // to the hive, falling back to nuget.org which doesn't have prerelease versions. + var aspireHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); + var hivesDir = Path.Combine(aspireHome, "hives"); + if (Directory.Exists(hivesDir)) + { + var hiveDirs = Directory.GetDirectories(hivesDir); + var sourceLines = new List { """""" }; + foreach (var hiveDir in hiveDirs) + { + var packagesDir = Path.Combine(hiveDir, "packages"); + if (Directory.Exists(packagesDir)) + { + var hiveName = Path.GetFileName(hiveDir); + sourceLines.Insert(0, $""""""); + } + } + var nugetConfig = $""" + + + + + {string.Join("\n ", sourceLines)} + + + """; + File.WriteAllText(Path.Combine(workDir, "nuget.config"), nugetConfig); + } + File.WriteAllText(Path.Combine(integrationDir, "MyIntegrationExtensions.cs"), """ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; From 5202e220f858c8b06abc70c0dd7161d8333f97b8 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Mar 2026 06:52:19 -0800 Subject: [PATCH 23/26] Address JamesNK review feedback - Add IntegrationReference validation via factory methods (FromPackage/FromProject) ensuring exactly one of Version or ProjectPath is set - Rename GetAllPackagesAsync to GetIntegrationReferencesAsync - Add explicit null check for Version instead of null-forgiving operator - Use DotNetBasedAppHostServerProject.TargetFramework constant instead of hardcoded "net10.0" in PrebuiltAppHostServer - Replace RestoreConfigFile with RestoreAdditionalProjectSources MSBuild property to add channel sources without replacing the user's NuGet config - Remove nuget.config generation with and unconditional nuget.org addition - Stop copying/overwriting user nuget.config (RestoreAdditionalProjectSources makes this unnecessary) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 5 +- .../Commands/Sdk/SdkGenerateCommand.cs | 7 +- .../Configuration/AspireJsonConfiguration.cs | 8 +-- .../Configuration/IntegrationReference.cs | 47 +++++++++++-- .../DotNetBasedAppHostServerProject.cs | 8 ++- .../Projects/GuestAppHostProject.cs | 10 +-- .../Projects/PrebuiltAppHostServer.cs | 68 +++++++------------ .../Scaffolding/ScaffoldingService.cs | 2 +- .../IntegrationReferenceTests.cs | 4 +- .../Projects/AppHostServerProjectTests.cs | 26 +++---- .../Projects/PrebuiltAppHostServerTests.cs | 14 ++-- 11 files changed, 110 insertions(+), 89 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index f0ec3761900..79d83d3f9a2 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -129,10 +129,9 @@ private async Task DumpCapabilitiesAsync( if (integrationProject is not null) { - integrations.Add(new IntegrationReference( + integrations.Add(IntegrationReference.FromProject( Path.GetFileNameWithoutExtension(integrationProject.FullName), - Version: null, - ProjectPath: integrationProject.FullName)); + integrationProject.FullName)); } _logger.LogDebug("Building AppHost server for capability scanning"); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index bdaff6da038..5935fdc9561 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -130,14 +130,13 @@ private async Task GenerateSdkAsync( var integrations = new List(); if (codeGenPackage is not null) { - integrations.Add(new IntegrationReference(codeGenPackage, VersionHelper.GetDefaultTemplateVersion(), ProjectPath: null)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, VersionHelper.GetDefaultTemplateVersion())); } // Add the integration project as a project reference - integrations.Add(new IntegrationReference( + integrations.Add(IntegrationReference.FromProject( Path.GetFileNameWithoutExtension(integrationProject.FullName), - Version: null, - ProjectPath: integrationProject.FullName)); + integrationProject.FullName)); _logger.LogDebug("Building AppHost server for SDK generation"); diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3559e8e953b..609f5929fd4 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -185,7 +185,7 @@ public IEnumerable GetIntegrationReferences(string default var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); // Base package always included - yield return new IntegrationReference("Aspire.Hosting", sdkVersion, ProjectPath: null); + yield return IntegrationReference.FromPackage("Aspire.Hosting", sdkVersion); if (Packages is null) { @@ -206,7 +206,7 @@ public IEnumerable GetIntegrationReferences(string default if (string.IsNullOrEmpty(trimmedValue)) { // NuGet package reference with no explicit version — fall back to the SDK version - yield return new IntegrationReference(packageName, sdkVersion, ProjectPath: null); + yield return IntegrationReference.FromPackage(packageName, sdkVersion); continue; } @@ -214,12 +214,12 @@ public IEnumerable GetIntegrationReferences(string default { // Project reference — resolve relative path to absolute var absolutePath = Path.GetFullPath(Path.Combine(settingsDirectory, trimmedValue)); - yield return new IntegrationReference(packageName, Version: null, ProjectPath: absolutePath); + yield return IntegrationReference.FromProject(packageName, absolutePath); } else { // NuGet package reference with explicit version - yield return new IntegrationReference(packageName, trimmedValue, ProjectPath: null); + yield return IntegrationReference.FromPackage(packageName, trimmedValue); } } } diff --git a/src/Aspire.Cli/Configuration/IntegrationReference.cs b/src/Aspire.Cli/Configuration/IntegrationReference.cs index 286aa37460e..be04807cbb5 100644 --- a/src/Aspire.Cli/Configuration/IntegrationReference.cs +++ b/src/Aspire.Cli/Configuration/IntegrationReference.cs @@ -6,12 +6,25 @@ namespace Aspire.Cli.Configuration; /// /// Represents a reference to an Aspire hosting integration, which can be either /// a NuGet package (with a version) or a local project reference (with a path to a .csproj). +/// Exactly one of or must be non-null. /// -/// The package or assembly name (e.g., "Aspire.Hosting.Redis"). -/// The NuGet package version, or null for project references. -/// The absolute path to the .csproj file, or null for NuGet packages. -internal sealed record IntegrationReference(string Name, string? Version, string? ProjectPath) +internal sealed record IntegrationReference { + /// + /// Gets the package or assembly name (e.g., "Aspire.Hosting.Redis"). + /// + public required string Name { get; init; } + + /// + /// Gets the NuGet package version, or null for project references. + /// + public string? Version { get; init; } + + /// + /// Gets the absolute path to the .csproj file, or null for NuGet packages. + /// + public string? ProjectPath { get; init; } + /// /// Returns true if this is a project reference (has a .csproj path). /// @@ -21,4 +34,30 @@ internal sealed record IntegrationReference(string Name, string? Version, string /// Returns true if this is a NuGet package reference (has a version). /// public bool IsPackageReference => Version is not null; + + /// + /// Creates a NuGet package reference. + /// + /// The package name. + /// The NuGet package version. + public static IntegrationReference FromPackage(string name, string version) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(version); + + return new IntegrationReference { Name = name, Version = version }; + } + + /// + /// Creates a local project reference. + /// + /// The assembly name. + /// The absolute path to the .csproj file. + public static IntegrationReference FromProject(string name, string projectPath) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(projectPath); + + return new IntegrationReference { Name = name, ProjectPath = projectPath }; + } } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 36202c8d790..8b36cccef99 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -26,7 +26,7 @@ internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject private const string AppsFolder = "hosts"; public const string ProjectFileName = "AppHostServer.csproj"; private const string ProjectDllName = "AppHostServer.dll"; - private const string TargetFramework = "net10.0"; + internal const string TargetFramework = "net10.0"; public const string BuildFolder = "build"; private const string AssemblyName = "AppHostServer"; @@ -207,7 +207,11 @@ private XDocument CreateProjectFile(IEnumerable integratio } else { - otherPackages.Add((integration.Name, integration.Version!)); + if (integration.Version is null) + { + throw new InvalidOperationException($"Integration '{integration.Name}' is neither a project reference nor a package reference (both Version and ProjectPath are null)."); + } + otherPackages.Add((integration.Name, integration.Version)); } } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 81ba47c9c32..7c3c38ce31d 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -139,7 +139,7 @@ public bool IsUsingProjectReferences(FileInfo appHostFile) /// /// Gets all integration references including the code generation package for the current language. /// - private async Task> GetAllPackagesAsync( + private async Task> GetIntegrationReferencesAsync( AspireJsonConfiguration config, DirectoryInfo directory, CancellationToken cancellationToken) @@ -150,7 +150,7 @@ private async Task> GetAllPackagesAsync( if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); - integrations.Add(new IntegrationReference(codeGenPackage, codeGenVersion, ProjectPath: null)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion)); } return integrations; } @@ -188,7 +188,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio // Step 1: Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, directory, cancellationToken); + var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); @@ -295,7 +295,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, directory, cancellationToken); + var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( @@ -599,7 +599,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 1: Load config - source of truth for SDK version and packages var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var config = LoadConfiguration(directory); - var packages = await GetAllPackagesAsync(config, directory, cancellationToken); + var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 5c7655e0cd1..5c2959809eb 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -170,7 +170,7 @@ private async Task RestoreNuGetPackagesAsync( return await _nugetService.RestorePackagesAsync( packages, - "net10.0", + DotNetBasedAppHostServerProject.TargetFramework, sources: sources, workingDirectory: appHostDirectory, ct: cancellationToken); @@ -199,44 +199,13 @@ private async Task BuildIntegrationProjectAsync( } Directory.CreateDirectory(outputDir); - // Write nuget.config first so we can reference its path in the project file - string? nugetConfigPath = null; - - // Copy nuget.config from the user's apphost directory if present - var appHostDirectory = Path.GetDirectoryName(_appPath)!; - var userNugetConfig = Path.Combine(appHostDirectory, "nuget.config"); - if (File.Exists(userNugetConfig)) - { - nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); - File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); - } - - // Ensure the synthetic project can find NuGet packages from the configured channel - // NuGetConfigMerger only works for explicit channels, so for implicit/hive channels - // we need to get the sources directly and write a nuget.config + // Resolve channel sources to add via RestoreAdditionalProjectSources + IEnumerable? channelSources = null; if (channelName is not null) { try { - var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); - if (sources is not null) - { - nugetConfigPath = Path.Combine(restoreDir, "nuget.config"); - var sourceElements = string.Join("\n ", - sources.Select((s, i) => $"""""")); - - var nugetConfig = $""" - - - - - {sourceElements} - - - - """; - await File.WriteAllTextAsync(nugetConfigPath, nugetConfig, cancellationToken); - } + channelSources = await GetNuGetSourcesAsync(channelName, cancellationToken); } catch (Exception ex) { @@ -244,7 +213,7 @@ private async Task BuildIntegrationProjectAsync( } } - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, nugetConfigPath); + var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, channelSources); var projectFilePath = Path.Combine(restoreDir, "IntegrationRestore.csproj"); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -297,10 +266,10 @@ internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, string outputDir, - string? nugetConfigPath = null) + IEnumerable? additionalSources = null) { var propertyGroup = new XElement("PropertyGroup", - new XElement("TargetFramework", "net10.0"), + new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), new XElement("EnableDefaultItems", "false"), new XElement("CopyLocalLockFileAssemblies", "true"), new XElement("ProduceReferenceAssembly", "false"), @@ -308,10 +277,14 @@ internal static string GenerateIntegrationProjectFile( new XElement("GenerateDocumentationFile", "false"), new XElement("OutDir", outputDir)); - // Force all projects in the build graph to use our nuget.config - if (nugetConfigPath is not null) + // Add channel sources without replacing the user's nuget.config + if (additionalSources is not null) { - propertyGroup.Add(new XElement("RestoreConfigFile", nugetConfigPath)); + var sourceList = string.Join(";", additionalSources); + if (sourceList.Length > 0) + { + propertyGroup.Add(new XElement("RestoreAdditionalProjectSources", sourceList)); + } } var doc = new XDocument( @@ -322,9 +295,16 @@ internal static string GenerateIntegrationProjectFile( if (packageRefs.Count > 0) { doc.Root!.Add(new XElement("ItemGroup", - packageRefs.Select(p => new XElement("PackageReference", - new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version!))))); + packageRefs.Select(p => + { + if (p.Version is null) + { + throw new InvalidOperationException($"Package reference '{p.Name}' is missing a version."); + } + return new XElement("PackageReference", + new XAttribute("Include", p.Name), + new XAttribute("Version", p.Version)); + }))); } if (projectRefs.Count > 0) diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 431f2470672..14615451120 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -65,7 +65,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat if (codeGenPackage is not null) { var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); - integrations.Add(new IntegrationReference(codeGenPackage, codeGenVersion, ProjectPath: null)); + integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs index 7c3423457b2..646321f32d6 100644 --- a/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/IntegrationReferenceTests.cs @@ -10,7 +10,7 @@ public class IntegrationReferenceTests [Fact] public void PackageReference_HasVersionAndNoProjectPath() { - var reference = new IntegrationReference("Aspire.Hosting.Redis", "13.2.0", null); + var reference = IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0"); Assert.True(reference.IsPackageReference); Assert.False(reference.IsProjectReference); @@ -21,7 +21,7 @@ public void PackageReference_HasVersionAndNoProjectPath() [Fact] public void ProjectReference_HasProjectPathAndNoVersion() { - var reference = new IntegrationReference("MyIntegration", null, "/path/to/MyIntegration.csproj"); + var reference = IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj"); Assert.True(reference.IsProjectReference); Assert.False(reference.IsPackageReference); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index eb7d61fba50..8fc62dbcf4c 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -50,10 +50,10 @@ public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot() var project = CreateProject(); var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.PostgreSQL", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") }; // Act @@ -74,7 +74,7 @@ public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot() var project = CreateProject(); var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -96,7 +96,7 @@ public async Task CreateProjectFiles_GeneratesProgramCs() var project = CreateProject(); var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -117,9 +117,9 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( var project = CreateProject(); var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") }; // Act @@ -143,7 +143,7 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() var project = CreateProject(); var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; // Act @@ -263,9 +263,9 @@ await File.WriteAllTextAsync(settingsJson, """ var packages = new List { - new IntegrationReference("Aspire.Hosting", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.AppHost", "13.1.0", null), - new IntegrationReference("Aspire.Hosting.Redis", "13.1.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.AppHost", "13.1.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0") }; // Act diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 6dc6bb1676d..b172eba4a5c 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -14,8 +14,8 @@ public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageRefer { var packageRefs = new List { - new("Aspire.Hosting", "13.2.0", null), - new("Aspire.Hosting.Redis", "13.2.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0") }; var projectRefs = new List(); @@ -36,7 +36,7 @@ public void GenerateIntegrationProjectFile_WithProjectRefsOnly_ProducesProjectRe var packageRefs = new List(); var projectRefs = new List { - new("MyIntegration", null, "/path/to/MyIntegration.csproj") + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") }; var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); @@ -54,12 +54,12 @@ public void GenerateIntegrationProjectFile_WithMixed_ProducesBothReferenceTypes( { var packageRefs = new List { - new("Aspire.Hosting", "13.2.0", null), - new("Aspire.Hosting.Redis", "13.2.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0") }; var projectRefs = new List { - new("MyIntegration", null, "/path/to/MyIntegration.csproj") + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") }; var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(packageRefs, projectRefs, "/tmp/libs"); @@ -74,7 +74,7 @@ public void GenerateIntegrationProjectFile_SetsOutDir() { var packageRefs = new List { - new("Aspire.Hosting", "13.2.0", null) + IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0") }; var projectRefs = new List(); From 1903c3dc51938df6e977d86efe8c9b28a2e500ce Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Mar 2026 07:37:07 -0800 Subject: [PATCH 24/26] Use RestoreAdditionalProjectSources in DotNetBasedAppHostServerProject Align with PrebuiltAppHostServer: replace NuGetConfigMerger with RestoreAdditionalProjectSources for channel source resolution. This is additive and propagates to the entire restore graph including project references. Update test to verify csproj contains the correct sources instead of checking nuget.config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNetBasedAppHostServerProject.cs | 43 +++++++++------ .../Projects/AppHostServerProjectTests.cs | 55 +++++-------------- ...onfig_UsesProjectLocalChannel.verified.xml | 16 ------ 3 files changed, 40 insertions(+), 74 deletions(-) delete mode 100644 tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 8b36cccef99..c0e96f8e839 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -335,11 +335,11 @@ private XDocument CreateProjectFile(IEnumerable integratio // Handle NuGet config and channel resolution string? channelName = null; - var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); var userNugetConfig = FindNuGetConfig(_appPath); if (userNugetConfig is not null) { + var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); } @@ -352,28 +352,39 @@ private XDocument CreateProjectFile(IEnumerable integratio configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); } - PackageChannel? channel; - if (!string.IsNullOrEmpty(configuredChannelName)) - { - channel = channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); - } - else - { - channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit); - } + // Resolve channel sources and add them via RestoreAdditionalProjectSources + // This is additive — it preserves the user's nuget.config and adds channel-specific sources + var channelSources = new List(); + var matchedChannels = !string.IsNullOrEmpty(configuredChannelName) + ? channels.Where(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)) + : channels.Where(c => c.Type == PackageChannelType.Explicit); - if (channel is not null) + foreach (var ch in matchedChannels) { - await NuGetConfigMerger.CreateOrUpdateAsync( - new DirectoryInfo(_projectModelPath), - channel, - cancellationToken: cancellationToken); - channelName = channel.Name; + channelName ??= ch.Name; + if (ch.Mappings is not null) + { + foreach (var mapping in ch.Mappings) + { + if (!channelSources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) + { + channelSources.Add(mapping.Source); + } + } + } } // Create the project file var doc = CreateProjectFile(integrations); + // Add channel sources to the project so project references can resolve packages + if (channelSources.Count > 0) + { + var sourceList = string.Join(";", channelSources); + doc.Root!.Descendants("PropertyGroup").First() + .Add(new XElement("RestoreAdditionalProjectSources", sourceList)); + } + // Add appsettings.json to output doc.Root!.Add(new XElement("ItemGroup", new XElement("None", diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 8fc62dbcf4c..85a7a59d689 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -288,48 +288,19 @@ await File.WriteAllTextAsync(settingsJson, """ } outputHelper.WriteLine("================================"); - // Assert - verify nuget.config uses the correct channel - // Note: NuGetConfigMerger creates the file as "nuget.config" (lowercase) - var nugetConfigPath = Path.Combine(project.ProjectModelPath, "nuget.config"); - - // Build diagnostic info for assertion failure - var diagnosticInfo = new System.Text.StringBuilder(); - diagnosticInfo.AppendLine($"appPath: {appPath}"); - diagnosticInfo.AppendLine($"settingsJson path: {settingsJson}"); - diagnosticInfo.AppendLine($"settingsJson exists: {File.Exists(settingsJson)}"); - if (File.Exists(settingsJson)) - { - diagnosticInfo.AppendLine($"settingsJson content: {File.ReadAllText(settingsJson)}"); - } - diagnosticInfo.AppendLine($"project.ProjectModelPath: {project.ProjectModelPath}"); - diagnosticInfo.AppendLine($"nugetConfigPath: {nugetConfigPath}"); - diagnosticInfo.AppendLine($"nugetConfigPath exists: {File.Exists(nugetConfigPath)}"); - - // List all files for debugging case sensitivity issues - if (Directory.Exists(project.ProjectModelPath)) - { - diagnosticInfo.AppendLine("Files in ProjectModelPath:"); - foreach (var file in Directory.GetFiles(project.ProjectModelPath)) - { - diagnosticInfo.AppendLine($" - {Path.GetFileName(file)}"); - } - } - - // The nuget.config should exist - Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created\n\nDiagnostics:\n{diagnosticInfo}"); - - var nugetConfigContent = await File.ReadAllTextAsync(nugetConfigPath); - - // Normalize paths for snapshot (replace machine-specific paths) - var normalizedContent = nugetConfigContent - .Replace(prNewHive.FullName, "{PR_NEW_HIVE}") - .Replace(prOldHive.FullName, "{PR_OLD_HIVE}"); - - // Snapshot verification - this will fail if the bug exists - // Expected: Contains {PR_NEW_HIVE} (project-local channel) - // Bug behavior: Contains {PR_OLD_HIVE} (global config channel) - await Verify(normalizedContent, extension: "xml") - .UseFileName("AppHostServerProject_NuGetConfig_UsesProjectLocalChannel"); + // Assert - verify the csproj uses RestoreAdditionalProjectSources with the correct channel sources + var projectFilePath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName); + + Assert.True(File.Exists(projectFilePath), $"Project file should be created at {projectFilePath}"); + + var projectContent = await File.ReadAllTextAsync(projectFilePath); + var projectDoc = XDocument.Parse(projectContent); + var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + + // Should contain the pr-new hive path (project-local channel), NOT pr-old (global config) + Assert.NotNull(restoreSources); + Assert.Contains(prNewHive.FullName, restoreSources); + Assert.DoesNotContain(prOldHive.FullName, restoreSources); } /// diff --git a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml deleted file mode 100644 index beb326e5ea6..00000000000 --- a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_NuGetConfig_UsesProjectLocalChannel.verified.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file From 3af7787849654caa74ea66eef9bd3be8b27c57ef Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Mar 2026 17:01:23 -0800 Subject: [PATCH 25/26] Address JamesNK and sebastienros review feedback - IntegrationReference: change from record to class (JamesNK) - Rename 'packages' locals to 'integrations' where assigned from GetIntegrationReferencesAsync (JamesNK) - Call GetNuGetSourcesAsync unconditionally in BuildIntegrationProjectAsync (sebastienros) - Always regenerate codegen when project references are present by including timestamp in hash (sebastienros) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/IntegrationReference.cs | 2 +- .../Projects/GuestAppHostProject.cs | 38 ++++++++++++------- .../Projects/PrebuiltAppHostServer.cs | 15 +++----- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Cli/Configuration/IntegrationReference.cs b/src/Aspire.Cli/Configuration/IntegrationReference.cs index be04807cbb5..b6d54013ce4 100644 --- a/src/Aspire.Cli/Configuration/IntegrationReference.cs +++ b/src/Aspire.Cli/Configuration/IntegrationReference.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Configuration; /// a NuGet package (with a version) or a local project reference (with a path to a .csproj). /// Exactly one of or must be non-null. /// -internal sealed record IntegrationReference +internal sealed class IntegrationReference { /// /// Gets the package or assembly name (e.g., "Aspire.Hosting.Redis"). diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 7c3c38ce31d..8d8530f82c9 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -188,10 +188,10 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio // Step 1: Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -218,7 +218,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } finally @@ -295,7 +295,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Load config - source of truth for SDK version and packages var config = LoadConfiguration(directory); - var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( @@ -303,7 +303,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken async () => { // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -408,7 +408,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } @@ -599,11 +599,11 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 1: Load config - source of truth for SDK version and packages var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var config = LoadConfiguration(directory); - var packages = await GetIntegrationReferencesAsync(config, directory, cancellationToken); + var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -681,7 +681,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca await GenerateCodeViaRpcAsync( directory.FullName, rpcClient, - packages, + integrations, cancellationToken); } @@ -1047,19 +1047,23 @@ private async Task GenerateCodeViaRpcAsync( } /// - /// Saves a hash of the packages to avoid regenerating code unnecessarily. + /// Saves a hash of the integrations to avoid regenerating code unnecessarily. + /// When project references are present, the hash is always unique to force regeneration + /// since project outputs are mutable. /// private static void SaveGenerationHash(string generatedPath, List integrations) { var hashPath = Path.Combine(generatedPath, ".codegen-hash"); - var hash = ComputePackagesHash(integrations); + var hash = ComputeIntegrationsHash(integrations); File.WriteAllText(hashPath, hash); } /// - /// Computes a hash of the package list for caching purposes. + /// Computes a hash of the integration list for caching purposes. + /// If any project references are present, includes a timestamp to force regeneration + /// since project outputs can change between builds. /// - private static string ComputePackagesHash(List integrations) + private static string ComputeIntegrationsHash(List integrations) { var sb = new System.Text.StringBuilder(); foreach (var integration in integrations.OrderBy(p => p.Name)) @@ -1069,6 +1073,14 @@ private static string ComputePackagesHash(List integration sb.Append(integration.Version ?? integration.ProjectPath ?? ""); sb.Append(';'); } + + // Project references are mutable — always regenerate when they're present + if (integrations.Any(i => i.IsProjectReference)) + { + sb.Append("timestamp:"); + sb.Append(DateTime.UtcNow.Ticks); + } + var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); return Convert.ToHexString(bytes); } diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 5c2959809eb..cb25a69d763 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -201,16 +201,13 @@ private async Task BuildIntegrationProjectAsync( // Resolve channel sources to add via RestoreAdditionalProjectSources IEnumerable? channelSources = null; - if (channelName is not null) + try { - try - { - channelSources = await GetNuGetSourcesAsync(channelName, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to configure NuGet sources for integration project build"); - } + channelSources = await GetNuGetSourcesAsync(channelName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to configure NuGet sources for integration project build"); } var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, outputDir, channelSources); From b682686ea8df6dd6aeb26098de8903104196399a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Mar 2026 23:18:43 -0800 Subject: [PATCH 26/26] Resolve project ref assembly names from MSBuild build output Add a custom MSBuild target to the synthetic project that writes resolved project reference assembly names to a file after build. Use these actual assembly names (which may differ from settings.json key or csproj filename if is overridden) for the AtsAssemblies list in appsettings.json. Move GenerateAppSettingsAsync to after build/restore in all cases for a simpler linear flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index cb25a69d763..ed3d1d63dce 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -107,9 +107,6 @@ public async Task PrepareAsync( try { - // Generate appsettings.json with ATS assemblies for the server to scan - await GenerateAppSettingsAsync(integrationList, cancellationToken); - // Resolve the configured channel (local settings.json → global config fallback) var channelName = await ResolveChannelNameAsync(cancellationToken); @@ -135,6 +132,13 @@ public async Task PrepareAsync( packageRefs, channelName, cancellationToken); } + // Generate appsettings.json after build/restore so we can use actual assembly names + // from the build output (project references may have custom ) + var projectRefAssemblyNames = _integrationLibsPath is not null + ? await ReadProjectRefAssemblyNamesAsync(_integrationLibsPath, cancellationToken) + : []; + await GenerateAppSettingsAsync(packageRefs, projectRefAssemblyNames, cancellationToken); + return new AppHostServerPrepareResult( Success: true, Output: null, @@ -309,6 +313,18 @@ internal static string GenerateIntegrationProjectFile( doc.Root!.Add(new XElement("ItemGroup", projectRefs.Select(p => new XElement("ProjectReference", new XAttribute("Include", p.ProjectPath!))))); + + // Add a target that writes the resolved project reference assembly names to a file. + // This lets us discover the actual assembly names after build (which may differ from + // the settings.json key or csproj filename if is overridden). + doc.Root!.Add( + new XElement("Target", + new XAttribute("Name", "_WriteProjectRefAssemblyNames"), + new XAttribute("AfterTargets", "Build"), + new XElement("WriteLinesToFile", + new XAttribute("File", Path.Combine(outputDir, "_project-ref-assemblies.txt")), + new XAttribute("Lines", "@(_ResolvedProjectReferencePaths->'%(Filename)')"), + new XAttribute("Overwrite", "true")))); } return doc.ToString(); @@ -491,25 +507,48 @@ internal static string GenerateIntegrationProjectFile( /// public string GetInstanceIdentifier() => _appPath; + /// + /// Reads the project reference assembly names written by the MSBuild target during build. + /// + private async Task> ReadProjectRefAssemblyNamesAsync(string libsPath, CancellationToken cancellationToken) + { + var filePath = Path.Combine(libsPath, "_project-ref-assemblies.txt"); + if (!File.Exists(filePath)) + { + _logger.LogWarning("Project reference assembly names file not found at {Path}", filePath); + return []; + } + + var lines = await File.ReadAllLinesAsync(filePath, cancellationToken); + return lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()).ToList(); + } + private async Task GenerateAppSettingsAsync( - List integrations, + List packageRefs, + List projectRefAssemblyNames, CancellationToken cancellationToken) { - // Build the list of ATS assemblies (for [AspireExport] scanning) - // Skip SDK-only packages that don't have runtime DLLs var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var integration in integrations) + + foreach (var pkg in packageRefs) { - // Skip SDK packages that don't produce runtime assemblies - if (integration.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || - integration.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) + if (pkg.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + pkg.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) { continue; } - if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase)) + if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) + { + atsAssemblies.Add(pkg.Name); + } + } + + foreach (var name in projectRefAssemblyNames) + { + if (!atsAssemblies.Contains(name, StringComparer.OrdinalIgnoreCase)) { - atsAssemblies.Add(integration.Name); + atsAssemblies.Add(name); } }