From cf82ee0beb273080d6d304f19866f2423c812d06 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Sat, 16 May 2026 01:09:44 -0400 Subject: [PATCH 01/17] fix(cli): honor source for guest language package restore aspire new aspire-empty --language typescript --source --version parsed --source, but the empty AppHost TypeScript scaffolding path dropped it before the prebuilt AppHost restored Aspire.Hosting and TypeScript code-generation packages. The bundled restore then searched channel sources, missed the requested PR hive packages, and NuGet floated to a stale preview package set, which later failed with TypeLoadException when the generator loaded against the PR CLI's Aspire.TypeSystem. Flow TemplateInputs.Source into ScaffoldContext, pass it to IAppHostServerProject.PrepareAsync, and have PrebuiltAppHostServer add that source to package and closure restores. When a source override is present, use exact version ranges so restore fails rather than silently resolving a different Aspire prerelease. Fixes #17159 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/CliExecutionContext.cs | 2 +- src/Aspire.Cli/Packaging/PackagingService.cs | 2 +- .../DotNetBasedAppHostServerProject.cs | 3 +- .../Projects/IAppHostServerProject.cs | 4 +- .../Projects/PrebuiltAppHostServer.cs | 120 ++++++++++++++---- .../Scaffolding/IScaffoldingService.cs | 4 +- .../Scaffolding/ScaffoldingService.cs | 2 +- .../CliTemplateFactory.EmptyTemplate.cs | 3 +- .../Projects/AppHostServerSessionTests.cs | 3 +- .../Projects/PrebuiltAppHostServerTests.cs | 55 +++++++- .../Scaffolding/ChannelReseedTests.cs | 59 ++++++++- .../FakeFailingAppHostServerProject.cs | 3 +- 12 files changed, 224 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index ab98a0ed6fc..dfc5aea8473 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -121,4 +121,4 @@ public int GetHiveCount() return HivesDirectory.GetDirectories().Length; } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index f3121672647..be727a53308 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -37,7 +37,7 @@ public Task> GetChannelsAsync(CancellationToken canc // Cannot use HiveDirectory.Exists here because it blows up on the // intermediate directory structure which may not exist in some - // contexts (e.g. in our Codespace where we have the CLI on the + // contexts (e.g. in our Codespace where we have the CLI on the // path but not in the $HOME/.aspire/bin folder). if (executionContext.HivesDirectory.Exists) { diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 67540c8ddfa..5541f7e596e 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -417,7 +417,8 @@ private XDocument CreateProjectFile(IEnumerable integratio public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) { var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken); var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index a5fc0730826..e3cba326b6f 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -41,11 +41,13 @@ internal interface IAppHostServerProject /// The Aspire SDK version to use. /// The integration references (NuGet packages and/or project references) required by the app host. /// Cancellation token. + /// Optional package source to prefer for Aspire package restore. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? packageSourceOverride = null); /// /// Runs the AppHost server process. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 63378ca0ea8..071227ac950 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -26,6 +26,8 @@ namespace Aspire.Cli.Projects; /// internal sealed class PrebuiltAppHostServer : IAppHostServerProject { + private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + internal const string ClosureMetadataFileName = "closure-metadata.txt"; internal const string ClosureSourcesFileName = "closure-sources.txt"; internal const string ClosureTargetsFileName = "closure-targets.txt"; @@ -123,7 +125,8 @@ public string GetServerPath() public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) { var integrationList = integrations.ToList(); var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); @@ -160,6 +163,7 @@ public async Task PrepareAsync( packageRefs, projectRefs, requestedChannel, + packageSourceOverride, cancellationToken).ConfigureAwait(false); if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked)) @@ -185,7 +189,7 @@ await IntegrationPackageProbeManifest.WriteAsync( { // NuGet-only — use the bundled NuGet service (no SDK required) _integrationProbeManifestPath = await RestoreNuGetPackagesAsync( - packageRefs, requestedChannel, cancellationToken); + packageRefs, requestedChannel, packageSourceOverride, cancellationToken); } var appSettingsContent = CreateAppSettingsContent(packageRefs, []); @@ -226,13 +230,17 @@ await IntegrationPackageProbeManifest.WriteAsync( private async Task RestoreNuGetPackagesAsync( List packageRefs, string? requestedChannel, + string? packageSourceOverride, CancellationToken cancellationToken) { _logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count); - var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList(); - using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, cancellationToken); - var sources = await GetNuGetSourcesAsync(requestedChannel, cancellationToken); + var useExactPackageVersions = !string.IsNullOrWhiteSpace(packageSourceOverride); + var packages = packageRefs + .Select(r => (r.Name, Version: GetRestoreVersion(r.Version!, useExactPackageVersions))) + .ToList(); + using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken); + var sources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken); return await _nugetService.RestorePackagesAsync( packages, @@ -253,13 +261,19 @@ private async Task BuildIntegrationClosureManifest List packageRefs, List projectRefs, string? requestedChannel, + string? packageSourceOverride, CancellationToken cancellationToken) { var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); Directory.CreateDirectory(restoreDir); - var channelSources = await GetNuGetSourcesAsync(requestedChannel, cancellationToken); - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, restoreDir, channelSources); + var channelSources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken); + var projectContent = GenerateIntegrationProjectFile( + packageRefs, + projectRefs, + restoreDir, + channelSources, + useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride)); var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -354,7 +368,8 @@ internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, string restoreDir, - IEnumerable? additionalSources = null) + IEnumerable? additionalSources = null, + bool useExactPackageVersions = false) { var propertyGroup = new XElement("PropertyGroup", new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), @@ -394,7 +409,7 @@ internal static string GenerateIntegrationProjectFile( } return new XElement("PackageReference", new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version)); + new XAttribute("Version", GetRestoreVersion(p.Version, useExactPackageVersions))); }))); } @@ -461,26 +476,18 @@ internal static string GenerateIntegrationProjectFile( /// /// Gets NuGet sources from the resolved channel for bundled restore. /// - private async Task?> GetNuGetSourcesAsync(string? requestedChannel, CancellationToken cancellationToken) + private async Task?> GetNuGetSourcesAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { var sources = new List(); - try + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) { - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - - IEnumerable explicitChannels; - if (!string.IsNullOrEmpty(requestedChannel)) - { - var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); - explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); - } - else - { - explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); - } + sources.Add(packageSourceOverride); + } - foreach (var channel in explicitChannels) + try + { + foreach (var channel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) { if (channel.Mappings is null) { @@ -501,11 +508,52 @@ internal static string GenerateIntegrationProjectFile( _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); } + if (!string.IsNullOrWhiteSpace(packageSourceOverride) && sources.Count == 1) + { + sources.Add(NuGetOrgSource); + } + return sources.Count > 0 ? sources : null; } - private async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, CancellationToken cancellationToken) + private async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) + { + var mappings = new List + { + new("Aspire*", packageSourceOverride) + }; + var configureGlobalPackagesFolder = false; + + try + { + foreach (var restoreChannel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) + { + if (restoreChannel.Mappings is null) + { + continue; + } + + mappings.AddRange(restoreChannel.Mappings); + configureGlobalPackagesFolder |= restoreChannel.ConfigureGlobalPackagesFolder; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config"); + } + + if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)) + { + mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)); + } + + return await TemporaryNuGetConfig.CreateAsync( + [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")], + configureGlobalPackagesFolder); + } + if (string.IsNullOrEmpty(requestedChannel)) { return null; @@ -541,6 +589,28 @@ internal static string GenerateIntegrationProjectFile( return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder); } + private async Task> GetExplicitRestoreChannelsAsync(string? requestedChannel, CancellationToken cancellationToken) + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + if (!string.IsNullOrEmpty(requestedChannel)) + { + var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); + return matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + } + + return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + } + + private static string GetRestoreVersion(string version, bool useExactPackageVersions) + { + if (!useExactPackageVersions || version.Length == 0 || version[0] is '[' or '(') + { + return version; + } + + return $"[{version}]"; + } + /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, diff --git a/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs index 2ad51d9943d..dbd5ed0bb90 100644 --- a/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs @@ -13,12 +13,14 @@ namespace Aspire.Cli.Scaffolding; /// Optional project name. /// Optional Aspire SDK version to use for scaffolding. /// Optional Aspire channel to use for scaffolding. +/// Optional package source to prefer when restoring scaffold/code-generation packages. internal record ScaffoldContext( LanguageInfo Language, DirectoryInfo TargetDirectory, string? ProjectName = null, string? SdkVersion = null, - string? Channel = null); + string? Channel = null, + string? PackageSourceOverride = null); /// /// Service for scaffolding new AppHost projects. diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index bf5f4a17b1f..af34060a682 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -97,7 +97,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can var prepareResult = await _interactionService.ShowStatusAsync( "Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken), + () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken, packageSourceOverride: context.PackageSourceOverride), emoji: KnownEmojis.Gear); if (!prepareResult.Success) { diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 30d7e2647ff..50b7eab6087 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -77,7 +77,8 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla new DirectoryInfo(outputPath), projectName, SdkVersion: inputs.Version, - Channel: inputs.Channel); + Channel: inputs.Channel, + PackageSourceOverride: inputs.Source); if (!await _scaffoldingService.ScaffoldAsync(context, cancellationToken)) { return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index de3e08ef937..e7d0de6fe4a 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -49,7 +49,8 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) => throw new NotSupportedException(); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 6969c3edea5..cd18b6da4c8 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -18,6 +18,8 @@ namespace Aspire.Cli.Tests.Projects; public class PrebuiltAppHostServerTests(ITestOutputHelper outputHelper) { + private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + [Fact] public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageReferences() { @@ -484,7 +486,7 @@ private static PrebuiltAppHostServer CreateServerWithChannel( System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var task = (Task)method.Invoke(server, [requestedChannel, CancellationToken.None])!; + var task = (Task)method.Invoke(server, [requestedChannel, null, CancellationToken.None])!; return await task; } @@ -589,6 +591,43 @@ public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifes } } + [Fact] + public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + List? restoreArgs = null; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Contains("--nuget-config", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithOnlyProjectReferences_SetsOnlyProjectLayout() { @@ -1177,6 +1216,20 @@ private static string GetWorkingDirectory(PrebuiltAppHostServer server) .GetValue(server)); } + private static string[] GetSourceArguments(IReadOnlyList args) + { + var sources = new List(); + for (var i = 0; i < args.Count - 1; i++) + { + if (args[i] == "--source") + { + sources.Add(args[i + 1]); + } + } + + return [.. sources]; + } + private static void DeleteWorkingDirectory(string workingDirectory) { if (Directory.Exists(workingDirectory)) diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 2aef7b05024..a3e12f9dabf 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -6,7 +6,9 @@ using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; namespace Aspire.Cli.Tests.Scaffolding; @@ -70,6 +72,36 @@ await Assert.ThrowsAnyAsync( Assert.Equal(expectedPersistedChannel, reloaded.Channel); } + [Fact] + public async Task ScaffoldAsync_PassesPackageSourceOverrideToPrepareAsync() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + var language = s_testLanguage with { PackageName = "Aspire.Hosting.CodeGeneration.TypeScript" }; + var appHostServerProject = new CapturingAppHostServerProject(workspace.WorkspaceRoot.FullName); + + var scaffoldingService = new ScaffoldingService( + appHostServerProjectFactory: new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject) + }, + languageDiscovery: new TestLanguageDiscovery(language), + interactionService: new TestInteractionService(), + logger: NullLogger.Instance); + + var context = new ScaffoldContext( + Language: language, + TargetDirectory: workspace.WorkspaceRoot, + ProjectName: "test", + SdkVersion: "13.4.0-pr.17141.gf142085f", + PackageSourceOverride: packageSourceOverride); + + var result = await scaffoldingService.ScaffoldAsync(context, CancellationToken.None); + + Assert.False(result); + Assert.Equal(packageSourceOverride, appHostServerProject.PackageSourceOverride); + } + private static readonly LanguageInfo s_testLanguage = new( LanguageId: new LanguageId(KnownLanguageId.TypeScript), DisplayName: "TypeScript", @@ -86,5 +118,30 @@ private static ScaffoldingService CreateScaffoldingService() interactionService: new TestInteractionService(), logger: NullLogger.Instance); } -} + private sealed class CapturingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject + { + public string AppDirectoryPath { get; } = appDirectoryPath; + + public string? PackageSourceOverride { get; private set; } + + public string GetInstanceIdentifier() => AppDirectoryPath; + + public Task PrepareAsync( + string sdkVersion, + IEnumerable integrations, + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) + { + PackageSourceOverride = packageSourceOverride; + return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); + } + + public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false) => + throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index b03c0c8db06..36799b62b9e 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -24,7 +24,8 @@ internal sealed class FakeFailingAppHostServerProject(string appDirectoryPath) : public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( From a49d604de452ff8aaee44e07a5f6d982c08c9ca0 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Sat, 16 May 2026 01:38:11 -0400 Subject: [PATCH 02/17] chore(cli): remove source restore diff noise Remove whitespace-only changes that are unrelated to the source restore fix, keeping the PR focused on the explicit package source propagation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/CliExecutionContext.cs | 2 +- src/Aspire.Cli/Packaging/PackagingService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index dfc5aea8473..ab98a0ed6fc 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -121,4 +121,4 @@ public int GetHiveCount() return HivesDirectory.GetDirectories().Length; } -} +} \ No newline at end of file diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index be727a53308..f3121672647 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -37,7 +37,7 @@ public Task> GetChannelsAsync(CancellationToken canc // Cannot use HiveDirectory.Exists here because it blows up on the // intermediate directory structure which may not exist in some - // contexts (e.g. in our Codespace where we have the CLI on the + // contexts (e.g. in our Codespace where we have the CLI on the // path but not in the $HOME/.aspire/bin folder). if (executionContext.HivesDirectory.Exists) { From 7ef60d431ca6e55be675eb4d52f658b1eb01409f Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Sat, 16 May 2026 02:13:27 -0400 Subject: [PATCH 03/17] fix(cli): constrain source override restore behavior When an explicit package source is passed to guest AppHost restore, keep the exact-version pinning scoped to Aspire packages because that is the source mapping being overridden. Non-Aspire integration packages should retain normal NuGet minimum-version restore semantics. Also apply the temporary NuGet.config to the project-reference closure restore path instead of only adding sources to the synthetic project. That keeps package source mapping and channel-specific restore settings active when package and project integrations are restored together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 32 +++-- .../Projects/PrebuiltAppHostServerTests.cs | 119 +++++++++++++++++- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 071227ac950..e2b76fef69e 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -237,7 +237,7 @@ private async Task RestoreNuGetPackagesAsync( var useExactPackageVersions = !string.IsNullOrWhiteSpace(packageSourceOverride); var packages = packageRefs - .Select(r => (r.Name, Version: GetRestoreVersion(r.Version!, useExactPackageVersions))) + .Select(r => (r.Name, Version: GetRestoreVersion(r.Name, r.Version!, useExactPackageVersions))) .ToList(); using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken); var sources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken); @@ -267,13 +267,17 @@ private async Task BuildIntegrationClosureManifest var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); Directory.CreateDirectory(restoreDir); - var channelSources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken); + using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken); + var channelSources = temporaryNuGetConfig is null + ? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken) + : null; var projectContent = GenerateIntegrationProjectFile( packageRefs, projectRefs, restoreDir, channelSources, - useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride)); + useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride), + restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName); var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -369,7 +373,8 @@ internal static string GenerateIntegrationProjectFile( List projectRefs, string restoreDir, IEnumerable? additionalSources = null, - bool useExactPackageVersions = false) + bool useExactPackageVersions = false, + string? restoreConfigFile = null) { var propertyGroup = new XElement("PropertyGroup", new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), @@ -383,8 +388,12 @@ internal static string GenerateIntegrationProjectFile( new XElement("AspireClosureTargetsFile", Path.Combine(restoreDir, ClosureTargetsFileName)), new XElement("AspireProjectRefAssemblyNamesFile", Path.Combine(restoreDir, ProjectRefAssemblyNamesFileName))); - // Add channel sources without replacing the user's nuget.config - if (additionalSources is not null) + if (!string.IsNullOrWhiteSpace(restoreConfigFile)) + { + propertyGroup.Add(new XElement("RestoreConfigFile", restoreConfigFile)); + } + // Add channel sources without replacing the user's nuget.config. + else if (additionalSources is not null) { var sourceList = string.Join(";", additionalSources); if (sourceList.Length > 0) @@ -409,7 +418,7 @@ internal static string GenerateIntegrationProjectFile( } return new XElement("PackageReference", new XAttribute("Include", p.Name), - new XAttribute("Version", GetRestoreVersion(p.Version, useExactPackageVersions))); + new XAttribute("Version", GetRestoreVersion(p.Name, p.Version, useExactPackageVersions))); }))); } @@ -601,9 +610,9 @@ private async Task> GetExplicitRestoreChannelsAsync( return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); } - private static string GetRestoreVersion(string version, bool useExactPackageVersions) + private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions) { - if (!useExactPackageVersions || version.Length == 0 || version[0] is '[' or '(') + if (!ShouldUseExactPackageVersion(packageName, useExactPackageVersions) || version.Length == 0 || version[0] is '[' or '(') { return version; } @@ -611,6 +620,11 @@ private static string GetRestoreVersion(string version, bool useExactPackageVers return $"[{version}]"; } + private static bool ShouldUseExactPackageVersion(string packageName, bool useExactPackageVersions) + { + return useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + } + /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index cd18b6da4c8..3e78ae23f5a 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -171,6 +171,26 @@ public void GenerateIntegrationProjectFile_WithAdditionalSources_SetsRestoreAddi Assert.Contains("https://my-feed/v3/index.json", restoreSources); } + [Fact] + public void GenerateIntegrationProjectFile_WithRestoreConfigFile_SetsRestoreConfigFile() + { + var sources = new[] { "/local/packages", "https://my-feed/v3/index.json" }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile( + [], + [], + "/tmp/libs", + sources, + restoreConfigFile: "/tmp/nuget.config"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var restoreConfigFile = doc.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + var restoreSources = doc.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault(); + Assert.Equal("/tmp/nuget.config", restoreConfigFile); + Assert.Null(restoreSources); + } + [Fact] public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_DoesNotSetRestoreAdditionalProjectSources() { @@ -182,6 +202,27 @@ public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_DoesNotSet Assert.Null(restoreSources); } + [Fact] + public void GenerateIntegrationProjectFile_WithExactVersions_ExactPinsOnlyAspirePackages() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17166.ga49d604d"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile( + packageRefs, + [], + "/tmp/libs", + useExactPackageVersions: true); + var doc = XDocument.Parse(xml); + + var packageElements = doc.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "[13.4.0-pr.17166.ga49d604d]"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "CommunityToolkit.Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "1.0.0"); + } + [Fact] public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory() { @@ -613,13 +654,17 @@ public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() { var result = await server.PrepareAsync( "13.4.0-pr.17141.gf142085f", - [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")], + [ + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + ], packageSourceOverride: packageSourceOverride); Assert.True(result.Success); Assert.NotNull(restoreArgs); Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); Assert.Contains("--nuget-config", restoreArgs!); } finally @@ -628,6 +673,78 @@ public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() } } + [Fact] + public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesNuGetConfig() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + XDocument? generatedProject = null; + bool restoreConfigFileExistedDuringBuild = false; + + var closureFiles = new Dictionary(StringComparer.Ordinal) + { + ["MyIntegration.dll"] = "integration-v1" + }; + var dotNetCliRunner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFilePath, _, _, _) => + { + generatedProject = XDocument.Load(projectFilePath.FullName); + var ns = generatedProject.Root!.GetDefaultNamespace(); + var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + restoreConfigFileExistedDuringBuild = restoreConfigFile is not null && File.Exists(restoreConfigFile); + WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]); + return 0; + } + }; + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + dotNetCliRunner, + new TestDotNetSdkInstaller(), + MockPackagingServiceFactory.Create(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17166.ga49d604d", + [ + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17166.ga49d604d"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + ], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.NotNull(generatedProject); + + var ns = generatedProject.Root!.GetDefaultNamespace(); + var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + Assert.NotNull(restoreConfigFile); + Assert.True(restoreConfigFileExistedDuringBuild); + Assert.Null(generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()); + + var packageElements = generatedProject.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "[13.4.0-pr.17166.ga49d604d]"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "CommunityToolkit.Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "1.0.0"); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithOnlyProjectReferences_SetsOnlyProjectLayout() { From 9b6d7059099a585e0974142388fed32e1bdc1e51 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Mon, 18 May 2026 23:06:00 -0400 Subject: [PATCH 04/17] fix(cli): keep --source override exclusive for Aspire packages When `aspire new aspire-empty --source ` ran without an explicit `--channel`, the temp NuGet.config built for restore folded in every explicit channel's `Aspire* -> channelSource` mapping alongside the override's `Aspire* -> packageSourceOverride`. NuGet treats same-pattern mappings on multiple sources as co-eligible, so Aspire packages could still resolve from a channel feed and silently defeat the override's fail-fast intent. Exact-version pinning masked this in practice because the requested PR-hive version was usually unique, but the package source mapping itself was no longer exclusive to the override. In the override branch of `TryCreateTemporaryNuGetConfigAsync`, only fold in mappings from an explicitly-requested, matched channel (skip the catch-all "all explicit channels" fallback baked into `GetExplicitRestoreChannelsAsync`), and drop any `Aspire*`-prefixed mappings from that matched channel before merging. Non-Aspire patterns (`CommunityToolkit*`, catch-all `*`) are preserved so non-Aspire transitives keep their channel feeds. Mirror the same gating in `GetNuGetSourcesAsync` so the bundled NuGet service's `sources` list doesn't broadcast every channel feed when `--source` is the override mechanism. Add seven `TryCreateTemporaryNuGetConfig_*` test cases covering the override-with-channels matrix (no channel, matched channel, channel with `Aspire*` mapping, channel with all-packages mapping, lookup failure, requested-channel threading) plus a `PrepareAsync_*` integration check for the NuGet.org fallback. `TestPackagingService` gains a `LastRequestedChannelName` observable so the new `PassesRequestedChannelToPackagingService` test can assert the override branch threads `requestedChannel` into the packaging service. Touch the comments near `NuGetOrgSource` and the `RestoreConfigFile`/`RestoreAdditionalProjectSources` split so they describe the actual constraint ("cannot float to NuGet.org or any other co-eligible feed"). Refs #17159 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 66 +++-- .../Projects/PrebuiltAppHostServerTests.cs | 234 +++++++++++++++++- .../TestServices/TestPackagingService.cs | 3 + 3 files changed, 285 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 3dfdc1fb17e..df8f1d0cc81 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -26,8 +26,8 @@ namespace Aspire.Cli.Projects; /// internal sealed class PrebuiltAppHostServer : IAppHostServerProject { - // An explicit PR/local source only contains Aspire packages. Keep NuGet.org available - // for non-Aspire packages and transitive dependencies that are outside the Aspire* mapping. + // An explicit source is preferred for Aspire packages. Keep NuGet.org available for + // non-Aspire packages and transitive dependencies that are outside the Aspire* mapping. private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; internal const string ClosureMetadataFileName = "closure-metadata.txt"; @@ -392,6 +392,9 @@ internal static string GenerateIntegrationProjectFile( if (!string.IsNullOrWhiteSpace(restoreConfigFile)) { + // RestoreAdditionalProjectSources can add feeds, but it cannot carry package source + // mappings. Use the temp NuGet.config so Aspire* packages stay pinned to the + // explicit source while non-Aspire dependencies can use fallback sources. propertyGroup.Add(new XElement("RestoreConfigFile", restoreConfigFile)); } // Add channel sources without replacing the user's nuget.config. @@ -498,7 +501,16 @@ internal static string GenerateIntegrationProjectFile( try { - foreach (var channel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) + // When --source is set without a specific channel, do NOT fold in every explicit + // channel's sources: each built-in channel contributes its own Aspire* feed, and + // letting all of them through would give NuGet multiple co-eligible sources for + // Aspire packages and silently defeat the override. The temp NuGet.config below + // emits PSM that constrains Aspire packages to the override; this list only needs + // the override (plus a NuGet.org fallback) for non-Aspire transitives. + var channels = !string.IsNullOrWhiteSpace(packageSourceOverride) && string.IsNullOrEmpty(requestedChannel) + ? [] + : await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken); + foreach (var channel in channels) { if (channel.Mappings is null) { @@ -521,6 +533,9 @@ internal static string GenerateIntegrationProjectFile( if (!string.IsNullOrWhiteSpace(packageSourceOverride) && sources.Count == 1) { + // Keep NuGet.org available for unrelated packages and transitives; the temp + // NuGet.config maps Aspire* exclusively to the explicit source so Aspire packages + // cannot float to NuGet.org or any other co-eligible feed. sources.Add(NuGetOrgSource); } @@ -531,6 +546,9 @@ internal static string GenerateIntegrationProjectFile( { if (!string.IsNullOrWhiteSpace(packageSourceOverride)) { + // Treat an explicit --source value as the preferred source for Aspire packages. + // Build a temporary NuGet.config that routes Aspire* there, optionally preserves + // non-Aspire channel mappings, and leaves a fallback source for non-Aspire deps. var mappings = new List { new("Aspire*", packageSourceOverride) @@ -539,15 +557,32 @@ internal static string GenerateIntegrationProjectFile( try { - foreach (var restoreChannel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) + // Only fold in mappings from an explicitly-requested, matched channel. Falling + // back to "all explicit channels" here would pull in every built-in channel's + // Aspire* mapping pointing at its own feed; NuGet would treat all of them as + // co-eligible sources for Aspire packages and silently defeat the override. + if (!string.IsNullOrEmpty(requestedChannel)) { - if (restoreChannel.Mappings is null) + var packageChannels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + var matchedChannel = packageChannels.FirstOrDefault(c => + string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); + if (matchedChannel?.Mappings is not null) { - continue; + foreach (var mapping in matchedChannel.Mappings) + { + // Drop any Aspire-prefixed mapping — the --source override owns + // Aspire restoration exclusively. Non-Aspire patterns (e.g. + // CommunityToolkit*, catch-all *) are preserved. + if (mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + mappings.Add(mapping); + } + + configureGlobalPackagesFolder |= matchedChannel.ConfigureGlobalPackagesFolder; } - - mappings.AddRange(restoreChannel.Mappings); - configureGlobalPackagesFolder |= restoreChannel.ConfigureGlobalPackagesFolder; } } catch (Exception ex) @@ -606,7 +641,10 @@ private async Task> GetExplicitRestoreChannelsAsync( if (!string.IsNullOrEmpty(requestedChannel)) { var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); - return matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + if (matchingChannel is not null) + { + return [matchingChannel]; + } } return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); @@ -614,7 +652,8 @@ private async Task> GetExplicitRestoreChannelsAsync( private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions) { - if (!ShouldUseExactPackageVersion(packageName, useExactPackageVersions) || version.Length == 0 || version[0] is '[' or '(') + var shouldUseExactAspirePackageVersion = useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + if (!shouldUseExactAspirePackageVersion || version.Length == 0 || version[0] is '[' or '(') { return version; } @@ -622,11 +661,6 @@ private static string GetRestoreVersion(string packageName, string version, bool return $"[{version}]"; } - private static bool ShouldUseExactPackageVersion(string packageName, bool useExactPackageVersions) - { - return useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); - } - /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 7a0dbde1c0e..28de517dad0 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -466,6 +466,178 @@ public async Task TryCreateTemporaryNuGetConfig_LocalRequested_ReturnsNull_Regar Assert.Null(result); } + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_MapsAspireToOverrideAndAddsNuGetOrgFallback() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([]) + }; + var server = CreateServerWithPackagingService(workspace, packagingService); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: null, + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverrideWithoutRequestedChannel_DoesNotMergeExplicitChannelAspireMappings() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var explicitChannel = PackageChannel.CreateExplicitChannel( + name: "daily", + quality: PackageChannelQuality.Both, + mappings: + [ + new PackageMapping("Aspire*", channelSource), + new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) + ], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, explicitChannel, CreateContextWithIdentityChannel("pr-12345")); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: null, + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Empty(GetPackagePatternsForSource(doc, channelSource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_PreservesRequestedChannelMappingsAndGlobalPackagesFolder() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("CommunityToolkit*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache(), + configureGlobalPackagesFolder: true); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: "staging", + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, channelSource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); + Assert.NotNull(doc.Descendants("config") + .SelectMany(c => c.Elements("add")) + .FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase))); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_DropsRequestedChannelAspireMappings() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: "staging", + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Empty(GetPackagePatternsForSource(doc, channelSource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_PassesRequestedChannelToPackagingService() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([]) + }; + var server = CreateServerWithPackagingService(workspace, packagingService); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: PackageChannelNames.Staging, + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + Assert.Equal(PackageChannelNames.Staging, packagingService.LastRequestedChannelName); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_UsesChannelAllPackagesMappingAsFallback() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: "staging", + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, channelSource)); + Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource)); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_WhenChannelLookupFails_StillCreatesOverrideConfigWithFallback() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup failed.") + }; + var server = CreateServerWithPackagingService(workspace, packagingService); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( + server, + requestedChannel: "staging", + packageSourceOverride: packageSourceOverride); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); + } + private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => new(new DirectoryInfo(Path.GetTempPath()), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), @@ -501,6 +673,15 @@ private static PrebuiltAppHostServer CreateServerWithChannel( GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; + return CreateServerWithPackagingService(workspace, packagingService, executionContext); + } + + private static PrebuiltAppHostServer CreateServerWithPackagingService( + TemporaryWorkspace workspace, + IPackagingService packagingService, + CliExecutionContext? executionContext = null) + { + executionContext ??= TestExecutionContextFactory.CreateTestContext(); var nugetService = new BundleNuGetService( new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), @@ -521,14 +702,16 @@ private static PrebuiltAppHostServer CreateServerWithChannel( } private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( - PrebuiltAppHostServer server, string requestedChannel) + PrebuiltAppHostServer server, + string? requestedChannel, + string? packageSourceOverride = null) { var method = typeof(PrebuiltAppHostServer).GetMethod( "TryCreateTemporaryNuGetConfigAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var task = (Task)method.Invoke(server, [requestedChannel, null, CancellationToken.None])!; + var task = (Task)method.Invoke(server, [requestedChannel, packageSourceOverride, CancellationToken.None])!; return await task; } @@ -674,6 +857,44 @@ public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() } } + [Fact] + public async Task PrepareAsync_WithPackageSourceOverride_AddsNuGetOrgFallbackSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + List? restoreArgs = null; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [ + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + ], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesNuGetConfig() { @@ -1479,6 +1700,15 @@ private static string[] GetSourceArguments(IReadOnlyList args) return [.. sources]; } + private static string[] GetPackagePatternsForSource(XDocument doc, string source) + { + return [.. doc.Descendants("packageSource") + .Where(e => string.Equals(e.Attribute("key")?.Value, source, StringComparison.OrdinalIgnoreCase)) + .Elements("package") + .Select(e => e.Attribute("pattern")?.Value) + .OfType()]; + } + private static void DeleteWorkingDirectory(string workingDirectory) { if (Directory.Exists(workingDirectory)) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index c7d9ee625fc..490170d45aa 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -8,9 +8,12 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestPackagingService : IPackagingService { public Func>>? GetChannelsAsyncCallback { get; set; } + public string? LastRequestedChannelName { get; private set; } public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { + LastRequestedChannelName = requestedChannelName; + if (GetChannelsAsyncCallback is not null) { return GetChannelsAsyncCallback(cancellationToken); From c3b0183dd1c130ed69ec383f217711b6819c3958 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 00:02:35 -0400 Subject: [PATCH 05/17] fix(cli): warn that aspire-empty --source is one-shot at scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `aspire new aspire-empty --language --source ` succeeds at scaffold time but the override is consumed only for the initial restore inside `PrebuiltAppHostServer`. The scaffolded project persists only the channel and SDK version, so a follow-up `aspire add` or `aspire restore` in the same project resolves Aspire packages from the channel feeds in `aspire.config.json` rather than `` — and silently produces a different package set, or fails when the channel does not carry the requested version. Emit a yellow warning immediately after the scaffold succeeds (when `inputs.Source` is non-empty on the non-C# branch) so users supplying `--source /packages` are not surprised when subsequent commands miss the override. Persisting the feed into a generated `nuget.config` (and also honoring `--source` on the C# empty path, which silently drops it today) is left as a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resources/TemplatingStrings.Designer.cs | 9 +++ .../Resources/TemplatingStrings.resx | 4 ++ .../Resources/xlf/TemplatingStrings.cs.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.de.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.es.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.fr.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.it.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.ja.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.ko.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.pl.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.pt-BR.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.ru.xlf | 5 ++ .../Resources/xlf/TemplatingStrings.tr.xlf | 5 ++ .../xlf/TemplatingStrings.zh-Hans.xlf | 5 ++ .../xlf/TemplatingStrings.zh-Hant.xlf | 5 ++ .../CliTemplateFactory.EmptyTemplate.cs | 15 ++++ .../Commands/NewCommandTests.cs | 69 +++++++++++++++++++ 17 files changed, 162 insertions(+) diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs index 11fb1542612..16ea554cdb5 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs @@ -483,6 +483,15 @@ public static string RunAspireRun { } } + /// + /// Looks up a localized string similar to [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/]. + /// + public static string EmptySourceOverrideNotPersistedWarning { + get { + return ResourceManager.GetString("EmptySourceOverrideNotPersistedWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Yes. /// diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.resx b/src/Aspire.Cli/Resources/TemplatingStrings.resx index 9867bec95b7..4c4ebb19893 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.resx +++ b/src/Aspire.Cli/Resources/TemplatingStrings.resx @@ -266,4 +266,8 @@ Run 'aspire run' to start your AppHost. + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf index 0572d5af797..3d6933da224 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf @@ -77,6 +77,11 @@ Vytváří se nový projekt Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Verze xUnit.net, která se má použít pro testovací projekt diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf index c15577a41cd..15391c4bc7f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf @@ -77,6 +77,11 @@ Neues Aspire-Projekt wird erstellt... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Die Version von xUnit.net, die für das Testprojekt verwendet werden soll. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf index 9155ce129e2..3677e84e9f7 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf @@ -77,6 +77,11 @@ Creando un nuevo proyecto de Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. La versión de xUnit.net que se utilizará para el proyecto de prueba. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf index 5fadfefb148..6c23dfc6b47 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf @@ -77,6 +77,11 @@ Création d’un nouveau projet Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Version de xUnit.net à utiliser pour le projet de test. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf index 0fbda9abc4e..55adab87f4f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf @@ -77,6 +77,11 @@ Creazione del nuovo progetto Aspire in corso... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Versione di xUnit.net da usare per il progetto di test. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf index 3c395bf247b..e3da2008c20 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf @@ -77,6 +77,11 @@ 新しい Aspire プロジェクトを作成しています... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. テスト プロジェクトのために使用する xUnit.net のバージョン。 diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf index 13bee1e4080..87d4425668a 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf @@ -77,6 +77,11 @@ 새 Aspire 프로젝트를 만드는 중... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. 테스트 프로젝트에 사용할 xUnit.net 버전입니다. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf index 6c250dfd93d..2c8a0e07d06 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf @@ -77,6 +77,11 @@ Trwa tworzenie nowego projektu Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Wersja xUnit.net do użycia w projekcie testowym. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf index 05eda21b653..50b0f6f1edf 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf @@ -77,6 +77,11 @@ Criando novo projeto Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. A versão do xUnit.net a ser usada para o projeto de teste. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf index 19f4a0fec5c..8298c560e2c 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf @@ -77,6 +77,11 @@ Создается новый проект Aspire... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Версия xUnit.net, которую следует использовать для тестового проекта. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf index b437151e0ad..6e2cf3530a0 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf @@ -77,6 +77,11 @@ Yeni Aspire projesi oluşturuluyor... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. Test projesi için kullanılacak xUnit.net sürümü. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf index e9a78da28be..0691af916d1 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf @@ -77,6 +77,11 @@ 正在创建新的 Aspire 项目... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. 要用于测试项目的 xUnit.net 版本。 diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf index 1db6a926d8b..7299934c914 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf @@ -77,6 +77,11 @@ 正在建立新的 Aspire 專案... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + The version of xUnit.net to use for the test project. 測試專案要使用的 xUnit.net 版本。 diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 896e84f1dc3..101baea71c5 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -88,6 +88,21 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla { await ApplyLocalhostTldToScaffoldedRunProfileAsync(outputPath, projectName, cancellationToken); } + + // The override is consumed only during the initial scaffold restore inside + // PrebuiltAppHostServer; nothing in the scaffolded project records the + // source, so a later `aspire restore`/`aspire add` will use the channel + // feeds resolved from aspire.config.json. Surface that so users supplying + // `--source /packages` aren't surprised when subsequent commands + // fail to find the same packages. Persisting the feed is tracked as a + // follow-up. + if (!string.IsNullOrWhiteSpace(inputs.Source)) + { + _interactionService.DisplayMessage( + KnownEmojis.Warning, + TemplatingStrings.EmptySourceOverrideNotPersistedWarning, + allowMarkup: true); + } } return new TemplateResult((int)CliExitCodes.Success, outputPath); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 7ae92a31842..d47aab1f556 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1151,6 +1151,75 @@ public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCrea Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.ts"))); } + [Fact] + public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideIsNotPersisted() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + string? capturedPackageSourceOverride = null; + TestInteractionService? interactionService = null; + + var services = CreateServiceCollection(workspace, options => + { + options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + }); + + services.AddSingleton(new TestScaffoldingService + { + ScaffoldAsyncCallback = (context, _) => + { + capturedPackageSourceOverride = context.PackageSourceOverride; + File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost"); + return Task.FromResult(true); + } + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source {sourceOverride}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(sourceOverride, capturedPackageSourceOverride); + Assert.NotNull(interactionService); + Assert.Contains( + interactionService!.DisplayedMessages, + entry => entry.Emoji.Name == KnownEmojis.Warning.Name + && entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + } + + [Fact] + public async Task NewCommandWithEmptyTemplateWithoutSourceOverrideDoesNotWarn() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + TestInteractionService? interactionService = null; + + var services = CreateServiceCollection(workspace, options => + { + options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + }); + + services.AddSingleton(new TestScaffoldingService + { + ScaffoldAsyncCallback = (context, _) => + { + File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost"); + return Task.FromResult(true); + } + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(interactionService); + Assert.DoesNotContain( + interactionService!.DisplayedMessages, + entry => entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + } + [Fact] public async Task NewCommandWithExplicitJavaEmptyTemplateCreatesJavaAppHost() { From 7518348eecaec433d557548fffaa2ec3041e3fc8 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 00:02:35 -0400 Subject: [PATCH 06/17] fix(cli): include --source/channel context in scaffold restore failures When the prebuilt AppHost scaffold restore fails, the displayed output is the only debugging surface most users see. Previously it carried only the raw NuGet stderr ("Failed to prepare: Package restore failed: ..."), with no record of which `--source`, channel, or package versions had been in play. Reproducing the failure required a verbose re-run with diagnostic logging just to recover the inputs. Append the override source, the requested channel, and a short preview of the package list to the `OutputCollector` from both of `PrepareAsync`'s catch blocks (`AppHostServerPrepareFailedException` and the catch-all wrapper around `RestoreNuGetPackagesAsync`). When neither `--source` nor a channel was specified the helper is a no-op, so existing failure messages without these inputs are unchanged. Add an end-to-end `PrepareAsync` test that wires `--source` together with a channel whose `Aspire*` mapping conflicts with the override and asserts the temp `nuget.config` actually passed to the restore invocation drops the channel's `Aspire*` mapping, pinning that the override is authoritative for `Aspire*` packages end-to-end (and not only at the temp-config generator unit boundary). Add a `PrepareAsync_RestoreFailure_OutputIncludesSourceAndChannelContext` test that fails the restore via a non-zero exit and asserts the override path, channel name, and package id are present in the returned output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 36 +++++ .../Projects/PrebuiltAppHostServerTests.cs | 137 +++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index df8f1d0cc81..4bbc57dd156 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -207,6 +207,7 @@ await IntegrationPackageProbeManifest.WriteAsync( catch (AppHostServerPrepareFailedException ex) { _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); + AppendRestoreContextOnFailure(ex.Output, requestedChannel, packageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: ex.Output, @@ -218,6 +219,7 @@ await IntegrationPackageProbeManifest.WriteAsync( _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); var output = new OutputCollector(); output.AppendError($"Failed to prepare: {ex.Message}"); + AppendRestoreContextOnFailure(output, requestedChannel, packageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: output, @@ -226,6 +228,40 @@ await IntegrationPackageProbeManifest.WriteAsync( } } + // Augment the failure output with the source / channel / requested versions so a user looking + // at the displayed error after `aspire new --source ` can immediately see which inputs were + // in play, instead of having to re-run with diagnostic logging. Called from both prepare + // failure paths so every restore failure surfaces the same context shape. + private static void AppendRestoreContextOnFailure( + OutputCollector output, + string? requestedChannel, + string? packageSourceOverride, + IReadOnlyList packageRefs) + { + var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride); + var hasChannel = !string.IsNullOrEmpty(requestedChannel); + if (!hasOverride && !hasChannel) + { + return; + } + + if (hasOverride) + { + output.AppendError($" --source: {packageSourceOverride}"); + } + + if (hasChannel) + { + output.AppendError($" channel: {requestedChannel}"); + } + + if (packageRefs.Count > 0) + { + var preview = packageRefs.Take(5).Select(static r => $"{r.Name} {r.Version}"); + output.AppendError($" packages: {string.Join(", ", preview)}{(packageRefs.Count > 5 ? $", … (+{packageRefs.Count - 5} more)" : string.Empty)}"); + } + } + /// /// Restores NuGet packages using the bundled NuGet service (no .NET SDK required). /// diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 28de517dad0..54a61d192e2 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -895,6 +895,134 @@ public async Task PrepareAsync_WithPackageSourceOverride_AddsNuGetOrgFallbackSou } } + [Fact] + public async Task PrepareAsync_WithSourceAndChannelHavingAspireMapping_TempConfigDropsChannelAspireMapping() + { + // End-to-end check that `aspire new --source --channel ` does not let the channel's + // Aspire* feed remain co-eligible with the override at restore time. The unit-level + // TryCreateTemporaryNuGetConfig_* cases pin the generator; this case pins that PrepareAsync + // wires that same temp config through to the actual restore invocation. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var dailyChannel = PackageChannel.CreateExplicitChannel( + name: "daily", + quality: PackageChannelQuality.Both, + mappings: + [ + new PackageMapping("Aspire*", channelSource), + new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) + ], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + XDocument? tempConfigDoc = null; + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + // Read the temp NuGet.config while it still exists; it is disposed when + // PrepareAsync's inner `using var` exits, which races with our assertions. + var argsList = (IReadOnlyList)args; + var nugetConfigIndex = -1; + for (var i = 0; i < argsList.Count - 1; i++) + { + if (argsList[i] == "--nuget-config") + { + nugetConfigIndex = i; + break; + } + } + + if (nugetConfigIndex >= 0) + { + tempConfigDoc = XDocument.Load(argsList[nugetConfigIndex + 1]); + } + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.Equal("daily", result.ChannelName); + Assert.NotNull(tempConfigDoc); + + // The temp config is the authoritative PSM gate. Verify the channel's Aspire* mapping + // was dropped — only the override serves Aspire packages. + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(tempConfigDoc!, packageSourceOverride)); + Assert.Empty(GetPackagePatternsForSource(tempConfigDoc!, channelSource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(tempConfigDoc!, NuGetOrgSource)); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_RestoreFailure_OutputIncludesSourceAndChannelContext() + { + // When restore fails, the displayed output is the only debugging surface most users see. + // Pin that --source and the requested channel are present so a failed + // `aspire new --source --channel ` doesn't require re-running with diagnostic logs + // just to recover which inputs were in play. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + // Fail the restore step itself; BundleNuGetService throws on non-zero exit which + // propagates through PrepareAsync's outer catch. + executionFactory.DefaultExitCode = 1; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")], + packageSourceOverride: packageSourceOverride); + + Assert.False(result.Success); + Assert.NotNull(result.Output); + + var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line)); + Assert.Contains(packageSourceOverride, combined); + Assert.Contains("daily", combined); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", combined); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesNuGetConfig() { @@ -1514,6 +1642,13 @@ private static PrebuiltAppHostServer CreateProjectReferenceServer( } private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace) + { + return CreatePackageReferenceServer(workspace, MockPackagingServiceFactory.Create()); + } + + private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer( + TemporaryWorkspace workspace, + IPackagingService packagingService) { var layout = CreateBundleLayout(workspace); var executionFactory = new TestProcessExecutionFactory(); @@ -1531,7 +1666,7 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut nugetService, new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), + packagingService, TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); From b59e2a0b254d43630d9c60e3e23b684d3aacb0f8 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 00:31:45 -0400 Subject: [PATCH 07/17] fix(cli): honor --source override for guest-language starter templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aspire new aspire-{ts,py,go}-starter --source --version ` hit the same TypeLoadException class of failure as `aspire-empty` did before the fix landed in this branch: the override was plumbed into PrebuiltAppHostServer.PrepareAsync for the empty-template path only, while starter templates went through GuestAppHostProject.BuildAndGenerateSdkAsync → PrepareAppHostServerAsync without forwarding the override, so Aspire packages restored from channel feeds rather than the requested source. Thread `packageSourceOverride` through IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync and the GuestAppHostProject prepare helper, then pass `inputs.Source` from all three guest starter templates. Hoist the "override is not persisted" warning into a shared helper on CliTemplateFactory so the empty and starter paths emit the same message; the warning fires only after a successful scaffold so it doesn't add noise behind a more prominent restore failure. Tests: - Expand the empty-template warning test to a [Theory] covering TypeScript and Java (the latter behind the experimental polyglot flag). - Add starter-template coverage for both the warning+plumb-through happy path and the failed-restore-suppresses-warning path. - Pin the restore-failure context footer shape (`--source:`, `channel:`, `packages:` labels) and the >5-package truncation behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/GuestAppHostProject.cs | 13 +- .../Projects/IGuestAppHostSdkGenerator.cs | 3 +- .../Resources/TemplatingStrings.resx | 2 +- .../Resources/xlf/TemplatingStrings.cs.xlf | 2 +- .../Resources/xlf/TemplatingStrings.de.xlf | 2 +- .../Resources/xlf/TemplatingStrings.es.xlf | 2 +- .../Resources/xlf/TemplatingStrings.fr.xlf | 2 +- .../Resources/xlf/TemplatingStrings.it.xlf | 2 +- .../Resources/xlf/TemplatingStrings.ja.xlf | 2 +- .../Resources/xlf/TemplatingStrings.ko.xlf | 2 +- .../Resources/xlf/TemplatingStrings.pl.xlf | 2 +- .../Resources/xlf/TemplatingStrings.pt-BR.xlf | 2 +- .../Resources/xlf/TemplatingStrings.ru.xlf | 2 +- .../Resources/xlf/TemplatingStrings.tr.xlf | 2 +- .../xlf/TemplatingStrings.zh-Hans.xlf | 2 +- .../xlf/TemplatingStrings.zh-Hant.xlf | 2 +- .../CliTemplateFactory.EmptyTemplate.cs | 15 +- .../CliTemplateFactory.GoStarterTemplate.cs | 4 +- ...liTemplateFactory.PythonStarterTemplate.cs | 4 +- ...mplateFactory.TypeScriptStarterTemplate.cs | 4 +- .../Templating/CliTemplateFactory.cs | 17 +++ .../Commands/AddCommandTests.cs | 8 +- .../Commands/NewCommandTests.cs | 138 +++++++++++++++++- .../Projects/PrebuiltAppHostServerTests.cs | 48 +++++- .../TestTypeScriptStarterProjectFactory.cs | 13 +- 25 files changed, 240 insertions(+), 55 deletions(-) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 637c07ec806..7b8c055460c 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -224,9 +224,10 @@ private string GetPrepareSdkVersion(AspireConfigFile config) IAppHostServerProject appHostServerProject, string sdkVersion, List integrations, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? packageSourceOverride = null) { - var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken); + var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken, packageSourceOverride: packageSourceOverride); return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } @@ -234,7 +235,7 @@ private string GetPrepareSdkVersion(AspireConfigFile config) /// Builds the AppHost server project and generates SDK code. /// /// if the code was generated successfully; otherwise, . - internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null) { var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); @@ -243,7 +244,7 @@ internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Canc var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, cancellationToken, packageSourceOverride); if (!buildSuccess) { if (buildOutput is not null) @@ -280,9 +281,9 @@ await GenerateCodeViaRpcAsync( return true; } - Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride) { - return BuildAndGenerateSdkAsync(directory, cancellationToken); + return BuildAndGenerateSdkAsync(directory, cancellationToken, packageSourceOverride); } // ═══════════════════════════════════════════════════════════════ diff --git a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs index ba89749d4c7..854571a1ae2 100644 --- a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs +++ b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs @@ -13,6 +13,7 @@ internal interface IGuestAppHostSdkGenerator /// /// The AppHost project directory. /// A cancellation token. + /// Optional package source to prefer for Aspire package restore during the build. /// if SDK generation succeeded; otherwise, . - Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken); + Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null); } \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.resx b/src/Aspire.Cli/Resources/TemplatingStrings.resx index 4c4ebb19893..a1e713d62fe 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.resx +++ b/src/Aspire.Cli/Resources/TemplatingStrings.resx @@ -268,6 +268,6 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf index 3d6933da224..3baca977598 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf index 15391c4bc7f..249b3b77659 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf index 3677e84e9f7..a532c4f68e3 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf index 6c23dfc6b47..7d757c8856b 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf index 55adab87f4f..34550efb00d 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf index e3da2008c20..bf9fc52d986 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf index 87d4425668a..e73f16be8aa 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf index 2c8a0e07d06..d286dae2ca4 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf index 50b0f6f1edf..225f6086bf7 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf index 8298c560e2c..2fc567b8f42 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf index 6e2cf3530a0..c67fc85e566 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf index 0691af916d1..1115e8101ad 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf index 7299934c914..a46e8fc41fd 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf @@ -80,7 +80,7 @@ [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new aspire-empty' when --source was supplied for a non-C# language. + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. The version of xUnit.net to use for the test project. diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 101baea71c5..d45cdcec3ca 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -89,20 +89,7 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla await ApplyLocalhostTldToScaffoldedRunProfileAsync(outputPath, projectName, cancellationToken); } - // The override is consumed only during the initial scaffold restore inside - // PrebuiltAppHostServer; nothing in the scaffolded project records the - // source, so a later `aspire restore`/`aspire add` will use the channel - // feeds resolved from aspire.config.json. Surface that so users supplying - // `--source /packages` aren't surprised when subsequent commands - // fail to find the same packages. Persisting the feed is tracked as a - // follow-up. - if (!string.IsNullOrWhiteSpace(inputs.Source)) - { - _interactionService.DisplayMessage( - KnownEmojis.Warning, - TemplatingStrings.EmptySourceOverrideNotPersistedWarning, - allowMarkup: true); - } + DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); } return new TemplateResult((int)CliExitCodes.Success, outputPath); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs index 6c4f9a55cf2..2c26a9dff6f 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs @@ -81,13 +81,15 @@ private async Task ApplyGoStarterTemplateAsync(CallbackTemplate } _logger.LogDebug("Generating SDK code for Go starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Go starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs index ef125e4d107..497bf767b7f 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -95,13 +95,15 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( } _logger.LogDebug("Generating SDK code for Python starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index 763e2cb0864..e54bb7f06ec 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -80,13 +80,15 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT } _logger.LogDebug("Generating SDK code for TypeScript starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index f67b7ba631b..d6316e7eb00 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -261,6 +261,23 @@ private static AppHostProfilePorts GenerateRandomPorts() return AppHostProfilePortGenerator.Generate(Random.Shared); } + // The --source override is consumed only during the initial scaffold restore inside + // PrebuiltAppHostServer / the guest AppHost build path; nothing in the scaffolded project + // records the source, so a later `aspire restore` / `aspire add` will use the channel + // feeds resolved from aspire.config.json. Surface that so users supplying + // `--source /packages` aren't surprised when subsequent commands fail to find + // the same packages. Persisting the feed is tracked as a follow-up. + private void DisplaySourceOverrideNotPersistedWarningIfNeeded(string? source) + { + if (!string.IsNullOrWhiteSpace(source)) + { + _interactionService.DisplayMessage( + KnownEmojis.Warning, + TemplatingStrings.EmptySourceOverrideNotPersistedWarning, + allowMarkup: true); + } + } + private static void AddOptionIfMissing(System.CommandLine.Command command, System.CommandLine.Option option) { if (!command.Options.Contains(option)) diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 77d50cd9ec2..43383bdcd1b 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -391,7 +391,7 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredCha ]) }; }); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -445,7 +445,7 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredSta ]) }; }); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -490,7 +490,7 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostOutsideLaunchDire options.InteractionServiceFactory = _ => testInteractionService; options.NuGetPackageCacheFactory = _ => cache; }); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -539,7 +539,7 @@ public async Task IntegrationSearchCommandFormatJsonWithUnpinnedAppHostUsesImpli ]) }; }); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _) => Task.FromResult(true))); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index d47aab1f556..9e5dc8d6036 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1151,8 +1151,10 @@ public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCrea Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.ts"))); } - [Fact] - public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideIsNotPersisted() + [Theory] + [InlineData("typescript", null, "apphost.ts")] + [InlineData("java", "experimentalPolyglot:java", "AppHost.java")] + public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideIsNotPersisted(string language, string? featureFlag, string scaffoldFileName) { using var workspace = TemporaryWorkspace.Create(outputHelper); const string sourceOverride = "/tmp/aspire-pr-hive/packages"; @@ -1162,6 +1164,15 @@ public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideI var services = CreateServiceCollection(workspace, options => { options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + if (featureFlag is not null) + { + options.FeatureFlagsFactory = _ => + { + var features = new TestFeatures(); + features.SetFeature(featureFlag, true); + return features; + }; + } }); services.AddSingleton(new TestScaffoldingService @@ -1169,14 +1180,14 @@ public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideI ScaffoldAsyncCallback = (context, _) => { capturedPackageSourceOverride = context.PackageSourceOverride; - File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost"); + File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, scaffoldFileName), "// test apphost"); return Task.FromResult(true); } }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source {sourceOverride}"); + var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language {language} --localhost-tld false --suppress-agent-init --source {sourceOverride}"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); @@ -1688,7 +1699,7 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() }; }); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken) => + services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => { buildAndGenerateCalled = true; var config = AspireConfigFile.Load(directory.FullName); @@ -1764,7 +1775,7 @@ public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWh }); services.AddSingleton(interactionService); - services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken) => Task.FromResult(false))); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false))); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); @@ -1777,6 +1788,121 @@ public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWh error => Assert.Equal("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details.", error)); } + [Fact] + public async Task NewCommandWithTypeScriptStarterAndSourceOverrideWarnsAndPlumbsOverride() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + TestInteractionService? interactionService = null; + var services = CreateServiceCollection(workspace, options => + { + options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner + { + SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + { + var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" }; + return (0, new NuGetPackage[] { package }); + } + }; + + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = cancellationToken => + { + var dailyCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => + { + var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" }; + return Task.FromResult>([package]); + } + }; + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + return Task.FromResult>([dailyChannel]); + } + }; + }); + + var projectFactory = new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => + { + var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, ".modules")); + File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.ts"), "// generated sdk"); + return Task.FromResult(true); + }); + services.AddSingleton(projectFactory); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source {sourceOverride}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(sourceOverride, projectFactory.Project.LastPackageSourceOverride); + Assert.NotNull(interactionService); + Assert.Contains( + interactionService!.DisplayedMessages, + entry => entry.Emoji.Name == KnownEmojis.Warning.Name + && entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + } + + [Fact] + public async Task NewCommandWithTypeScriptStarterAndFailedRestoreDoesNotWarnAboutSourceOverride() + { + // The warning is only meaningful when the scaffold succeeded — surfacing it on a failed + // restore would just add noise behind a more prominent error. Pin that the starter path + // mirrors the empty-template path here. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + TestInteractionService? interactionService = null; + var services = CreateServiceCollection(workspace, options => + { + options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner + { + SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) => + { + var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" }; + return (0, new NuGetPackage[] { package }); + } + }; + + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = cancellationToken => + { + var dailyCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) => + { + var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" }; + return Task.FromResult>([package]); + } + }; + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + return Task.FromResult>([dailyChannel]); + } + }; + }); + + services.AddSingleton(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false))); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source {sourceOverride}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode); + Assert.NotNull(interactionService); + Assert.DoesNotContain( + interactionService!.DisplayedMessages, + entry => entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + } + [Fact] public async Task NewCommandNonInteractiveDoesNotPrompt() { diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 54a61d192e2..9effc375636 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -1013,9 +1013,51 @@ await File.WriteAllTextAsync(aspireConfigPath, """ Assert.NotNull(result.Output); var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line)); - Assert.Contains(packageSourceOverride, combined); - Assert.Contains("daily", combined); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", combined); + Assert.Contains($"--source: {packageSourceOverride}", combined); + Assert.Contains("channel: daily", combined); + Assert.Contains("packages: Aspire.Hosting.CodeGeneration.TypeScript 13.4.0-pr.17141.gf142085f", combined); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_RestoreFailure_WithManyPackages_TruncatesPackageList() + { + // The package preview caps at 5 entries with a "(+N more)" suffix so the error footer + // doesn't explode for projects with large package counts. Pin the truncation shape so + // it can't silently regress. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + executionFactory.DefaultExitCode = 1; + + var packages = Enumerable.Range(0, 8) + .Select(i => IntegrationReference.FromPackage($"Aspire.Hosting.Pkg{i}", "1.0.0")) + .ToArray(); + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + packages, + packageSourceOverride: packageSourceOverride); + + Assert.False(result.Success); + Assert.NotNull(result.Output); + + var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line)); + // First five packages appear; later ones are collapsed into a count. + Assert.Contains("Aspire.Hosting.Pkg0 1.0.0", combined); + Assert.Contains("Aspire.Hosting.Pkg4 1.0.0", combined); + Assert.DoesNotContain("Aspire.Hosting.Pkg5 1.0.0", combined); + Assert.DoesNotContain("Aspire.Hosting.Pkg7 1.0.0", combined); + Assert.Contains("(+3 more)", combined); } finally { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs index 67efc403466..59ddbeb912f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs @@ -6,10 +6,12 @@ namespace Aspire.Cli.Tests.TestServices; -internal sealed class TestTypeScriptStarterProjectFactory(Func> buildAndGenerateSdkAsync) : IAppHostProjectFactory +internal sealed class TestTypeScriptStarterProjectFactory(Func> buildAndGenerateSdkAsync) : IAppHostProjectFactory { private readonly TestTypeScriptStarterProject _project = new(buildAndGenerateSdkAsync); + public TestTypeScriptStarterProject Project => _project; + public IAppHostProject GetProject(LanguageInfo language) { ArgumentNullException.ThrowIfNull(language); @@ -33,10 +35,12 @@ public IAppHostProject GetProject(FileInfo appHostFile) } } -internal sealed class TestTypeScriptStarterProject(Func> buildAndGenerateSdkAsync) : IAppHostProject, IGuestAppHostSdkGenerator +internal sealed class TestTypeScriptStarterProject(Func> buildAndGenerateSdkAsync) : IAppHostProject, IGuestAppHostSdkGenerator { public bool IsUnsupported { get; set; } + public string? LastPackageSourceOverride { get; private set; } + public string LanguageId => KnownLanguageId.TypeScript; public string DisplayName => "TypeScript (Node.js)"; @@ -103,8 +107,9 @@ public Task FindAndStopRunningInstanceAsync(FileInfo appH throw new NotImplementedException(); } - public Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + public Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null) { - return buildAndGenerateSdkAsync(directory, cancellationToken); + LastPackageSourceOverride = packageSourceOverride; + return buildAndGenerateSdkAsync(directory, cancellationToken, packageSourceOverride); } } From 1f0c6065354e53e8b91e23f02ca99571d7e7f864 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 01:11:04 -0400 Subject: [PATCH 08/17] chore(cli): rename EmptySourceOverrideNotPersistedWarning resource The shared `DisplaySourceOverrideNotPersistedWarningIfNeeded` helper on `CliTemplateFactory` is invoked by both `aspire-empty` and the three guest-language starter templates (TypeScript, Python, Go), so the `Empty*` prefix on the resource key is stale. Drop the prefix while the string is still pre-release and re-translation has not yet been triggered for translators. Renames the resource in `.resx`, `.Designer.cs`, the single production call site in `CliTemplateFactory.cs`, and four references in `NewCommandTests.cs` (empty and starter happy-path + suppression cases). `dotnet build /t:UpdateXlf src/Aspire.Cli/Aspire.Cli.csproj` regenerates the 13 `*.xlf` files to pick up the new `trans-unit id`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs | 4 ++-- src/Aspire.Cli/Resources/TemplatingStrings.resx | 2 +- src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf | 10 +++++----- .../Resources/xlf/TemplatingStrings.pt-BR.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf | 10 +++++----- src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf | 10 +++++----- .../Resources/xlf/TemplatingStrings.zh-Hans.xlf | 10 +++++----- .../Resources/xlf/TemplatingStrings.zh-Hant.xlf | 10 +++++----- src/Aspire.Cli/Templating/CliTemplateFactory.cs | 2 +- tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs | 8 ++++---- 17 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs index 16ea554cdb5..438e23c5e35 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs @@ -486,9 +486,9 @@ public static string RunAspireRun { /// /// Looks up a localized string similar to [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/]. /// - public static string EmptySourceOverrideNotPersistedWarning { + public static string SourceOverrideNotPersistedWarning { get { - return ResourceManager.GetString("EmptySourceOverrideNotPersistedWarning", resourceCulture); + return ResourceManager.GetString("SourceOverrideNotPersistedWarning", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.resx b/src/Aspire.Cli/Resources/TemplatingStrings.resx index a1e713d62fe..70d1a447815 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.resx +++ b/src/Aspire.Cli/Resources/TemplatingStrings.resx @@ -266,7 +266,7 @@ Run 'aspire run' to start your AppHost. - + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf index 3baca977598..7b88987a49f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf @@ -77,11 +77,6 @@ Vytváří se nový projekt Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Verze xUnit.net, která se má použít pro testovací projekt @@ -172,6 +167,11 @@ Hledají se dostupné verze šablon projektu... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf index 249b3b77659..e0e1a16ec2d 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf @@ -77,11 +77,6 @@ Neues Aspire-Projekt wird erstellt... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Die Version von xUnit.net, die für das Testprojekt verwendet werden soll. @@ -172,6 +167,11 @@ Verfügbare Projektvorlagenversionen werden gesucht... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf index a532c4f68e3..3bd9b8ce2fa 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf @@ -77,11 +77,6 @@ Creando un nuevo proyecto de Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. La versión de xUnit.net que se utilizará para el proyecto de prueba. @@ -172,6 +167,11 @@ Buscando versiones de plantillas de proyecto disponibles... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf index 7d757c8856b..e79e7786594 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf @@ -77,11 +77,6 @@ Création d’un nouveau projet Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Version de xUnit.net à utiliser pour le projet de test. @@ -172,6 +167,11 @@ Recherche des versions de modèles de projet disponibles... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf index 34550efb00d..a5e89b6810f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf @@ -77,11 +77,6 @@ Creazione del nuovo progetto Aspire in corso... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Versione di xUnit.net da usare per il progetto di test. @@ -172,6 +167,11 @@ Ricerca delle versioni dei modelli di progetto disponibili in corso... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf index bf9fc52d986..54f1a150932 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf @@ -77,11 +77,6 @@ 新しい Aspire プロジェクトを作成しています... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. テスト プロジェクトのために使用する xUnit.net のバージョン。 @@ -172,6 +167,11 @@ 使用可能なプロジェクト テンプレートのバージョンを検索しています... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf index e73f16be8aa..f15ec0376d5 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf @@ -77,11 +77,6 @@ 새 Aspire 프로젝트를 만드는 중... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. 테스트 프로젝트에 사용할 xUnit.net 버전입니다. @@ -172,6 +167,11 @@ 사용 가능한 프로젝트 템플릿 버전을 검색하는 중... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf index d286dae2ca4..b01f4728ae0 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf @@ -77,11 +77,6 @@ Trwa tworzenie nowego projektu Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Wersja xUnit.net do użycia w projekcie testowym. @@ -172,6 +167,11 @@ Trwa wyszukiwanie dostępnych wersji szablonu projektu... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf index 225f6086bf7..0fb830986b6 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf @@ -77,11 +77,6 @@ Criando novo projeto Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. A versão do xUnit.net a ser usada para o projeto de teste. @@ -172,6 +167,11 @@ Procurando versões disponíveis do modelo de projeto... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf index 2fc567b8f42..a6ea76245c8 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf @@ -77,11 +77,6 @@ Создается новый проект Aspire... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Версия xUnit.net, которую следует использовать для тестового проекта. @@ -172,6 +167,11 @@ Производится поиск доступных версий шаблонов проекта... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf index c67fc85e566..4f2ec2a2d00 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf @@ -77,11 +77,6 @@ Yeni Aspire projesi oluşturuluyor... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. Test projesi için kullanılacak xUnit.net sürümü. @@ -172,6 +167,11 @@ Kullanılabilir proje şablonu sürümleri aranıyor... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf index 1115e8101ad..b00966db15e 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf @@ -77,11 +77,6 @@ 正在创建新的 Aspire 项目... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. 要用于测试项目的 xUnit.net 版本。 @@ -172,6 +167,11 @@ 正在搜索可用的项目模板版本... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf index a46e8fc41fd..1dea7e9226a 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf @@ -77,11 +77,6 @@ 正在建立新的 Aspire 專案... - - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] - Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. - The version of xUnit.net to use for the test project. 測試專案要使用的 xUnit.net 版本。 @@ -172,6 +167,11 @@ 正在搜尋可用的專案範本版本... + + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + [yellow]--source was used for the initial scaffold restore only and is not persisted. Later 'aspire restore' / 'aspire add' will use the channel feeds configured for this project.[/] + Do not translate [yellow] or [/]. Displayed after 'aspire new' (aspire-empty or aspire-starter) when --source was supplied for a non-C# language. + The template installation failed with exit code {0}. The template installation failed with exit code {0}. diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index d6316e7eb00..d29560bc2c3 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -273,7 +273,7 @@ private void DisplaySourceOverrideNotPersistedWarningIfNeeded(string? source) { _interactionService.DisplayMessage( KnownEmojis.Warning, - TemplatingStrings.EmptySourceOverrideNotPersistedWarning, + TemplatingStrings.SourceOverrideNotPersistedWarning, allowMarkup: true); } } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 9e5dc8d6036..f38aafbef0d 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1196,7 +1196,7 @@ public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideI Assert.Contains( interactionService!.DisplayedMessages, entry => entry.Emoji.Name == KnownEmojis.Warning.Name - && entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + && entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); } [Fact] @@ -1228,7 +1228,7 @@ public async Task NewCommandWithEmptyTemplateWithoutSourceOverrideDoesNotWarn() Assert.NotNull(interactionService); Assert.DoesNotContain( interactionService!.DisplayedMessages, - entry => entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); } [Fact] @@ -1845,7 +1845,7 @@ public async Task NewCommandWithTypeScriptStarterAndSourceOverrideWarnsAndPlumbs Assert.Contains( interactionService!.DisplayedMessages, entry => entry.Emoji.Name == KnownEmojis.Warning.Name - && entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + && entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); } [Fact] @@ -1900,7 +1900,7 @@ public async Task NewCommandWithTypeScriptStarterAndFailedRestoreDoesNotWarnAbou Assert.NotNull(interactionService); Assert.DoesNotContain( interactionService!.DisplayedMessages, - entry => entry.Message == TemplatingStrings.EmptySourceOverrideNotPersistedWarning); + entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); } [Fact] From 7e76ca4a419190c2289b2c163258f706238b1eae Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 01:13:06 -0400 Subject: [PATCH 09/17] fix(cli): align --source argument list with temp NuGet.config in PrebuiltAppHostServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TryCreateTemporaryNuGetConfigAsync` already drops the matched channel's `Aspire*` mapping in the override branch, pinning Aspire package restoration to `--source` exclusively. But `GetNuGetSourcesAsync` — which builds the `--source` CLI argument list passed alongside the temp config — was still iterating every mapping in the matched channel and adding each mapping URL, including the channel's Aspire feed. The bundled NuGet tool treats `--source` CLI args as co-eligible with config mappings (which is why the original "don't fold in every explicit channel" comment exists in this method), so re-adding the channel's Aspire feed silently undoes the temp config's PSM drop and lets Aspire packages still resolve from the channel feed. A second, smaller divergence: when the matched channel had no `*` (AllPackages) mapping, the temp config added `* -> NuGet.org` as a catch-all but the sources list's `sources.Count == 1` heuristic only added NuGet.org in the no-channel case, leaving a mismatched catch-all whenever a matched channel contributed any non-Aspire mapping (e.g. `CommunityToolkit*`, `Microsoft.*`). In the matched-channel loop, skip mappings whose `PackageFilter` starts with "Aspire" when an override is set, and observe whether the matched channel supplied its own AllPackages mapping. After the loop, fall back to NuGet.org only when no AllPackages mapping was seen — the same rule the temp config uses for its catch-all. Tests: - `GetNuGetSources_WithPackageSourceOverrideAndMatchedChannel_OmitsChannelAspireFeedFromSources` pins that the channel's Aspire feed URL does NOT appear in the `--source` argument list, even though the channel maps `Aspire*` to it. This is the inverse assertion of the existing `TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_DropsRequestedChannelAspireMappings` test on the config side. - `..._KeepsChannelSourceAndAddsNuGetOrgFallback` covers the `CommunityToolkit*` case: non-Aspire channel mapping stays, and NuGet.org is added because the matched channel has no AllPackages mapping. - `..._OmitsNuGetOrgFallback` covers a channel that already supplies `* -> channelSource`: NuGet.org should NOT be added, because the channel's own AllPackages mapping is the catch-all in both the temp config and the sources list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 36 ++++++-- .../Projects/PrebuiltAppHostServerTests.cs | 89 +++++++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 4bbc57dd156..cc5aef48507 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -546,6 +546,8 @@ internal static string GenerateIntegrationProjectFile( var channels = !string.IsNullOrWhiteSpace(packageSourceOverride) && string.IsNullOrEmpty(requestedChannel) ? [] : await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken); + var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride); + var matchedChannelHasAllPackagesMapping = false; foreach (var channel in channels) { if (channel.Mappings is null) @@ -555,26 +557,44 @@ internal static string GenerateIntegrationProjectFile( foreach (var mapping in channel.Mappings) { + // Stay consistent with TryCreateTemporaryNuGetConfigAsync, which drops the + // matched channel's Aspire* mapping in the override branch: the bundled + // restore tool treats `--source` CLI args as co-eligible with config + // mappings, so re-adding the channel's Aspire feed here would silently + // defeat the override even though the temp NuGet.config's PSM tries to + // pin Aspire* to the override exclusively. + if (hasOverride && mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (mapping.PackageFilter == PackageMapping.AllPackages) + { + matchedChannelHasAllPackagesMapping = true; + } + if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) { sources.Add(mapping.Source); } } } + + // Mirror the temp NuGet.config's catch-all decision: it adds `* -> NuGet.org` + // only when the matched channel did not supply its own AllPackages mapping. The + // --source argument list must agree so non-Aspire transitives have the same + // catch-all source in both views. + if (hasOverride && !matchedChannelHasAllPackagesMapping && + !sources.Contains(NuGetOrgSource, StringComparer.OrdinalIgnoreCase)) + { + sources.Add(NuGetOrgSource); + } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); } - if (!string.IsNullOrWhiteSpace(packageSourceOverride) && sources.Count == 1) - { - // Keep NuGet.org available for unrelated packages and transitives; the temp - // NuGet.config maps Aspire* exclusively to the explicit source so Aspire packages - // cannot float to NuGet.org or any other co-eligible feed. - sources.Add(NuGetOrgSource); - } - return sources.Count > 0 ? sources : null; } diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 9effc375636..a521357dd2e 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -638,6 +638,80 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_WhenCh Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); } + [Fact] + public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannel_OmitsChannelAspireFeedFromSources() + { + // Regression: the temp NuGet.config drops the matched channel's Aspire* mapping in + // the override branch, but the --source argument list passed to the bundled NuGet + // tool also has to drop that source URL. The bundled tool treats extra `--source` + // CLI args as co-eligible with config mappings, so re-adding the channel's Aspire + // feed here would silently let Aspire packages resolve from it and defeat the + // override. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); + + Assert.NotNull(sources); + Assert.Contains(packageSourceOverride, sources); + Assert.DoesNotContain(channelSource, sources); + Assert.Contains(NuGetOrgSource, sources); + } + + [Fact] + public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannelNonAspireMapping_KeepsChannelSourceAndAddsNuGetOrgFallback() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("CommunityToolkit*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); + + Assert.NotNull(sources); + Assert.Contains(packageSourceOverride, sources); + Assert.Contains(channelSource, sources); + // Matched channel has no AllPackages mapping, so the temp NuGet.config uses NuGet.org + // as catch-all and the sources list must include it too. + Assert.Contains(NuGetOrgSource, sources); + } + + [Fact] + public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannelAllPackagesMapping_OmitsNuGetOrgFallback() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-packages"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var stagingChannel = PackageChannel.CreateExplicitChannel( + name: "staging", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + + var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); + + Assert.NotNull(sources); + Assert.Contains(packageSourceOverride, sources); + Assert.Contains(channelSource, sources); + // Matched channel supplied its own AllPackages mapping, so NuGet.org should not be + // added as a co-eligible source — the channel's catch-all wins in both the temp + // config's PSM and the --source argument list. + Assert.DoesNotContain(NuGetOrgSource, sources); + } + private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => new(new DirectoryInfo(Path.GetTempPath()), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), @@ -715,6 +789,21 @@ private static PrebuiltAppHostServer CreateServerWithPackagingService( return await task; } + private static async Task?> InvokeGetNuGetSourcesAsync( + PrebuiltAppHostServer server, + string? requestedChannel, + string? packageSourceOverride = null) + { + var method = typeof(PrebuiltAppHostServer).GetMethod( + "GetNuGetSourcesAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = (Task?>)method.Invoke(server, [requestedChannel, packageSourceOverride, CancellationToken.None])!; + var result = await task; + return result?.ToList(); + } + [Fact] public async Task ResolveRequestedChannel_UsesProjectLocalAspireConfig() { From 6e8bc748142a3373d26e06d12409a8a5abb7d640 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 01:14:09 -0400 Subject: [PATCH 10/17] fix(cli): redact credentials from --source in restore-failure output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new restore-failure context block introduced earlier in this branch echoes `--source: ` into the OutputCollector that ScaffoldingService displays on a failed scaffold. NuGet feed URLs routinely carry credentials — `https://name:pat@host/...` for token-auth feeds or SAS-style `?sv=...&sig=...` query tokens for blob-storage feeds — and that block is exactly the text users copy verbatim into GitHub issues, Teams chats, and CI failure transcripts. Add a `RedactSourceForDisplay` helper that strips UserInfo, Query, and Fragment from http/https URIs before display, and route the override through it from `AppendRestoreContextOnFailure`. Plain URLs without credentials/query are detected via early-return and pass through unchanged; local paths and `file://`-style sources bypass the URI branch and are emitted as-is. The redaction is only for the display copy — the actual restore invocation still receives the original source string. Cover the helper with a `[Theory]` exercising the no-redaction path (plain URL, Unix path, Windows path), the userinfo-only case, the query-only case, the combined userinfo+query case, and the fragment case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 43 ++++++++++++++++++- .../Projects/PrebuiltAppHostServerTests.cs | 13 ++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index cc5aef48507..86268f60e76 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -247,7 +247,11 @@ private static void AppendRestoreContextOnFailure( if (hasOverride) { - output.AppendError($" --source: {packageSourceOverride}"); + // NuGet feed URLs commonly embed credentials in UserInfo + // (https://name:pat@host/...) or as SAS-style tokens in the query string. + // This line ends up in the output users copy into bug reports and CI + // transcripts, so strip the credential-carrying components before display. + output.AppendError($" --source: {RedactSourceForDisplay(packageSourceOverride!)}"); } if (hasChannel) @@ -717,6 +721,43 @@ private static string GetRestoreVersion(string packageName, string version, bool return $"[{version}]"; } + // Returns a display-safe form of a NuGet source for inclusion in user-visible output. + // For http/https feeds we strip the UserInfo, query, and fragment because users commonly + // pass `https://user:pat@host/...` or SAS-token URLs (`?sv=...&sig=...`) and the failure + // output flows into bug reports and CI logs. Local paths and other source forms (file://, + // bare paths on Windows/Unix) pass through unchanged — they don't carry credentials. + internal static string RedactSourceForDisplay(string source) + { + if (string.IsNullOrEmpty(source)) + { + return source; + } + + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return source; + } + + var hasUserInfo = !string.IsNullOrEmpty(uri.UserInfo); + var hasQuery = !string.IsNullOrEmpty(uri.Query); + var hasFragment = !string.IsNullOrEmpty(uri.Fragment); + if (!hasUserInfo && !hasQuery && !hasFragment) + { + return source; + } + + var builder = new UriBuilder(uri) + { + UserName = hasUserInfo ? "***" : string.Empty, + Password = string.Empty, + Query = string.Empty, + Fragment = string.Empty + }; + + return builder.Uri.ToString(); + } + /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index a521357dd2e..8d16200416d 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -712,6 +712,19 @@ public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannelAllP Assert.DoesNotContain(NuGetOrgSource, sources); } + [Theory] + [InlineData("https://api.nuget.org/v3/index.json", "https://api.nuget.org/v3/index.json")] + [InlineData("/tmp/aspire-packages", "/tmp/aspire-packages")] + [InlineData(@"C:\packages", @"C:\packages")] + [InlineData("https://user:pat@feed.example.com/v3/index.json", "https://***@feed.example.com/v3/index.json")] + [InlineData("https://feed.blob.core.windows.net/foo/index.json?sv=2024-01&sig=secret-sig", "https://feed.blob.core.windows.net/foo/index.json")] + [InlineData("https://user:pat@feed.example.com/v3/index.json?sig=secret", "https://***@feed.example.com/v3/index.json")] + [InlineData("https://feed.example.com/v3/index.json#fragment", "https://feed.example.com/v3/index.json")] + public void RedactSourceForDisplay_StripsCredentialsAndQueryFromHttpUrlsButPreservesPlainSources(string input, string expected) + { + Assert.Equal(expected, PrebuiltAppHostServer.RedactSourceForDisplay(input)); + } + private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => new(new DirectoryInfo(Path.GetTempPath()), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), From f55e41f7c29adbe97822a605e20664dd57a13beb Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 01:49:34 -0400 Subject: [PATCH 11/17] fix(cli): auto-discover local Aspire source from requested channel When --source isn't supplied, PrebuiltAppHostServer now resolves the requested channel and, if it has a hive-backed Aspire* mapping pointing at an existing local directory, uses that as the package source override for both package-only and project-reference restore. That closes the dogfood gap where `aspire new aspire-empty --language typescript` from a PR/local CLI would resolve Aspire packages through the ambient channel feed instead of the CLI's own hive, surfacing as TypeLoadException during code generation. Channel-lookup failures are swallowed-and-logged (mirroring the existing defensive catches in TryCreateTemporaryNuGetConfigAsync and GetNuGetSourcesAsync); OperationCanceledException is re-thrown. Tests cover the explicit-channel-only path, the explicit-source-wins path, and that http-backed channels keep their existing non-exact restore behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 75 +++++++- .../Projects/PrebuiltAppHostServerTests.cs | 173 ++++++++++++++++++ 2 files changed, 246 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 86268f60e76..e71b484e938 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -146,6 +146,11 @@ public async Task PrepareAsync( // with a legacy .aspire/settings.json#channel fallback). This is independent of the // running CLI's identity hive (CliExecutionContext.IdentityChannel). requestedChannel = ResolveRequestedChannel(); + var effectivePackageSourceOverride = packageSourceOverride; + if (string.IsNullOrWhiteSpace(effectivePackageSourceOverride)) + { + effectivePackageSourceOverride = await ResolveLocalPackageSourceOverrideAsync(requestedChannel, cancellationToken).ConfigureAwait(false); + } if (projectRefs.Count > 0) { @@ -165,7 +170,7 @@ public async Task PrepareAsync( packageRefs, projectRefs, requestedChannel, - packageSourceOverride, + effectivePackageSourceOverride, cancellationToken).ConfigureAwait(false); if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked)) @@ -191,7 +196,7 @@ await IntegrationPackageProbeManifest.WriteAsync( { // NuGet-only — use the bundled NuGet service (no SDK required) _integrationProbeManifestPath = await RestoreNuGetPackagesAsync( - packageRefs, requestedChannel, packageSourceOverride, cancellationToken); + packageRefs, requestedChannel, effectivePackageSourceOverride, cancellationToken); } var appSettingsContent = CreateAppSettingsContent(packageRefs, []); @@ -695,6 +700,72 @@ [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.So return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder); } + private async Task ResolveLocalPackageSourceOverrideAsync(string? requestedChannel, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(requestedChannel)) + { + return null; + } + + PackageChannel? channel; + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + channel = channels.FirstOrDefault(c => + c.Type == PackageChannelType.Explicit && + c.Mappings is { Length: > 0 } && + string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // A transient packaging-service failure during auto-discovery must not turn + // `aspire new` into a hard failure. Returning null falls through to the existing + // ambient + channel-sources path, matching the defensive catches in + // TryCreateTemporaryNuGetConfigAsync and GetNuGetSourcesAsync. + _logger.LogWarning(ex, "Failed to resolve local Aspire package source for channel '{Channel}'.", requestedChannel); + return null; + } + + var source = channel is null ? null : GetExistingLocalAspirePackageSource(channel); + + if (!string.IsNullOrWhiteSpace(source)) + { + _logger.LogDebug("Using local package source '{Source}' for channel '{Channel}'.", source, requestedChannel); + } + + return source; + } + + private static string? GetExistingLocalAspirePackageSource(PackageChannel channel) + { + if (channel.Mappings is null) + { + return null; + } + + foreach (var mapping in channel.Mappings) + { + if (!IsAspireSpecificMapping(mapping) || + UrlHelper.IsHttpUrl(mapping.Source) || + !Directory.Exists(mapping.Source)) + { + continue; + } + + return mapping.Source; + } + + return null; + } + + private static bool IsAspireSpecificMapping(PackageMapping mapping) => + mapping.PackageFilter != PackageMapping.AllPackages && + mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + private async Task> GetExplicitRestoreChannelsAsync(string? requestedChannel, CancellationToken cancellationToken) { var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 8d16200416d..05ac64c57d1 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -997,6 +997,179 @@ public async Task PrepareAsync_WithPackageSourceOverride_AddsNuGetOrgFallbackSou } } + [Theory] + [InlineData("pr-12345")] + [InlineData("local")] + [InlineData("worktree-feature")] + public async Task PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride(string channelName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packageSource = workspace.CreateDirectory("hive-packages"); + List? restoreArgs = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, $$""" + { + "channel": "{{channelName}}" + } + """); + + var channel = PackageChannel.CreateExplicitChannel( + name: channelName, + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", packageSource.FullName)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [ + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + ]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([packageSource.FullName, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); + Assert.Contains("--nuget-config", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithExplicitPackageSourceOverride_IgnoresHiveBackedAspireSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var explicitPackageSource = workspace.CreateDirectory("explicit-packages"); + var hivePackageSource = workspace.CreateDirectory("hive-packages"); + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + List? restoreArgs = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "pr-12345" + } + """); + + var channel = PackageChannel.CreateExplicitChannel( + name: "pr-12345", + quality: PackageChannelQuality.Both, + mappings: + [ + new PackageMapping("Aspire*", hivePackageSource.FullName), + new PackageMapping(PackageMapping.AllPackages, channelSource) + ], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")], + packageSourceOverride: explicitPackageSource.FullName); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([explicitPackageSource.FullName, channelSource], GetSourceArguments(restoreArgs!)); + Assert.DoesNotContain(hivePackageSource.FullName, restoreArgs!); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithHttpBackedChannel_DoesNotUseExactPackageVersions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + List? restoreArgs = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var channel = PackageChannel.CreateExplicitChannel( + name: "daily", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([channelSource], GetSourceArguments(restoreArgs!)); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); + Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithSourceAndChannelHavingAspireMapping_TempConfigDropsChannelAspireMapping() { From 5e069d75d71af1ac3ebc5ef7a22fa7b9b6850d47 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 02:29:12 -0400 Subject: [PATCH 12/17] fix(cli): close 5 findings from PR #17166 post-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post-merge review of #17166 (saved under .squad/log) flagged five issues in the prebuilt + DotNet-based AppHost restore paths that survived the `origin/main` merge. This addresses them. **#1 (HIGH) — Project-ref restore replaced ambient nuget.config for any non-Local explicit channel.** `BuildIntegrationClosureManifestAsync` called `TryCreateTemporaryNuGetConfigAsync` for every explicit channel, which emitted `` and replaced nuget.config discovery wholesale. A user with a private/internal feed in their ambient nuget.config and a `daily` or `pr-*` channel pin would silently lose that feed during project-ref restore. Now only synthesize a temp nuget.config when `--source` is set; otherwise add channel sources via `` so the ambient nuget.config is preserved. **#2 (HIGH) — DotNetBasedAppHostServerProject accepted `packageSourceOverride` but ignored it.** The in-repo / dogfood path (selected whenever `AspireRepositoryDetector.DetectRepositoryRoot` returns non-null) declared the parameter to satisfy `IAppHostServerProject` but never threaded it into restore. The template factory was unconditionally telling users `--source was used for the initial scaffold restore only…` even when the override had been silently dropped. Thread the override through `CreateProjectFilesAsync` and prepend it to the `` list so the hive is the first source NuGet evaluates. This path does not use Package Source Mappings (PSM) like `PrebuiltAppHostServer` does — in dev mode most Aspire.* dependencies come from `ProjectReference` and the override is best- effort for the rare `PackageReference` fallback. Documented inline. **#3 (MED) — Restore-failure footer showed the original `--source`, not the auto-discovered effective one.** When `--source` was not passed but `ResolveLocalPackageSourceOverrideAsync` auto-discovered a local hive, the catches in `PrepareAsync` passed the original (unset) `packageSourceOverride` argument to `AppendRestoreContextOnFailure`. The user saw only the channel name and had no signal that a local hive participated in the failed restore. Lift `effectivePackageSourceOverride` to outer scope and pass it to the catches. **#4 (MED) — `BundleNuGetService` logged raw `--source` to the debug log.** The full restore args (including credentialed feed URLs) were emitted as a single debug line that downstream `RedactSourceForDisplay` never touched. Now build a redacted copy of the args specifically for the log line — the verbatim args still go to the process. Handles repeated `--source` flags and a missing trailing value defensively. **#5 (MED) — `RedactSourceForDisplay` failed open on malformed credentialed URLs.** `Uri.TryCreate` returns false for `https://user:p@ss@host/path` and `https://user:p#word@host/` (confirmed empirically), and the redactor's parse-failure branch returned the raw input. Such inputs were guaranteed to leak credentials into the failure footer that ships in bug reports. Fail closed for HTTP-shaped inputs by detecting `http://` / `https://` prefix before parsing and returning `` when the parse fails. Plain non-HTTP inputs (local paths, file://, etc.) still pass through unchanged. Refactor: extract `RedactSourceForDisplay` into a shared `PackageSourceRedactor` utility so the same redaction is applied wherever sources appear in user-visible output. `PrebuiltAppHostServer` keeps the internal static alias for back-compat with existing tests. Tests added: - `PrepareAsync_WithProjectReferencesAndExplicitChannelButNoOverride_UsesAdditionalSourcesNotRestoreConfigFile` - `PrepareAsync_RestoreFailure_WithAutoDiscoveredLocalSource_FooterShowsEffectiveSource` - `RedactSourceForDisplay_FailsClosedForMalformedHttpButPassesThroughLocalPaths` (5 inline cases) - `CreateProjectFiles_WithPackageSourceOverride_PrependsOverrideToRestoreAdditionalProjectSources` - `CreateProjectFiles_WithoutPackageSourceOverride_DoesNotInjectExtraSource` All 3297 tests in Aspire.Cli.Tests pass (0 failures, 20 platform skips). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/NuGet/BundleNuGetService.cs | 28 ++- .../DotNetBasedAppHostServerProject.cs | 19 +- .../Projects/PrebuiltAppHostServer.cs | 62 +++---- src/Aspire.Cli/Utils/PackageSourceRedactor.cs | 64 +++++++ .../Projects/AppHostServerProjectTests.cs | 104 +++++++++++ .../Projects/PrebuiltAppHostServerTests.cs | 162 ++++++++++++++++++ 6 files changed, 395 insertions(+), 44 deletions(-) create mode 100644 src/Aspire.Cli/Utils/PackageSourceRedactor.cs diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index e24cdcde172..7a6ee76e087 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -164,7 +164,14 @@ public async Task RestorePackagesAsync( _logger.LogDebug("Restoring {Count} packages", packageList.Count); _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); - _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs)); + if (_logger.IsEnabled(LogLevel.Debug)) + { + // Build a redacted copy of the args specifically for the log line so user-supplied + // credentialed feeds (e.g., `https://user:pat@host/v3/index.json`, SAS-token URLs) do + // not flow to the debug log alongside the rest of the restore invocation. The + // original `restoreArgs` list is still passed verbatim to the process below. + _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", BuildRedactedArgsForLog(restoreArgs))); + } var environmentVariables = new Dictionary(); NuGetSignatureVerificationEnabler.Apply(environmentVariables, _features, _executionContext); @@ -253,6 +260,25 @@ private static bool TryValidatePackageManifest(string manifestPath, ILogger logg } } + // Returns a redacted copy of the restore args suitable for debug logging. Replaces the value + // immediately following each `--source` token with the credential-safe form from + // PackageSourceRedactor. Built defensively to handle repeated `--source` flags and a missing + // trailing value at the end of the args list. + private static IReadOnlyList BuildRedactedArgsForLog(IReadOnlyList args) + { + var redacted = new List(args.Count); + for (var i = 0; i < args.Count; i++) + { + redacted.Add(args[i]); + if (string.Equals(args[i], "--source", StringComparison.Ordinal) && i + 1 < args.Count) + { + redacted.Add(PackageSourceRedactor.RedactForDisplay(args[++i])); + } + } + + return redacted; + } + internal static string ComputePackageHash( List<(string Id, string Version)> packages, string tfm, diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index c5f0e8d5d4a..6a7515874aa 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -259,7 +259,8 @@ private XDocument CreateProjectFile(IEnumerable integratio /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -351,6 +352,20 @@ private XDocument CreateProjectFile(IEnumerable integratio } } + // Thread an explicit `--source` override into the restore sources so the dogfood + // `aspire new --source ` flow is honored in dev mode (in-repo). Prepending + // makes the override the first source NuGet evaluates, which matters when the same + // Aspire package version exists in both the hive and a channel feed. Note: unlike + // PrebuiltAppHostServer this path does not emit Package Source Mappings, so NuGet + // may still consult other sources if the override does not satisfy a request — the + // override is best-effort here, sufficient for the in-repo developer scenario where + // most Aspire.* dependencies come from ProjectReference, not PackageReference. + if (!string.IsNullOrWhiteSpace(packageSourceOverride) && + !channelSources.Contains(packageSourceOverride, StringComparer.OrdinalIgnoreCase)) + { + channelSources.Insert(0, packageSourceOverride); + } + // Create the project file var doc = CreateProjectFile(integrations); @@ -425,7 +440,7 @@ public async Task PrepareAsync( CancellationToken cancellationToken = default, string? packageSourceOverride = null) { - var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken); + var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken, packageSourceOverride); var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); if (!buildSuccess) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index b03f69e170f..01916802ef5 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -134,6 +134,11 @@ public async Task PrepareAsync( var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList(); string? requestedChannel = null; + // Lifted to outer scope so the failure footer reflects the source actually used by + // restore — including the auto-discovered local hive resolved by + // ResolveLocalPackageSourceOverrideAsync — rather than the unset --source the user + // originally passed in. + var effectivePackageSourceOverride = packageSourceOverride; try { @@ -146,7 +151,6 @@ public async Task PrepareAsync( // with a legacy .aspire/settings.json#channel fallback). This is independent of the // running CLI's identity hive (CliExecutionContext.IdentityChannel). requestedChannel = ResolveRequestedChannel(); - var effectivePackageSourceOverride = packageSourceOverride; if (string.IsNullOrWhiteSpace(effectivePackageSourceOverride)) { effectivePackageSourceOverride = await ResolveLocalPackageSourceOverrideAsync(requestedChannel, cancellationToken).ConfigureAwait(false); @@ -212,7 +216,7 @@ await IntegrationPackageProbeManifest.WriteAsync( catch (AppHostServerPrepareFailedException ex) { _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); - AppendRestoreContextOnFailure(ex.Output, requestedChannel, packageSourceOverride, packageRefs); + AppendRestoreContextOnFailure(ex.Output, requestedChannel, effectivePackageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: ex.Output, @@ -224,7 +228,7 @@ await IntegrationPackageProbeManifest.WriteAsync( _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); var output = new OutputCollector(); output.AppendError($"Failed to prepare: {ex.Message}"); - AppendRestoreContextOnFailure(output, requestedChannel, packageSourceOverride, packageRefs); + AppendRestoreContextOnFailure(output, requestedChannel, effectivePackageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: output, @@ -314,9 +318,17 @@ private async Task BuildIntegrationClosureManifest var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); Directory.CreateDirectory(restoreDir); - using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken); + // Only synthesize a temp NuGet.config (replacing nuget.config discovery via + // RestoreConfigFile) when the user explicitly opted into a single source via --source. + // The explicit-channel-no-override path keeps the user's ambient nuget.config in place + // and contributes channel mappings additively via RestoreAdditionalProjectSources so + // private/internal feeds the user has configured remain reachable for non-Aspire + // transitives during project-ref restore. + using var temporaryNuGetConfig = !string.IsNullOrWhiteSpace(packageSourceOverride) + ? await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken) + : null; var channelSources = temporaryNuGetConfig is null - ? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken) + ? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride: null, cancellationToken) : null; var projectContent = GenerateIntegrationProjectFile( packageRefs, @@ -827,42 +839,10 @@ private static string GetRestoreVersion(string packageName, string version, bool return $"[{version}]"; } - // Returns a display-safe form of a NuGet source for inclusion in user-visible output. - // For http/https feeds we strip the UserInfo, query, and fragment because users commonly - // pass `https://user:pat@host/...` or SAS-token URLs (`?sv=...&sig=...`) and the failure - // output flows into bug reports and CI logs. Local paths and other source forms (file://, - // bare paths on Windows/Unix) pass through unchanged — they don't carry credentials. - internal static string RedactSourceForDisplay(string source) - { - if (string.IsNullOrEmpty(source)) - { - return source; - } - - if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || - (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) - { - return source; - } - - var hasUserInfo = !string.IsNullOrEmpty(uri.UserInfo); - var hasQuery = !string.IsNullOrEmpty(uri.Query); - var hasFragment = !string.IsNullOrEmpty(uri.Fragment); - if (!hasUserInfo && !hasQuery && !hasFragment) - { - return source; - } - - var builder = new UriBuilder(uri) - { - UserName = hasUserInfo ? "***" : string.Empty, - Password = string.Empty, - Query = string.Empty, - Fragment = string.Empty - }; - - return builder.Uri.ToString(); - } + // Display-safe form of a NuGet source used in user-visible error footers. Delegates to the + // shared helper so the same redaction is applied wherever sources appear (failure context, + // debug logs in BundleNuGetService, etc.). + internal static string RedactSourceForDisplay(string source) => PackageSourceRedactor.RedactForDisplay(source); /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/src/Aspire.Cli/Utils/PackageSourceRedactor.cs b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs new file mode 100644 index 00000000000..02a1c6ee297 --- /dev/null +++ b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs @@ -0,0 +1,64 @@ +// 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.Utils; + +/// +/// Helpers for rendering NuGet package sources in user-visible output without leaking credentials. +/// +internal static class PackageSourceRedactor +{ + private const string UnparseableHttpSentinel = ""; + + /// + /// Returns a display-safe form of a NuGet source for inclusion in user-visible output (error + /// footers, debug logs, bug reports). For http/https feeds we strip the UserInfo, query, and + /// fragment because users commonly pass https://user:pat@host/... or SAS-token URLs + /// (?sv=...&sig=...). Local paths and other source forms (file://, bare paths on + /// Windows/Unix) pass through unchanged — they don't carry credentials. + /// + /// + /// Fails closed for HTTP-shaped inputs that + /// cannot parse (for example https://user:p@ss@host/path or + /// https://user:p#word@host/): returns a sentinel rather than the raw input. Plain + /// non-HTTP-looking inputs (local paths, file://, etc.) still pass through unchanged. + /// + public static string RedactForDisplay(string source) + { + if (string.IsNullOrEmpty(source)) + { + return source; + } + + // Detect HTTP-shaped inputs before attempting to parse so malformed URLs that look like + // an HTTP feed fail closed instead of leaking credentials through the parse-failure + // branch below. + var looksHttp = + source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + source.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return looksHttp ? UnparseableHttpSentinel : source; + } + + var hasUserInfo = !string.IsNullOrEmpty(uri.UserInfo); + var hasQuery = !string.IsNullOrEmpty(uri.Query); + var hasFragment = !string.IsNullOrEmpty(uri.Fragment); + if (!hasUserInfo && !hasQuery && !hasFragment) + { + return source; + } + + var builder = new UriBuilder(uri) + { + UserName = hasUserInfo ? "***" : string.Empty, + Password = string.Empty, + Query = string.Empty, + Fragment = string.Empty + }; + + return builder.Uri.ToString(); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 074bf6b9d66..1fe80bb6059 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -351,6 +351,110 @@ await File.WriteAllTextAsync(aspireConfigPath, """ Assert.DoesNotContain(prOldHive.FullName, restoreSources); } + [Fact] + public async Task CreateProjectFiles_WithPackageSourceOverride_PrependsOverrideToRestoreAdditionalProjectSources() + { + // Regression for finding #2 of the 2026-05-19 post-merge review: the DotNet-based + // (in-repo / dogfood) AppHost path previously declared a packageSourceOverride parameter + // on PrepareAsync but ignored it during restore, so `aspire new --source ` was + // silently dropped in dev mode. The override now threads through CreateProjectFilesAsync + // and prepends to the RestoreAdditionalProjectSources list so the dogfood hive is the + // first source NuGet evaluates for any PackageReference fallback in this path. + var appPath = _workspace.WorkspaceRoot.FullName; + const string overrideSource = "/tmp/aspire-pr-hive/packages"; + var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var nugetCache = new FakeNuGetPackageCache(); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[] + { + new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json"), + new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + }, nugetCache); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>(new[] { dailyChannel }) + }; + + var projectModelPath = Path.Combine(appPath, ".aspire_server"); + var project = new DotNetBasedAppHostServerProject( + appPath, + "test.sock", + appPath, + new TestDotNetCliRunner(), + packagingService, + NullLogger.Instance, + projectModelPath); + + var packages = new List + { + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") + }; + + var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages, CancellationToken.None, packageSourceOverride: overrideSource).DefaultTimeout(); + + var projectDoc = XDocument.Load(projectFilePath); + var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + Assert.NotNull(restoreSources); + var sources = restoreSources!.Split(';'); + // Override is prepended so the hive wins NuGet's source evaluation order when the same + // Aspire package version exists in both the hive and the channel feed. + Assert.Equal(overrideSource, sources[0]); + Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources); + } + + [Fact] + public async Task CreateProjectFiles_WithoutPackageSourceOverride_DoesNotInjectExtraSource() + { + // Negative companion to the override regression: ensure the no-override path still emits + // only the channel sources (i.e., we are not accidentally introducing an empty/null + // source that breaks restore on the existing in-repo flow). + var appPath = _workspace.WorkspaceRoot.FullName; + var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var nugetCache = new FakeNuGetPackageCache(); + const string channelFeed = "https://pkgs.dev.azure.com/fake/v3/index.json"; + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[] + { + new PackageMapping("Aspire*", channelFeed) + }, nugetCache); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>(new[] { dailyChannel }) + }; + + var projectModelPath = Path.Combine(appPath, ".aspire_server"); + var project = new DotNetBasedAppHostServerProject( + appPath, + "test.sock", + appPath, + new TestDotNetCliRunner(), + packagingService, + NullLogger.Instance, + projectModelPath); + + var packages = new List + { + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") + }; + + var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout(); + + var projectDoc = XDocument.Load(projectFilePath); + var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + Assert.NotNull(restoreSources); + Assert.Equal(channelFeed, restoreSources); + } + private static void DumpDirectoryTree(string path, ITestOutputHelper output, string indent = "") { var dirInfo = new DirectoryInfo(path); diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 42bb15dda2b..37b83b7fe3f 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -1464,6 +1464,168 @@ public async Task PrepareAsync_RestoreFailure_WithManyPackages_TruncatesPackageL } } + [Fact] + public async Task PrepareAsync_WithProjectReferencesAndExplicitChannelButNoOverride_UsesAdditionalSourcesNotRestoreConfigFile() + { + // Regression for finding #1 of the 2026-05-19 post-merge review: a project-ref restore + // with an explicit channel pin (daily/staging/pr-*) and NO --source must not replace the + // user's ambient nuget.config via . The channel sources flow through + // additively via so private/internal feeds the user + // has configured in nuget.config remain reachable for non-Aspire transitives. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + XDocument? generatedProject = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var closureFiles = new Dictionary(StringComparer.Ordinal) + { + ["MyIntegration.dll"] = "integration-v1" + }; + var dotNetCliRunner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFilePath, _, _, _) => + { + generatedProject = XDocument.Load(projectFilePath.FullName); + WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]); + return 0; + } + }; + + var dailyChannel = PackageChannel.CreateExplicitChannel( + name: "daily", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) + }; + + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + TestExecutionContextFactory.CreateTestContext(), + NullLogger.Instance); + var server = new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + dotNetCliRunner, + new TestDotNetSdkInstaller(), + packagingService, + TestExecutionContextFactory.CreateTestContext(), + NullLogger.Instance); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [ + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17141.gf142085f"), + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + ]); + + Assert.True(result.Success); + Assert.NotNull(generatedProject); + + var ns = generatedProject!.Root!.GetDefaultNamespace(); + Assert.Null(generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()); + + var restoreSources = generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + Assert.NotNull(restoreSources); + Assert.Contains(channelSource, restoreSources!); + + // Aspire package versions remain in their original (non-pinned) form when no override + // is in play; the exact-version pinning only fires when a single source is selected. + var packageElements = generatedProject.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => + e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && + e.Attribute("Version")?.Value == "13.4.0-pr.17141.gf142085f"); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_RestoreFailure_WithAutoDiscoveredLocalSource_FooterShowsEffectiveSource() + { + // Regression for finding #3 of the 2026-05-19 post-merge review: when the caller passes + // no --source but ResolveLocalPackageSourceOverrideAsync auto-discovers a local hive, + // the failure footer must reflect the source actually used by restore. Previously the + // catch blocks read the original (unset) `packageSourceOverride` argument and the user + // saw only the channel name, hiding that a local hive participated in the failed + // restore. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var localHive = workspace.CreateDirectory("local-aspire-hive").FullName; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "pr-12345" + } + """); + + var prChannel = PackageChannel.CreateExplicitChannel( + name: "pr-12345", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", localHive)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([prChannel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.DefaultExitCode = 1; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.12345.gabcdef00", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.12345.gabcdef00")]); + + Assert.False(result.Success); + Assert.NotNull(result.Output); + + var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line)); + Assert.Contains($"--source: {localHive}", combined); + Assert.Contains("channel: pr-12345", combined); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Theory] + [InlineData("https://user:p@ss@host/path", "")] + [InlineData("https://user:p#word@host/", "")] + [InlineData("http://foo bar/path", "")] + [InlineData("HTTPS://user:p@ss@host/path", "")] + [InlineData("/tmp/aspire/some path with [brackets]", "/tmp/aspire/some path with [brackets]")] + public void RedactSourceForDisplay_FailsClosedForMalformedHttpButPassesThroughLocalPaths(string input, string expected) + { + // Regression for finding #5 of the 2026-05-19 post-merge review: HTTP-looking inputs that + // Uri.TryCreate cannot parse (e.g. unescaped @ or # in user-info, embedded whitespace) + // must return the sentinel rather than the raw input, otherwise credentials embedded in + // the malformed URL would leak through the failure footer / bug reports. Plain non-HTTP + // inputs continue to pass through unchanged because they don't carry credentials. + Assert.Equal(expected, PrebuiltAppHostServer.RedactSourceForDisplay(input)); + } + [Fact] public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesNuGetConfig() { From 057abfaadedd700cb01a9318315f0db7b55c67d4 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 02:49:46 -0400 Subject: [PATCH 13/17] fix(cli): degrade restore on channel-lookup failure + cover gaps from #17227 merge PR #17227's defensive catch around the channel-lookup helper had two call sites; only the auto-discovery one survived the merge into this branch. The PSM-temp-config no-override path still propagates a transient `IPackagingService.GetChannelsAsync` failure out to `PrepareAsync`'s outer catch, turning a transient packaging-service hiccup (malformed `aspire.config.json`, unexpected feed probe error) into a hard `aspire new` scaffold failure. Mirror the existing defensive catch into the no-override branch of `TryCreateTemporaryNuGetConfigAsync`: cancellation rethrows, anything else logs and returns null so restore falls through to the ambient nuget.config + caller-resolved channel sources path, matching the catch in `ResolveLocalPackageSourceOverrideAsync` and the long-standing catch in `GetNuGetSourcesAsync`. Restore the dropped degrade test (`PrepareAsync_WhenPackagingService- ThrowsDuringAutoDiscovery_DegradesGracefully`) so a future refactor can't silently regress this back. Also add two negative-path tests for `aspire-empty --language ` source-coherence: - `PrepareAsync_WithHiveBackedChannelPointingAtMissingLocalDirectory_- DoesNotApplyOverride` pins that a stale `aspire.config.json` (user deleted the local hive but the channel pin remains) does not pin Aspire packages to a non-existent directory or emit exact-pin / NuGet.org fallback. - Extend `NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverride- IsNotPersisted` to also cover python, go, and rust, matching the five guest languages registered in `DefaultLanguageDiscovery`. Refs #17159 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServer.cs | 31 ++++- .../Commands/NewCommandTests.cs | 3 + .../Projects/PrebuiltAppHostServerTests.cs | 121 ++++++++++++++++++ 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 01916802ef5..a5f7a659cb3 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -717,11 +717,32 @@ [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.So // against whichever sources the caller resolved separately. ThrowIfStagingUnavailable(requestedChannel); - var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); - var channel = channels.FirstOrDefault(c => - c.Type == PackageChannelType.Explicit && - c.Mappings is { Length: > 0 } && - string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); + PackageChannel? channel; + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + channel = channels.FirstOrDefault(c => + c.Type == PackageChannelType.Explicit && + c.Mappings is { Length: > 0 } && + string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Mirror the defensive catch in the override branch above and in + // ResolveLocalPackageSourceOverrideAsync / GetNuGetSourcesAsync: a transient + // packaging-service failure must degrade to the ambient nuget.config + the + // caller's separately resolved channel-source list, rather than failing the + // whole PrepareAsync. Returning null skips the PSM-bearing temp config; for + // non-staging channels the caller still gets channel sources via + // GetNuGetSourcesAsync (which catches), and for staging the unavailable-reason + // refusal above has already short-circuited before we reach this point. + _logger.LogWarning(ex, "Failed to get package channels while creating channel NuGet.config for '{Channel}'.", requestedChannel); + return null; + } if (channel?.Mappings is null) { diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index f38aafbef0d..223eb3b84e0 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1154,6 +1154,9 @@ public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCrea [Theory] [InlineData("typescript", null, "apphost.ts")] [InlineData("java", "experimentalPolyglot:java", "AppHost.java")] + [InlineData("python", "experimentalPolyglot:python", "apphost.py")] + [InlineData("go", "experimentalPolyglot:go", "apphost.go")] + [InlineData("rust", "experimentalPolyglot:rust", "apphost.rs")] public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideIsNotPersisted(string language, string? featureFlag, string scaffoldFileName) { using var workspace = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 37b83b7fe3f..547b4c8315b 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -1294,6 +1294,127 @@ await File.WriteAllTextAsync(aspireConfigPath, """ } } + [Fact] + public async Task PrepareAsync_WhenPackagingServiceThrowsDuringAutoDiscovery_DegradesGracefully() + { + // Regression guard: an unexpected failure from IPackagingService.GetChannelsAsync during + // hive-source auto-discovery must NOT turn `aspire new` into a hard failure. Both call + // sites that resolve channels for an effective package source — ResolveLocalPackageSource- + // OverrideAsync and TryCreateTemporaryNuGetConfigAsync's no-override branch — catch + // transient exceptions and fall through to "no override discovered" / "no PSM-bearing + // temp config", matching the defensive catch in GetNuGetSourcesAsync. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelName = "pr-12345"; + List? restoreArgs = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, $$""" + { + "channel": "{{channelName}}" + } + """); + + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromException>( + new InvalidOperationException("simulated packaging service failure")) + }; + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + // No override resolved → no exact version pinning, no synthesized [override, nuget.org] source set. + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); + Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithHiveBackedChannelPointingAtMissingLocalDirectory_DoesNotApplyOverride() + { + // Negative case for ResolveLocalPackageSourceOverrideAsync: a stale aspire.config.json + // (e.g. user pinned channel = "pr-12345" but later deleted the local hive directory) + // must NOT cause the prebuilt restore to pin Aspire packages to a non-existent local + // directory. GetExistingLocalAspirePackageSource skips mappings whose Source does not + // exist on disk, so auto-discovery returns null and restore falls through to the + // ambient + channel-source path with no exact-pin. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var missingPackageSource = Path.Combine(workspace.WorkspaceRoot.FullName, "this-hive-was-deleted"); + Assert.False(Directory.Exists(missingPackageSource)); + List? restoreArgs = null; + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "pr-12345" + } + """); + + var channel = PackageChannel.CreateExplicitChannel( + name: "pr-12345", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", missingPackageSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + // The override was not applied (Directory.Exists check failed), so the source list + // is just the channel's raw Aspire mapping with no NuGet.org fallback appended (the + // fallback only fires on the override path), and no exact-pin is emitted. Contrast + // with PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride where the + // existing local directory promotes the channel source to an override and adds the + // NuGet.org fallback + exact-pinning. + Assert.Equal([missingPackageSource], GetSourceArguments(restoreArgs!)); + Assert.DoesNotContain(NuGetOrgSource, GetSourceArguments(restoreArgs!)); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); + Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithSourceAndChannelHavingAspireMapping_TempConfigDropsChannelAspireMapping() { From 968df31d4d919470bbdaee55424a62e4b4f5925d Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 13:50:15 -0400 Subject: [PATCH 14/17] fix(cli): address source-restore review feedback Keep cancellation tokens last on the guest AppHost prepare/source APIs now that both requested channel and source override are threaded through the same calls. Move the staging-unavailable guard before temporary NuGet.config creation so the source-override project-reference restore path cannot silently fall back to NuGet.org when staging cannot be synthesized. Also update the project-reference restore comment to describe both explicit --source and auto-discovered local channel sources. Add direct PackageSourceRedactor coverage for happy paths, malformed HTTP inputs, whitespace-prefixed HTTP sources, and non-HTTP source forms. Trim HTTP inputs before detection/parsing so indented feed URLs are still redacted or fail closed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RestoreCommand.cs | 4 +- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 2 +- .../Commands/Sdk/SdkGenerateCommand.cs | 2 +- .../Projects/AppHostServerSession.cs | 2 +- .../DotNetBasedAppHostServerProject.cs | 10 ++--- .../Projects/GuestAppHostProject.cs | 26 +++++------ .../Projects/IAppHostServerProject.cs | 6 +-- .../Projects/IGuestAppHostSdkGenerator.cs | 6 +-- .../Projects/PrebuiltAppHostServer.cs | 24 +++++------ .../Scaffolding/ScaffoldingService.cs | 2 +- .../CliTemplateFactory.GoStarterTemplate.cs | 2 +- ...liTemplateFactory.PythonStarterTemplate.cs | 2 +- ...mplateFactory.TypeScriptStarterTemplate.cs | 2 +- src/Aspire.Cli/Utils/PackageSourceRedactor.cs | 22 +++++++--- .../Projects/AppHostServerProjectTests.cs | 2 +- .../Projects/AppHostServerSessionTests.cs | 4 +- .../Projects/PrebuiltAppHostServerTests.cs | 15 +++++++ .../Scaffolding/ChannelReseedTests.cs | 4 +- .../FakeFailingAppHostServerProject.cs | 4 +- .../TestTypeScriptStarterProjectFactory.cs | 2 +- .../Utils/PackageSourceRedactorTests.cs | 43 +++++++++++++++++++ 21 files changed, 126 insertions(+), 60 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/PackageSourceRedactorTests.cs diff --git a/src/Aspire.Cli/Commands/RestoreCommand.cs b/src/Aspire.Cli/Commands/RestoreCommand.cs index 84e08f9868b..3abae59799a 100644 --- a/src/Aspire.Cli/Commands/RestoreCommand.cs +++ b/src/Aspire.Cli/Commands/RestoreCommand.cs @@ -99,7 +99,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul var success = await _interactionService.ShowStatusAsync( RestoreCommandStrings.RestoringSdkCode, - async () => await configOnlyGuestProject.BuildAndGenerateSdkAsync(configOnlyProjectDirectory, cancellationToken), + async () => await configOnlyGuestProject.BuildAndGenerateSdkAsync(configOnlyProjectDirectory, cancellationToken: cancellationToken), emoji: KnownEmojis.Gear); if (success) @@ -156,7 +156,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul var success = await _interactionService.ShowStatusAsync( RestoreCommandStrings.RestoringSdkCode, - async () => await guestProject.BuildAndGenerateSdkAsync(directory, cancellationToken), + async () => await guestProject.BuildAndGenerateSdkAsync(directory, cancellationToken: cancellationToken), emoji: KnownEmojis.Gear); if (success) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index ed85964325d..1f5988d6313 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -147,7 +147,7 @@ private async Task DumpCapabilitiesAsync( var prepareResult = await appHostServerProject.PrepareAsync( VersionHelper.GetDefaultTemplateVersion(), integrations, - cancellationToken); + cancellationToken: cancellationToken); if (!prepareResult.Success) { diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index f2c5bd632c1..af047419cd5 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -142,7 +142,7 @@ private async Task GenerateSdkAsync( var prepareResult = await appHostServerProject.PrepareAsync( VersionHelper.GetDefaultTemplateVersion(), integrations, - cancellationToken); + cancellationToken: cancellationToken); if (!prepareResult.Success) { diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 62399532de7..8c17833e15f 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -203,7 +203,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, integrations, cancellationToken); + var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken: cancellationToken); if (!prepareResult.Success) { return new AppHostServerSessionResult( diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 55cd5f0ffa4..fa9b8cb5abe 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -259,9 +259,9 @@ private XDocument CreateProjectFile(IEnumerable integratio /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -439,11 +439,11 @@ private XDocument CreateProjectFile(IEnumerable integratio public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { - var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken, requestedChannel, packageSourceOverride); + var (_, channelName) = await CreateProjectFilesAsync(integrations, requestedChannel, packageSourceOverride, 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 0e56810e716..b8c95d47b0f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -225,10 +225,10 @@ private string GetPrepareSdkVersion(AspireConfigFile config) string sdkVersion, List integrations, string? requestedChannel, - CancellationToken cancellationToken, - string? packageSourceOverride = null) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { - var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken, requestedChannel, packageSourceOverride); + var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, requestedChannel, packageSourceOverride, cancellationToken); return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } @@ -236,13 +236,13 @@ private string GetPrepareSdkVersion(AspireConfigFile config) /// Builds the AppHost server project and generates SDK code. /// /// if the code was generated successfully; otherwise, . - internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null) + internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { var config = LoadConfiguration(directory); - return await BuildAndGenerateSdkAsync(directory, config, cancellationToken, packageSourceOverride); + return await BuildAndGenerateSdkAsync(directory, config, packageSourceOverride, cancellationToken); } - private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, CancellationToken cancellationToken, string? packageSourceOverride = null) + private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); @@ -252,7 +252,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir var integrations = await GetIntegrationReferencesAsync(config, directory, cancellationToken); var sdkVersion = GetPrepareSdkVersion(config); - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken, packageSourceOverride); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, packageSourceOverride, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -289,9 +289,9 @@ await GenerateCodeViaRpcAsync( return true; } - Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride) + Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride, CancellationToken cancellationToken) { - return BuildAndGenerateSdkAsync(directory, cancellationToken, packageSourceOverride); + return BuildAndGenerateSdkAsync(directory, packageSourceOverride, cancellationToken); } // ═══════════════════════════════════════════════════════════════ @@ -365,7 +365,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, integrations, config.Channel, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken: cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -889,7 +889,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, cancellationToken: cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -1201,7 +1201,7 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT config.AddOrUpdatePackage(context.PackageId, context.PackageVersion); // Build and regenerate SDK code with the new package - var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken); + var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken: cancellationToken); if (!regenerateSuccess) { return false; @@ -1331,7 +1331,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex UpdateCommandStrings.RegeneratingSdkCode, async () => { - var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken); + var regenerateSuccess = await BuildAndGenerateSdkAsync(directory, config, cancellationToken: cancellationToken); if (!regenerateSuccess) { diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index fdcbd6ac62a..216dccd42a8 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -40,16 +40,16 @@ internal interface IAppHostServerProject /// /// The Aspire SDK version to use. /// The integration references (NuGet packages and/or project references) required by the app host. - /// Cancellation token. /// The package channel to use for this prepare operation, or to use the project configuration. /// Optional package source to prefer for Aspire package restore. + /// Cancellation token. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null); + string? packageSourceOverride = null, + CancellationToken cancellationToken = default); /// /// Runs the AppHost server process. diff --git a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs index 854571a1ae2..329e7e70f37 100644 --- a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs +++ b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs @@ -12,8 +12,8 @@ internal interface IGuestAppHostSdkGenerator /// Builds any required server components and generates guest SDK artifacts. /// /// The AppHost project directory. - /// A cancellation token. /// Optional package source to prefer for Aspire package restore during the build. + /// A cancellation token. /// if SDK generation succeeded; otherwise, . - Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null); -} \ No newline at end of file + Task BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride = null, CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 67509d6ec0b..c9c735cf3cc 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -127,9 +127,9 @@ public string GetServerPath() public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { var integrationList = integrations.ToList(); var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); @@ -319,11 +319,11 @@ private async Task BuildIntegrationClosureManifest Directory.CreateDirectory(restoreDir); // Only synthesize a temp NuGet.config (replacing nuget.config discovery via - // RestoreConfigFile) when the user explicitly opted into a single source via --source. - // The explicit-channel-no-override path keeps the user's ambient nuget.config in place - // and contributes channel mappings additively via RestoreAdditionalProjectSources so - // private/internal feeds the user has configured remain reachable for non-Aspire - // transitives during project-ref restore. + // RestoreConfigFile) when an explicit --source or auto-discovered local channel source + // is in play. The explicit-channel-no-override path keeps the user's ambient + // nuget.config in place and contributes channel mappings additively via + // RestoreAdditionalProjectSources so private/internal feeds the user has configured + // remain reachable for non-Aspire transitives during project-ref restore. using var temporaryNuGetConfig = !string.IsNullOrWhiteSpace(packageSourceOverride) ? await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken) : null; @@ -651,6 +651,11 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) internal async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { + // Keep staging refusal consistent across both temp-config branches. The project-reference + // restore path skips GetNuGetSourcesAsync when a temp config exists, so this method must + // surface the actionable staging-unavailable reason before building any override config. + ThrowIfStagingUnavailable(requestedChannel); + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) { // Treat an explicit --source value as the preferred source for Aspire packages. @@ -712,11 +717,6 @@ [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.So return null; } - // Same staging refusal as GetNuGetSourcesAsync: if the CLI cannot synthesize staging, - // surface the actionable reason instead of returning null and letting restore proceed - // against whichever sources the caller resolved separately. - ThrowIfStagingUnavailable(requestedChannel); - PackageChannel? channel; try { diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 6342897e231..1ad9c09420f 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -97,7 +97,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can var prepareResult = await _interactionService.ShowStatusAsync( "Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken, requestedChannel: context.Channel, packageSourceOverride: context.PackageSourceOverride), + () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, requestedChannel: context.Channel, packageSourceOverride: context.PackageSourceOverride, cancellationToken: cancellationToken), emoji: KnownEmojis.Gear); if (!prepareResult.Success) { diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs index 2c26a9dff6f..ca69b8d6b4d 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs @@ -81,7 +81,7 @@ private async Task ApplyGoStarterTemplateAsync(CallbackTemplate } _logger.LogDebug("Generating SDK code for Go starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), packageSourceOverride: inputs.Source, cancellationToken: cancellationToken); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Go starter project. Run 'aspire restore' in the project directory for more details."); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs index 497bf767b7f..69e394afbcc 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -95,7 +95,7 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( } _logger.LogDebug("Generating SDK code for Python starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), packageSourceOverride: inputs.Source, cancellationToken: cancellationToken); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details."); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index e54bb7f06ec..9d76ba58d8e 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -80,7 +80,7 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT } _logger.LogDebug("Generating SDK code for TypeScript starter in '{OutputPath}'.", outputPath); - var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken, packageSourceOverride: inputs.Source); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), packageSourceOverride: inputs.Source, cancellationToken: cancellationToken); if (!restoreSucceeded) { _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details."); diff --git a/src/Aspire.Cli/Utils/PackageSourceRedactor.cs b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs index 02a1c6ee297..29300c35fed 100644 --- a/src/Aspire.Cli/Utils/PackageSourceRedactor.cs +++ b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs @@ -20,8 +20,10 @@ internal static class PackageSourceRedactor /// /// Fails closed for HTTP-shaped inputs that /// cannot parse (for example https://user:p@ss@host/path or - /// https://user:p#word@host/): returns a sentinel rather than the raw input. Plain - /// non-HTTP-looking inputs (local paths, file://, etc.) still pass through unchanged. + /// https://user:p#word@host/): returns a sentinel rather than the raw input. Leading + /// and trailing whitespace is ignored for HTTP detection so indented feed URLs are still + /// protected. Plain non-HTTP-looking inputs (local paths, file://, etc.) still pass through + /// unchanged. /// public static string RedactForDisplay(string source) { @@ -30,14 +32,20 @@ public static string RedactForDisplay(string source) return source; } + var sourceToParse = source.Trim(); + if (sourceToParse.Length == 0) + { + return source; + } + // Detect HTTP-shaped inputs before attempting to parse so malformed URLs that look like // an HTTP feed fail closed instead of leaking credentials through the parse-failure - // branch below. + // branch below. Trim first because NuGet sources in config/output can be indented. var looksHttp = - source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - source.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + sourceToParse.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + sourceToParse.StartsWith("https://", StringComparison.OrdinalIgnoreCase); - if (!Uri.TryCreate(source, UriKind.Absolute, out var uri) || + if (!Uri.TryCreate(sourceToParse, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { return looksHttp ? UnparseableHttpSentinel : source; @@ -48,7 +56,7 @@ public static string RedactForDisplay(string source) var hasFragment = !string.IsNullOrEmpty(uri.Fragment); if (!hasUserInfo && !hasQuery && !hasFragment) { - return source; + return sourceToParse; } var builder = new UriBuilder(uri) diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 1fe80bb6059..6ef1f7fc86b 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -395,7 +395,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0") }; - var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages, CancellationToken.None, packageSourceOverride: overrideSource).DefaultTimeout(); + var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages, packageSourceOverride: overrideSource, cancellationToken: CancellationToken.None).DefaultTimeout(); var projectDoc = XDocument.Load(projectFilePath); var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value; diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 7566dfb6637..1a13d1af777 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -133,9 +133,9 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) => + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 547b4c8315b..7c94fc81d5d 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -744,6 +744,21 @@ public async Task TryCreateTemporaryNuGetConfig_StagingRequested_RefusesWhenPack Assert.Equal(unavailableReason, ex.Message); } + [Fact] + public async Task TryCreateTemporaryNuGetConfig_StagingRequestedWithSourceOverride_RefusesWhenPackagingServiceReportsUnavailable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithIdentityChannel("daily"); + const string unavailableReason = + "Staging unavailable on this daily CLI build. Set overrideStagingFeed or enable the StagingChannelEnabled feature flag to use it."; + var server = CreateServerWithUnavailableStagingChannel(workspace, executionContext, unavailableReason); + + var ex = await Assert.ThrowsAsync( + () => InvokeTryCreateTemporaryNuGetConfigAsync(server, "staging", "/tmp/aspire-pr-hive/packages")); + Assert.Equal(unavailableReason, ex.Message); + } + [Fact] public async Task GetNuGetSources_StagingRequested_RefusesWhenPackagingServiceReportsUnavailable() { diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 9152c574fc2..e553ca6ca29 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -130,9 +130,9 @@ private sealed class CapturingAppHostServerProject(string appDirectoryPath) : IA public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { PackageSourceOverride = packageSourceOverride; return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index 609c7f42ba9..c9e85d24996 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -24,9 +24,9 @@ internal sealed class FakeFailingAppHostServerProject(string appDirectoryPath) : public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, string? requestedChannel = null, - string? packageSourceOverride = null) => + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs index 59ddbeb912f..61621a688b7 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs @@ -107,7 +107,7 @@ public Task FindAndStopRunningInstanceAsync(FileInfo appH throw new NotImplementedException(); } - public Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken, string? packageSourceOverride = null) + public Task BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { LastPackageSourceOverride = packageSourceOverride; return buildAndGenerateSdkAsync(directory, cancellationToken, packageSourceOverride); diff --git a/tests/Aspire.Cli.Tests/Utils/PackageSourceRedactorTests.cs b/tests/Aspire.Cli.Tests/Utils/PackageSourceRedactorTests.cs new file mode 100644 index 00000000000..506fea3e1a4 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/PackageSourceRedactorTests.cs @@ -0,0 +1,43 @@ +// 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.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class PackageSourceRedactorTests +{ + [Theory] + [InlineData("https://api.nuget.org/v3/index.json", "https://api.nuget.org/v3/index.json")] + [InlineData(" https://api.nuget.org/v3/index.json ", "https://api.nuget.org/v3/index.json")] + [InlineData("https://user:pat@feed.example.com/v3/index.json", "https://***@feed.example.com/v3/index.json")] + [InlineData("https://feed.example.com/v3/index.json?sig=secret", "https://feed.example.com/v3/index.json")] + [InlineData("https://feed.example.com/v3/index.json#fragment", "https://feed.example.com/v3/index.json")] + [InlineData("https://user:pat@feed.example.com/v3/index.json?sig=secret#fragment", "https://***@feed.example.com/v3/index.json")] + public void RedactForDisplay_RedactsHttpSources(string source, string expected) + { + Assert.Equal(expected, PackageSourceRedactor.RedactForDisplay(source)); + } + + [Theory] + [InlineData("https://user:p@ss@host/path")] + [InlineData("https://user:p#word@host/")] + [InlineData("http://foo bar/path")] + [InlineData(" HTTPS://user:p@ss@host/path ")] + public void RedactForDisplay_FailsClosedForMalformedHttpSources(string source) + { + Assert.Equal("", PackageSourceRedactor.RedactForDisplay(source)); + } + + [Theory] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("/tmp/aspire-packages", "/tmp/aspire-packages")] + [InlineData(@"C:\packages", @"C:\packages")] + [InlineData("file:///tmp/aspire-packages", "file:///tmp/aspire-packages")] + [InlineData("/tmp/aspire/some path with [brackets]", "/tmp/aspire/some path with [brackets]")] + public void RedactForDisplay_PreservesNonHttpSources(string source, string expected) + { + Assert.Equal(expected, PackageSourceRedactor.RedactForDisplay(source)); + } +} From d1564d2fc6d62094758ed95f143a95aa35863240 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 14:59:46 -0400 Subject: [PATCH 15/17] fix(cli): persist aspire new source overrides An explicit `aspire new --source ` previously only affected the initial scaffold restore. The generated project did not record that source, so later `aspire add` or `aspire restore` could fall back to channel or ambient NuGet configuration and lose the Aspire package source selected at creation time. Persist source overrides into the generated project's NuGet.config by mapping `Aspire*` to the explicit source and keeping non-Aspire fallback sources from the resolved channel, or NuGet.org when no channel fallback is available. The persisted config remains self-contained: it does not import parent, user, or global NuGet sources, mappings, disabled sources, or credentials; only an existing project-local NuGet.config is merged. Remove the stale warning that source overrides are not persisted, share the source-override mapping logic with the prebuilt restore path, and add tests covering empty templates, starter templates, .NET templates, existing config merge behavior, and ambient-config non-absorption. Refs #17159 Refs #17225 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/NuGetConfigMerger.cs | 50 ++++-- .../PackageSourceOverrideMappings.cs | 39 +++++ .../Projects/PrebuiltAppHostServer.cs | 37 +--- .../CliTemplateFactory.EmptyTemplate.cs | 12 +- .../CliTemplateFactory.GoStarterTemplate.cs | 3 +- ...liTemplateFactory.PythonStarterTemplate.cs | 3 +- ...mplateFactory.TypeScriptStarterTemplate.cs | 3 +- .../Templating/CliTemplateFactory.cs | 17 -- .../Templating/DotNetTemplateFactory.cs | 5 +- .../Templating/TemplateNuGetConfigService.cs | 49 ++++++ .../Commands/NewCommandTests.cs | 98 ++++++++++- .../TemplateNuGetConfigServiceTests.cs | 160 ++++++++++++++++++ 12 files changed, 400 insertions(+), 76 deletions(-) create mode 100644 src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs index c4edd7a1974..d245300feef 100644 --- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs +++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs @@ -42,6 +42,27 @@ public static async Task CreateOrUpdateAsync(DirectoryInfo targetDirectory, Pack return; } + await CreateOrUpdateAsync(targetDirectory, mappings, channel.ConfigureGlobalPackagesFolder, confirmationCallback, cancellationToken); + } + + /// + /// Creates or updates a NuGet.config file in the specified directory based on the provided package source mappings. + /// + public static async Task CreateOrUpdateAsync( + DirectoryInfo targetDirectory, + PackageMapping[] mappings, + bool configureGlobalPackagesFolder = false, + Func>? confirmationCallback = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(targetDirectory); + ArgumentNullException.ThrowIfNull(mappings); + + if (mappings.Length == 0) + { + return; + } + if (!targetDirectory.Exists) { targetDirectory.Create(); @@ -49,18 +70,17 @@ public static async Task CreateOrUpdateAsync(DirectoryInfo targetDirectory, Pack if (!TryFindNuGetConfigInDirectory(targetDirectory, out var nugetConfigFile)) { - await CreateNewNuGetConfigAsync(targetDirectory, channel, confirmationCallback, cancellationToken); + await CreateNewNuGetConfigAsync(targetDirectory, mappings, configureGlobalPackagesFolder, confirmationCallback, cancellationToken); } else { - await UpdateExistingNuGetConfigAsync(nugetConfigFile, channel, confirmationCallback, cancellationToken); + await UpdateExistingNuGetConfigAsync(nugetConfigFile, mappings, configureGlobalPackagesFolder, confirmationCallback, cancellationToken); } } - private static async Task CreateNewNuGetConfigAsync(DirectoryInfo targetDirectory, PackageChannel channel, Func>? confirmationCallback, CancellationToken cancellationToken) + private static async Task CreateNewNuGetConfigAsync(DirectoryInfo targetDirectory, PackageMapping[] mappings, bool configureGlobalPackagesFolder, Func>? confirmationCallback, CancellationToken cancellationToken) { - var mappings = channel.Mappings; - if (mappings is null || mappings.Length == 0) + if (mappings.Length == 0) { return; } @@ -83,7 +103,7 @@ private static async Task CreateNewNuGetConfigAsync(DirectoryInfo targetDirector } } - if (channel.ConfigureGlobalPackagesFolder) + if (configureGlobalPackagesFolder) { // Need to modify the temporary config to add globalPackagesFolder before copying await AddGlobalPackagesFolderToConfigAsync(tmpConfig.ConfigFile); @@ -92,10 +112,9 @@ private static async Task CreateNewNuGetConfigAsync(DirectoryInfo targetDirector File.Copy(tmpConfig.ConfigFile.FullName, targetPath, overwrite: true); } - private static async Task UpdateExistingNuGetConfigAsync(FileInfo nugetConfigFile, PackageChannel channel, Func>? confirmationCallback, CancellationToken cancellationToken) + private static async Task UpdateExistingNuGetConfigAsync(FileInfo nugetConfigFile, PackageMapping[] mappings, bool configureGlobalPackagesFolder, Func>? confirmationCallback, CancellationToken cancellationToken) { - var mappings = channel.Mappings; - if (mappings is null || mappings.Length == 0) + if (mappings.Length == 0) { return; } @@ -136,7 +155,7 @@ private static async Task UpdateExistingNuGetConfigAsync(FileInfo nugetConfigFil } } - if (channel.ConfigureGlobalPackagesFolder) + if (configureGlobalPackagesFolder) { AddGlobalPackagesFolderConfiguration(configContext); } @@ -633,6 +652,17 @@ private static void HandleWildcardMappingForExistingSources( var sourceElement = context.ExistingAdds .FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase)); var sourceValue = (string?)sourceElement?.Attribute("value"); + var isRequiredByCurrentChannel = context.RequiredSources.Contains(sourceKey, StringComparer.OrdinalIgnoreCase) || + context.RequiredSources.Contains(sourceValue ?? "", StringComparer.OrdinalIgnoreCase); + var requiredSourceHasWildcard = context.Mappings.Any(m => + m.PackageFilter == PackageMapping.AllPackages && + (string.Equals(m.Source, sourceKey, StringComparison.OrdinalIgnoreCase) || + string.Equals(m.Source, sourceValue, StringComparison.OrdinalIgnoreCase))); + + if (isRequiredByCurrentChannel && !requiredSourceHasWildcard) + { + continue; + } // For user-defined sources that still have patterns, also give them wildcard patterns // to ensure they can serve other packages too. But skip Microsoft-controlled sources diff --git a/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs new file mode 100644 index 00000000000..7325685c156 --- /dev/null +++ b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs @@ -0,0 +1,39 @@ +// 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.Packaging; + +internal static class PackageSourceOverrideMappings +{ + internal const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + + public static PackageMapping[] Create(string packageSourceOverride, PackageChannel? requestedChannel) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageSourceOverride); + + var mappings = new List + { + new("Aspire*", packageSourceOverride) + }; + + if (requestedChannel?.Mappings is not null) + { + foreach (var mapping in requestedChannel.Mappings) + { + if (mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + mappings.Add(mapping); + } + } + + if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)) + { + mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)); + } + + return [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")]; + } +} diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index c9c735cf3cc..9f1cdc08ae1 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -26,10 +26,6 @@ namespace Aspire.Cli.Projects; /// internal sealed class PrebuiltAppHostServer : IAppHostServerProject { - // An explicit source is preferred for Aspire packages. Keep NuGet.org available for - // non-Aspire packages and transitive dependencies that are outside the Aspire* mapping. - private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; - internal const string ClosureMetadataFileName = "closure-metadata.txt"; internal const string ClosureSourcesFileName = "closure-sources.txt"; internal const string ClosureTargetsFileName = "closure-targets.txt"; @@ -636,9 +632,9 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) // --source argument list must agree so non-Aspire transitives have the same // catch-all source in both views. if (hasOverride && !matchedChannelHasAllPackagesMapping && - !sources.Contains(NuGetOrgSource, StringComparer.OrdinalIgnoreCase)) + !sources.Contains(PackageSourceOverrideMappings.NuGetOrgSource, StringComparer.OrdinalIgnoreCase)) { - sources.Add(NuGetOrgSource); + sources.Add(PackageSourceOverrideMappings.NuGetOrgSource); } } catch (Exception ex) @@ -661,10 +657,7 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) // Treat an explicit --source value as the preferred source for Aspire packages. // Build a temporary NuGet.config that routes Aspire* there, optionally preserves // non-Aspire channel mappings, and leaves a fallback source for non-Aspire deps. - var mappings = new List - { - new("Aspire*", packageSourceOverride) - }; + PackageChannel? matchedChannel = null; var configureGlobalPackagesFolder = false; try @@ -676,23 +669,10 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) if (!string.IsNullOrEmpty(requestedChannel)) { var packageChannels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); - var matchedChannel = packageChannels.FirstOrDefault(c => + matchedChannel = packageChannels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); - if (matchedChannel?.Mappings is not null) + if (matchedChannel is not null) { - foreach (var mapping in matchedChannel.Mappings) - { - // Drop any Aspire-prefixed mapping — the --source override owns - // Aspire restoration exclusively. Non-Aspire patterns (e.g. - // CommunityToolkit*, catch-all *) are preserved. - if (mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - mappings.Add(mapping); - } - configureGlobalPackagesFolder |= matchedChannel.ConfigureGlobalPackagesFolder; } } @@ -702,13 +682,8 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) _logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config"); } - if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)) - { - mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)); - } - return await TemporaryNuGetConfig.CreateAsync( - [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")], + PackageSourceOverrideMappings.Create(packageSourceOverride, matchedChannel), configureGlobalPackagesFolder); } diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index d45cdcec3ca..60da7ab279a 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -57,7 +57,10 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla if (isCsharp) { // Do this first so there is no prompt while status is displayed for creating project. - await _templateNuGetConfigService.PromptToCreateOrUpdateNuGetConfigAsync(inputs.Channel, outputPath, cancellationToken); + if (!await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken)) + { + await _templateNuGetConfigService.PromptToCreateOrUpdateNuGetConfigAsync(inputs.Channel, outputPath, cancellationToken); + } } templateResult = await _interactionService.ShowStatusAsync( @@ -88,8 +91,6 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla { await ApplyLocalhostTldToScaffoldedRunProfileAsync(outputPath, projectName, cancellationToken); } - - DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); } return new TemplateResult((int)CliExitCodes.Success, outputPath); @@ -99,6 +100,11 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla { return templateResult; } + + if (!isCsharp) + { + await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken); + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs index ca69b8d6b4d..b44dc2a01cf 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs @@ -87,8 +87,7 @@ private async Task ApplyGoStarterTemplateAsync(CallbackTemplate _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Go starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } - - DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken); return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs index 69e394afbcc..a4561d61d6d 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -101,8 +101,7 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } - - DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken); return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index 9d76ba58d8e..b837f35fa5d 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -86,8 +86,7 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT _interactionService.DisplayError("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } - - DisplaySourceOverrideNotPersistedWarningIfNeeded(inputs.Source); + await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken); return new TemplateResult((int)CliExitCodes.Success, outputPath); }), emoji: KnownEmojis.Rocket); diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index d29560bc2c3..f67b7ba631b 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -261,23 +261,6 @@ private static AppHostProfilePorts GenerateRandomPorts() return AppHostProfilePortGenerator.Generate(Random.Shared); } - // The --source override is consumed only during the initial scaffold restore inside - // PrebuiltAppHostServer / the guest AppHost build path; nothing in the scaffolded project - // records the source, so a later `aspire restore` / `aspire add` will use the channel - // feeds resolved from aspire.config.json. Surface that so users supplying - // `--source /packages` aren't surprised when subsequent commands fail to find - // the same packages. Persisting the feed is tracked as a follow-up. - private void DisplaySourceOverrideNotPersistedWarningIfNeeded(string? source) - { - if (!string.IsNullOrWhiteSpace(source)) - { - _interactionService.DisplayMessage( - KnownEmojis.Warning, - TemplatingStrings.SourceOverrideNotPersistedWarning, - allowMarkup: true); - } - } - private static void AddOptionIfMissing(System.CommandLine.Command command, System.CommandLine.Option option) { if (!command.Options.Contains(option)) diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index b4a45bb817a..1bcbcdbf7ef 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -546,7 +546,10 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, // For explicit channels, optionally create or update a NuGet.config. If none exists in the current // working directory, create one in the newly created project's output directory. - await templateNuGetConfigService.PromptToCreateOrUpdateNuGetConfigAsync(selectedTemplateDetails.Channel, outputPath, cancellationToken); + if (!await TemplateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, selectedTemplateDetails.Channel, outputPath, cancellationToken)) + { + await templateNuGetConfigService.PromptToCreateOrUpdateNuGetConfigAsync(selectedTemplateDetails.Channel, outputPath, cancellationToken); + } interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreatedSuccessfully, outputPath)); diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index 580d37c0344..67181150b86 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -145,6 +145,55 @@ public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync(string? chan return true; } + /// + /// Creates or updates a project NuGet.config that maps Aspire packages to an explicit package source override. + /// + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync( + string? sourceOverride, + string? channelName, + string outputPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sourceOverride)) + { + return false; + } + + PackageChannel? matchingChannel = null; + + if (!string.IsNullOrWhiteSpace(channelName)) + { + var channels = await packagingService.GetChannelsAsync(cancellationToken, channelName); + matchingChannel = channels.FirstOrDefault(c => + string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + } + + return await CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, matchingChannel, outputPath, cancellationToken); + } + + /// + /// Creates or updates a project NuGet.config that maps Aspire packages to an explicit package source override. + /// + public static async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync( + string? sourceOverride, + PackageChannel? channel, + string outputPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sourceOverride)) + { + return false; + } + + var mappings = PackageSourceOverrideMappings.Create(sourceOverride, channel); + await NuGetConfigMerger.CreateOrUpdateAsync( + new DirectoryInfo(outputPath), + mappings, + channel?.ConfigureGlobalPackagesFolder ?? false, + cancellationToken: cancellationToken); + return true; + } + /// /// Resolves the channel and template package version that should be used to install Aspire project templates. /// diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 223eb3b84e0..406735b122e 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Xml.Linq; using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; @@ -710,6 +711,36 @@ private static TestDotNetCliRunner CreateTestRunnerWithStandardPackages() return runner; } + private static void AssertSourceOverrideNuGetConfig(string outputPath, string sourceOverride) + { + var doc = XDocument.Load(Path.Combine(outputPath, "nuget.config")); + var packageSources = doc.Root!.Element("packageSources")!; + + Assert.Contains(packageSources.Elements("clear"), _ => true); + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride); + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == PackageSourceOverrideMappings.NuGetOrgSource); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, PackageSourceOverrideMappings.NuGetOrgSource)); + } + + private static string[] GetPackagePatternsForSource(XDocument doc, string source) + { + var packageSourceMapping = doc.Root!.Element("packageSourceMapping"); + if (packageSourceMapping is null) + { + return []; + } + + return packageSourceMapping + .Elements("packageSource") + .Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase)) + .Elements("package") + .Select(e => (string?)e.Attribute("pattern")) + .Where(pattern => pattern is not null) + .Select(pattern => pattern!) + .ToArray(); + } + private sealed class ThrowingCertificateService : ICertificateService { public Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) @@ -1157,7 +1188,7 @@ public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCrea [InlineData("python", "experimentalPolyglot:python", "apphost.py")] [InlineData("go", "experimentalPolyglot:go", "apphost.go")] [InlineData("rust", "experimentalPolyglot:rust", "apphost.rs")] - public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideIsNotPersisted(string language, string? featureFlag, string scaffoldFileName) + public async Task NewCommandWithEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore(string language, string? featureFlag, string scaffoldFileName) { using var workspace = TemporaryWorkspace.Create(outputHelper); const string sourceOverride = "/tmp/aspire-pr-hive/packages"; @@ -1195,11 +1226,29 @@ public async Task NewCommandWithEmptyTemplateAndSourceOverrideWarnsThatOverrideI var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); Assert.Equal(sourceOverride, capturedPackageSourceOverride); + AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); Assert.NotNull(interactionService); - Assert.Contains( + Assert.DoesNotContain( interactionService!.DisplayedMessages, - entry => entry.Emoji.Name == KnownEmojis.Warning.Name - && entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); + entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); + } + + [Fact] + public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + var services = CreateServiceCollection(workspace); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --source {sourceOverride}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); } [Fact] @@ -1792,7 +1841,7 @@ public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWh } [Fact] - public async Task NewCommandWithTypeScriptStarterAndSourceOverrideWarnsAndPlumbsOverride() + public async Task NewCommandWithTypeScriptStarterAndSourceOverridePersistsSourceAndPlumbsOverride() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string sourceOverride = "/tmp/aspire-pr-hive/packages"; @@ -1844,11 +1893,44 @@ public async Task NewCommandWithTypeScriptStarterAndSourceOverrideWarnsAndPlumbs Assert.Equal(CliExitCodes.Success, exitCode); Assert.Equal(sourceOverride, projectFactory.Project.LastPackageSourceOverride); + AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); Assert.NotNull(interactionService); - Assert.Contains( + Assert.DoesNotContain( interactionService!.DisplayedMessages, - entry => entry.Emoji.Name == KnownEmojis.Warning.Name - && entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); + entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning); + } + + [Fact] + public async Task NewCommandWithDotNetTemplateAndSourceOverridePersistsSourceForLaterRestore() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + var services = CreateServiceCollection(workspace, options => + { + options.DotNetCliRunnerFactory = _ => + { + var runner = CreateTestRunnerWithStandardPackages(); + runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, cancellationToken) => + { + return (0, version); + }; + runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, cancellationToken) => + { + return 0; + }; + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-starter --name TestApp --output ./output --source {sourceOverride} --use-redis-cache --test-framework None"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs index 2c61c860ed7..efe7fea770f 100644 --- a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; +using System.Xml.Linq; namespace Aspire.Cli.Tests.Templating; @@ -21,6 +22,147 @@ namespace Aspire.Cli.Tests.Templating; /// public class TemplateNuGetConfigServiceTests(ITestOutputHelper outputHelper) { + [Fact] + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CreatesSelfContainedConfigWithoutAmbientSources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output"); + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"), + """ + + + + + + + + + + + + + + + + + + + + + """); + + var service = CreateService(); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None)); + + var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config")); + Assert.Contains(doc.Root!.Element("packageSources")!.Elements("clear"), _ => true); + Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride); + Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == PackageSourceOverrideMappings.NuGetOrgSource); + Assert.DoesNotContain(doc.Descendants("add"), e => (string?)e.Attribute("value") == "https://private.example/v3/index.json"); + Assert.Null(doc.Root!.Element("disabledPackageSources")); + Assert.Null(doc.Root!.Element("packageSourceCredentials")); + Assert.Empty(GetPackagePatternsForSource(doc, "ambient-private")); + } + + [Fact] + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_PreservesRequestedChannelFallbackMappings() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output"); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + const string channelAspireSource = "https://example.invalid/aspire"; + const string communitySource = "https://example.invalid/community"; + const string fallbackSource = "https://example.invalid/fallback"; + + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => + { + var channel = PackageChannel.CreateExplicitChannel( + "daily", + PackageChannelQuality.Both, + [ + new PackageMapping("Aspire*", channelAspireSource), + new PackageMapping("CommunityToolkit*", communitySource), + new PackageMapping(PackageMapping.AllPackages, fallbackSource), + ], + new FakeNuGetPackageCache()); + return Task.FromResult>([channel]); + } + }; + var service = CreateService(packagingService: packagingService); + + Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: "daily", outputDirectory.FullName, CancellationToken.None)); + + var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config")); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride)); + Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, communitySource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, fallbackSource)); + Assert.Empty(GetPackagePatternsForSource(doc, channelAspireSource)); + Assert.Empty(GetPackagePatternsForSource(doc, PackageSourceOverrideMappings.NuGetOrgSource)); + } + + [Fact] + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_UpdatesOnlyProjectLocalConfig() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output"); + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"), + """ + + + + + + + """); + await File.WriteAllTextAsync( + Path.Combine(outputDirectory.FullName, "nuget.config"), + """ + + + + + + + + + + + + """); + + var service = CreateService(); + const string sourceOverride = "/tmp/aspire-pr-hive/packages"; + + Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None)); + + var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config")); + Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://project.example/v3/index.json"); + Assert.DoesNotContain(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://parent.example/v3/index.json"); + Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride)); + Assert.Equal(["Project.*", PackageMapping.AllPackages], GetPackagePatternsForSource(doc, "project-local")); + } + + [Fact] + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_NullSourceShortCircuits() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup should not run without a source override.") + }; + var service = CreateService(packagingService: packagingService); + + Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: null, channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None)); + Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: "", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None)); + Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: " ", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None)); + } + [Fact] public async Task PromptToCreateOrUpdateNuGetConfigAsync_NullChannelName_ShortCircuits() { @@ -266,6 +408,24 @@ private static CliExecutionContext CreateExecutionContextWithHives(DirectoryInfo hivesDirectory: hivesDirectory); } + private static string[] GetPackagePatternsForSource(XDocument doc, string source) + { + var packageSourceMapping = doc.Root!.Element("packageSourceMapping"); + if (packageSourceMapping is null) + { + return []; + } + + return packageSourceMapping + .Elements("packageSource") + .Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase)) + .Elements("package") + .Select(e => (string?)e.Attribute("pattern")) + .Where(pattern => pattern is not null) + .Select(pattern => pattern!) + .ToArray(); + } + private static TemplateNuGetConfigService CreateService( TestPackagingService? packagingService = null, CliExecutionContext? executionContext = null) From 0bd9fce4dc5452edfbf5a0712a7cab7376103254 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 15:12:25 -0400 Subject: [PATCH 16/17] fix(cli): reject credentialed new sources before persistence Persisting `aspire new --source` into a project NuGet.config makes the source durable project state. Credential-bearing HTTP URLs should not be written there because the generated file can be committed accidentally. Reject HTTP(S) sources that contain user info, query strings, or fragments before project creation starts, and keep the lower-level mapping helper from persisting those sources if it is called directly. The error points users at NuGet credential providers or user-level NuGet configuration instead of embedding secrets in the feed URL. Refs #17159 Refs #17225 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 9 ++++- .../PackageSourceOverrideMappings.cs | 14 +++++++ .../Resources/NewCommandStrings.Designer.cs | 6 +++ .../Resources/NewCommandStrings.resx | 3 ++ .../Resources/xlf/NewCommandStrings.cs.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.de.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.es.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.fr.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.it.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.ja.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.ko.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.pl.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.pt-BR.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.ru.xlf | 5 +++ .../Resources/xlf/NewCommandStrings.tr.xlf | 5 +++ .../xlf/NewCommandStrings.zh-Hans.xlf | 5 +++ .../xlf/NewCommandStrings.zh-Hant.xlf | 5 +++ .../Commands/NewCommandTests.cs | 37 +++++++++++++++++++ .../TemplateNuGetConfigServiceTests.cs | 15 ++++++++ 19 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index ded84d0fd06..50044ec8951 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -412,6 +412,13 @@ protected override async Task ExecuteAsync(ParseResult parseResul { using var activity = Telemetry.StartDiagnosticActivity(this.Name); + var source = parseResult.GetValue(s_sourceOption); + if (!string.IsNullOrWhiteSpace(source) && PackageSourceOverrideMappings.HasCredentialMaterial(source)) + { + InteractionService.DisplayError(NewCommandStrings.SourceWithCredentialsCannotBePersisted); + return CommandResult.Failure(CliExitCodes.InvalidCommand); + } + // Resolve which templates are actually available at runtime (performs // async checks like SDK availability). This may be a subset of the // templates registered as subcommands. @@ -448,7 +455,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul { Name = parseResult.GetValue(s_nameOption), Output = parseResult.GetValue(s_outputOption), - Source = parseResult.GetValue(s_sourceOption), + Source = source, Version = version, Channel = parseResult.GetValue(_channelOption) ?? resolvedChannelName, Language = selectedLanguageId diff --git a/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs index 7325685c156..6858745d8d5 100644 --- a/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs +++ b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs @@ -10,6 +10,10 @@ internal static class PackageSourceOverrideMappings public static PackageMapping[] Create(string packageSourceOverride, PackageChannel? requestedChannel) { ArgumentException.ThrowIfNullOrWhiteSpace(packageSourceOverride); + if (HasCredentialMaterial(packageSourceOverride)) + { + throw new ArgumentException("Credential-bearing HTTP sources cannot be persisted.", nameof(packageSourceOverride)); + } var mappings = new List { @@ -36,4 +40,14 @@ public static PackageMapping[] Create(string packageSourceOverride, PackageChann return [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")]; } + + public static bool HasCredentialMaterial(string source) + { + return Uri.TryCreate(source.Trim(), UriKind.Absolute, out var uri) && + (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) && + (!string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment)); + } } diff --git a/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs index 001689d8bf3..c7a80de6a61 100644 --- a/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/NewCommandStrings.Designer.cs @@ -68,6 +68,12 @@ public static string SourceArgumentDescription { return ResourceManager.GetString("SourceArgumentDescription", resourceCulture); } } + + public static string SourceWithCredentialsCannotBePersisted { + get { + return ResourceManager.GetString("SourceWithCredentialsCannotBePersisted", resourceCulture); + } + } public static string VersionArgumentDescription { get { diff --git a/src/Aspire.Cli/Resources/NewCommandStrings.resx b/src/Aspire.Cli/Resources/NewCommandStrings.resx index 32b4b84bc45..6bd751d494d 100644 --- a/src/Aspire.Cli/Resources/NewCommandStrings.resx +++ b/src/Aspire.Cli/Resources/NewCommandStrings.resx @@ -118,6 +118,9 @@ The NuGet source to use for the project templates + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The version of the project templates to use diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf index 75421626a0e..a98a45772c7 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf @@ -97,6 +97,11 @@ Zdroj NuGet, který se má použít pro šablony projektu + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (použít šablony předběžné verze) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf index e1aa7b2e31f..7ed1251e153 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf @@ -97,6 +97,11 @@ Die NuGet-Quelle, die für die Projektvorlagen verwendet werden soll. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (Vorabversionsvorlagen verwenden) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf index 7c437f8c5fc..d723836cfc5 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf @@ -97,6 +97,11 @@ El origen de NuGet que se va a usar para las plantillas de proyecto. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (usar plantillas de versión preliminar) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf index 3d1b9b0afd3..70f15b42433 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf @@ -97,6 +97,11 @@ La source NuGet à utiliser pour les modèles de projet. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (utilisez des modèles de préversion) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf index b37631f57f2..4995106ebc3 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf @@ -97,6 +97,11 @@ Origine NuGet da utilizzare per i modelli di progetto. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (usa modelli di versioni non definitive) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf index 9558a64f195..9018d729344 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf @@ -97,6 +97,11 @@ プロジェクト テンプレートに使用する NuGet ソース。 + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (プレリリース テンプレートを使用する) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf index c3ce8d97d5b..3f09f3739bc 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf @@ -97,6 +97,11 @@ 프로젝트 템플릿에 사용할 NuGet 소스입니다. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (사전 릴리스 템플릿 사용) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf index 0bdc487fd55..9a8c38bbe61 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf @@ -97,6 +97,11 @@ Źródło NuGet, które ma być używane dla szablonów projektu. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (użyj szablonów wersji wstępnej) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf index e0bff8a3107..741a8100eaa 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf @@ -97,6 +97,11 @@ A origem do NuGet a ser usada para os modelos de projeto. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (usar modelos de pré-lançamento) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf index cbfc42fb7ee..4ba8ae6165e 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf @@ -97,6 +97,11 @@ Источник NuGet для использования в шаблонах проекта. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (использовать шаблоны предварительного выпуска) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf index 741f73b1a5c..c4091a07f87 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf @@ -97,6 +97,11 @@ Proje şablonları için kullanılacak NuGet kaynağı. + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (ön sürüm şablonlarını kullanın) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf index d2b4bd90e7f..138ddbc540c 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf @@ -97,6 +97,11 @@ 要用于项目模板的 NuGet 源。 + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (使用预发布模板) diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf index 5539a753ad3..42297c6b568 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf @@ -97,6 +97,11 @@ 要用於專案範本的 NuGet 來源。 + + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + The --source URL includes credentials, a query string, or a fragment and cannot be written to the project's NuGet.config. Configure credentials with NuGet credential providers or user-level NuGet configuration, then pass the feed URL without embedded secrets. + + (use pre-release templates) (使用發行前版本的範本) diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 406735b122e..01918d1f74d 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1251,6 +1251,43 @@ public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverridePersistsSour AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); } + [Theory] + [InlineData("https://user:token@example.invalid/v3/index.json")] + [InlineData("https://example.invalid/v3/index.json?sig=token")] + [InlineData("https://example.invalid/v3/index.json#token")] + public async Task NewCommandWithCredentialBearingHttpSourceFailsBeforeCreatingProject(string sourceOverride) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var scaffoldingInvoked = false; + TestInteractionService? interactionService = null; + + var services = CreateServiceCollection(workspace, options => + { + options.InteractionServiceFactory = _ => interactionService = new TestInteractionService(); + }); + + services.AddSingleton(new TestScaffoldingService + { + ScaffoldAsyncCallback = (_, _) => + { + scaffoldingInvoked = true; + return Task.FromResult(true); + } + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source {sourceOverride}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.InvalidCommand, exitCode); + Assert.False(scaffoldingInvoked); + Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output"))); + Assert.NotNull(interactionService); + Assert.Contains(NewCommandStrings.SourceWithCredentialsCannotBePersisted, interactionService!.DisplayedErrors); + } + [Fact] public async Task NewCommandWithEmptyTemplateWithoutSourceOverrideDoesNotWarn() { diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs index efe7fea770f..53a33b520a4 100644 --- a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs @@ -163,6 +163,21 @@ public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_NullSourceShor Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: " ", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None)); } + [Theory] + [InlineData("https://user:token@example.invalid/v3/index.json")] + [InlineData("https://example.invalid/v3/index.json?sig=token")] + [InlineData("https://example.invalid/v3/index.json#token")] + public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CredentialBearingHttpSourceThrows(string sourceOverride) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var service = CreateService(); + + await Assert.ThrowsAsync( + async () => await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, workspace.WorkspaceRoot.FullName, CancellationToken.None)); + + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"))); + } + [Fact] public async Task PromptToCreateOrUpdateNuGetConfigAsync_NullChannelName_ShortCircuits() { From 9e82f97a2909ba685c4d0da5584fce0a213676a8 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Tue, 19 May 2026 16:11:40 -0400 Subject: [PATCH 17/17] fix(cli): update PR-hive NuGet config snapshots The NuGet config merger no longer maps wildcard package resolution to the PR hive when a separate fallback source already owns `*`. Update the PR-hive snapshots so CI expects Aspire packages only from the hive and keeps the fallback mapping on the appropriate source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...dWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml | 3 +-- ...WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml | 3 +-- ...appingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml | 3 +-- ...dIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml index 13162c35237..6d562940486 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml @@ -15,10 +15,9 @@ - - \ No newline at end of file + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml index d73c31e907d..ff5ac25a9b3 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml @@ -10,7 +10,6 @@ - - \ No newline at end of file + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml index e978e326b2e..853900ba67c 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml @@ -12,7 +12,6 @@ - @@ -21,4 +20,4 @@ - \ No newline at end of file + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml index 3695a23bf34..b72e839778e 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml @@ -11,10 +11,9 @@ - - \ No newline at end of file +