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/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/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/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..6858745d8d5 --- /dev/null +++ b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs @@ -0,0 +1,53 @@ +// 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); + if (HasCredentialMaterial(packageSourceOverride)) + { + throw new ArgumentException("Credential-bearing HTTP sources cannot be persisted.", nameof(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}")]; + } + + 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/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 4c3d7c7d961..fa9b8cb5abe 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -259,8 +259,9 @@ private XDocument CreateProjectFile(IEnumerable integratio /// public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( IEnumerable integrations, - CancellationToken cancellationToken = default, - string? requestedChannel = null) + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { // Clean obj folder to ensure fresh NuGet restore var objPath = Path.Combine(_projectModelPath, "obj"); @@ -353,6 +354,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); @@ -424,10 +439,11 @@ private XDocument CreateProjectFile(IEnumerable integratio public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, - string? requestedChannel = null) + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { - var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken, requestedChannel); + 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 ee2829d9a32..b8c95d47b0f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -225,9 +225,10 @@ private string GetPrepareSdkVersion(AspireConfigFile config) string sdkVersion, List integrations, string? requestedChannel, - CancellationToken cancellationToken) + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { - var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken, requestedChannel); + var result = await appHostServerProject.PrepareAsync(sdkVersion, integrations, requestedChannel, packageSourceOverride, cancellationToken); return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } @@ -235,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) + internal async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { var config = LoadConfiguration(directory); - return await BuildAndGenerateSdkAsync(directory, config, cancellationToken); + return await BuildAndGenerateSdkAsync(directory, config, packageSourceOverride, cancellationToken); } - private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, CancellationToken cancellationToken) + private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, AspireConfigFile config, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); @@ -251,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); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, integrations, config.Channel, packageSourceOverride, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -288,9 +289,9 @@ await GenerateCodeViaRpcAsync( return true; } - Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) + Task IGuestAppHostSdkGenerator.BuildAndGenerateSdkAsync(DirectoryInfo directory, string? packageSourceOverride, CancellationToken cancellationToken) { - return BuildAndGenerateSdkAsync(directory, cancellationToken); + return BuildAndGenerateSdkAsync(directory, packageSourceOverride, cancellationToken); } // ═══════════════════════════════════════════════════════════════ @@ -364,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); @@ -888,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 @@ -1200,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; @@ -1330,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 a5e81a871df..216dccd42a8 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -40,14 +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? requestedChannel = 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 ba89749d4c7..329e7e70f37 100644 --- a/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs +++ b/src/Aspire.Cli/Projects/IGuestAppHostSdkGenerator.cs @@ -12,7 +12,8 @@ internal interface IGuestAppHostSdkGenerator /// Builds any required server components and generates guest SDK artifacts. /// /// The AppHost project directory. + /// 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); -} \ 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 a082f06acc0..9f1cdc08ae1 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -123,12 +123,18 @@ public string GetServerPath() public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, - string? requestedChannel = null) + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) { var integrationList = integrations.ToList(); var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); var projectRefs = integrationList.Where(r => r.IsProjectReference).ToList(); + // 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 { @@ -141,6 +147,10 @@ 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(); + if (string.IsNullOrWhiteSpace(effectivePackageSourceOverride)) + { + effectivePackageSourceOverride = await ResolveLocalPackageSourceOverrideAsync(requestedChannel, cancellationToken).ConfigureAwait(false); + } if (projectRefs.Count > 0) { @@ -160,6 +170,7 @@ public async Task PrepareAsync( packageRefs, projectRefs, requestedChannel, + effectivePackageSourceOverride, cancellationToken).ConfigureAwait(false); if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked)) @@ -185,7 +196,7 @@ await IntegrationPackageProbeManifest.WriteAsync( { // NuGet-only — use the bundled NuGet service (no SDK required) _integrationProbeManifestPath = await RestoreNuGetPackagesAsync( - packageRefs, requestedChannel, cancellationToken); + packageRefs, requestedChannel, effectivePackageSourceOverride, cancellationToken); } var appSettingsContent = CreateAppSettingsContent(packageRefs, []); @@ -201,6 +212,7 @@ await IntegrationPackageProbeManifest.WriteAsync( catch (AppHostServerPrepareFailedException ex) { _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); + AppendRestoreContextOnFailure(ex.Output, requestedChannel, effectivePackageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: ex.Output, @@ -212,6 +224,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, effectivePackageSourceOverride, packageRefs); return new AppHostServerPrepareResult( Success: false, Output: output, @@ -220,19 +233,61 @@ 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) + { + // 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) + { + 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). /// 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.Name, 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 +308,31 @@ 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); + // Only synthesize a temp NuGet.config (replacing nuget.config discovery via + // 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; + var channelSources = temporaryNuGetConfig is null + ? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride: null, cancellationToken) + : null; + var projectContent = GenerateIntegrationProjectFile( + packageRefs, + projectRefs, + restoreDir, + channelSources, + useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride), + restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName); var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -354,7 +427,9 @@ internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, string restoreDir, - IEnumerable? additionalSources = null) + IEnumerable? additionalSources = null, + bool useExactPackageVersions = false, + string? restoreConfigFile = null) { var propertyGroup = new XElement("PropertyGroup", new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), @@ -368,8 +443,15 @@ 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)) + { + // 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. + else if (additionalSources is not null) { var sourceList = string.Join(";", additionalSources); if (sourceList.Length > 0) @@ -394,7 +476,7 @@ internal static string GenerateIntegrationProjectFile( } return new XElement("PackageReference", new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version)); + new XAttribute("Version", GetRestoreVersion(p.Name, p.Version, useExactPackageVersions))); }))); } @@ -482,7 +564,7 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) /// /// Gets NuGet sources from the resolved channel for bundled restore. /// - internal async Task?> GetNuGetSourcesAsync(string? requestedChannel, CancellationToken cancellationToken) + internal async Task?> GetNuGetSourcesAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { // Refuse to silently downgrade staging restores to the shared daily feed when the running // CLI cannot synthesize a real staging channel (daily/local/pr-). PackagingService omits @@ -495,22 +577,25 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) var sources = new List(); - try + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) { - var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); - - IEnumerable explicitChannels; - if (!string.IsNullOrEmpty(requestedChannel)) - { - var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); - 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 + { + // 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); + var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride); + var matchedChannelHasAllPackagesMapping = false; + foreach (var channel in channels) { if (channel.Mappings is null) { @@ -519,12 +604,38 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) 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(PackageSourceOverrideMappings.NuGetOrgSource, StringComparer.OrdinalIgnoreCase)) + { + sources.Add(PackageSourceOverrideMappings.NuGetOrgSource); + } } catch (Exception ex) { @@ -534,23 +645,79 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) return sources.Count > 0 ? sources : null; } - internal async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, CancellationToken cancellationToken) + 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. + // Build a temporary NuGet.config that routes Aspire* there, optionally preserves + // non-Aspire channel mappings, and leaves a fallback source for non-Aspire deps. + PackageChannel? matchedChannel = null; + var configureGlobalPackagesFolder = false; + + try + { + // 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)) + { + var packageChannels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + matchedChannel = packageChannels.FirstOrDefault(c => + string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); + if (matchedChannel is not null) + { + configureGlobalPackagesFolder |= matchedChannel.ConfigureGlobalPackagesFolder; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config"); + } + + return await TemporaryNuGetConfig.CreateAsync( + PackageSourceOverrideMappings.Create(packageSourceOverride, matchedChannel), + configureGlobalPackagesFolder); + } + if (string.IsNullOrEmpty(requestedChannel)) { 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); - - 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) { @@ -576,6 +743,103 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) 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, StringComparisons.ChannelName)); + } + 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); + if (!string.IsNullOrEmpty(requestedChannel)) + { + var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparisons.ChannelName)); + if (matchingChannel is not null) + { + return [matchingChannel]; + } + } + + return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + } + + private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions) + { + var shouldUseExactAspirePackageVersion = useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + if (!shouldUseExactAspirePackageVersion || version.Length == 0 || version[0] is '[' or '(') + { + return version; + } + + return $"[{version}]"; + } + + // 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( int hostPid, 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/TemplatingStrings.Designer.cs b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs index 11fb1542612..438e23c5e35 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 SourceOverrideNotPersistedWarning { + get { + return ResourceManager.GetString("SourceOverrideNotPersistedWarning", 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..70d1a447815 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 or aspire-starter) when --source was supplied for a non-C# language. + 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/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf index 0572d5af797..7b88987a49f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf @@ -167,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 c15577a41cd..e0e1a16ec2d 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf @@ -167,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 9155ce129e2..3bd9b8ce2fa 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf @@ -167,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 5fadfefb148..e79e7786594 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf @@ -167,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 0fbda9abc4e..a5e89b6810f 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf @@ -167,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 3c395bf247b..54f1a150932 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf @@ -167,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 13bee1e4080..f15ec0376d5 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf @@ -167,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 6c250dfd93d..b01f4728ae0 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf @@ -167,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 05eda21b653..0fb830986b6 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf @@ -167,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 19f4a0fec5c..a6ea76245c8 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf @@ -167,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 b437151e0ad..4f2ec2a2d00 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf @@ -167,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 e9a78da28be..b00966db15e 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf @@ -167,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 1db6a926d8b..1dea7e9226a 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf @@ -167,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/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..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), + () => 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.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 37f715e8ca1..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( @@ -77,7 +80,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((int)CliExitCodes.FailedToCreateNewProject); @@ -96,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 6c4f9a55cf2..b44dc2a01cf 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs @@ -81,12 +81,13 @@ 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), 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."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + 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 ef125e4d107..a4561d61d6d 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -95,12 +95,13 @@ 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), 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."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + 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 763e2cb0864..b837f35fa5d 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -80,12 +80,13 @@ 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), 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."); return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); } + 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/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/src/Aspire.Cli/Utils/PackageSourceRedactor.cs b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs new file mode 100644 index 00000000000..29300c35fed --- /dev/null +++ b/src/Aspire.Cli/Utils/PackageSourceRedactor.cs @@ -0,0 +1,72 @@ +// 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. 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) + { + if (string.IsNullOrEmpty(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. Trim first because NuGet sources in config/output can be indented. + var looksHttp = + sourceToParse.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + sourceToParse.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + if (!Uri.TryCreate(sourceToParse, 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 sourceToParse; + } + + 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/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 7ae92a31842..01918d1f74d 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) @@ -1151,6 +1182,144 @@ public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCrea Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.ts"))); } + [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 NewCommandWithEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore(string language, string? featureFlag, string scaffoldFileName) + { + 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(); + if (featureFlag is not null) + { + options.FeatureFlagsFactory = _ => + { + var features = new TestFeatures(); + features.SetFeature(featureFlag, true); + return features; + }; + } + }); + + services.AddSingleton(new TestScaffoldingService + { + ScaffoldAsyncCallback = (context, _) => + { + capturedPackageSourceOverride = context.PackageSourceOverride; + 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 {language} --localhost-tld false --suppress-agent-init --source {sourceOverride}"); + + 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.DoesNotContain( + interactionService!.DisplayedMessages, + 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); + } + + [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() + { + 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.SourceOverrideNotPersistedWarning); + } + [Fact] public async Task NewCommandWithExplicitJavaEmptyTemplateCreatesJavaAppHost() { @@ -1619,7 +1788,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); @@ -1695,7 +1864,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(); @@ -1708,6 +1877,154 @@ 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 NewCommandWithTypeScriptStarterAndSourceOverridePersistsSourceAndPlumbsOverride() + { + 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); + AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride); + Assert.NotNull(interactionService); + Assert.DoesNotContain( + interactionService!.DisplayedMessages, + 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] + 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.SourceOverrideNotPersistedWarning); + } + [Fact] public async Task NewCommandNonInteractiveDoesNotPrompt() { diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 074bf6b9d66..6ef1f7fc86b 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, packageSourceOverride: overrideSource, cancellationToken: CancellationToken.None).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/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 26a119f1848..1a13d1af777 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -133,8 +133,9 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, - string? requestedChannel = null) => + string? requestedChannel = 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 31b8628659c..7c94fc81d5d 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -19,6 +19,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() { @@ -170,6 +172,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() { @@ -181,6 +203,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() { @@ -423,6 +466,265 @@ 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)); + } + + [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); + } + + [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)); + } + [Fact] public async Task TryCreateTemporaryNuGetConfig_StagingRequested_RefusesWhenPackagingServiceReportsUnavailable() { @@ -442,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() { @@ -457,222 +774,971 @@ public async Task GetNuGetSources_StagingRequested_RefusesWhenPackagingServiceRe var server = CreateServerWithUnavailableStagingChannel(workspace, executionContext, unavailableReason); var ex = await Assert.ThrowsAsync( - () => server.GetNuGetSourcesAsync("staging", CancellationToken.None)); + () => server.GetNuGetSourcesAsync("staging", packageSourceOverride: null, CancellationToken.None)); Assert.Equal(unavailableReason, ex.Message); } [Fact] - public async Task GetNuGetSources_NonStagingRequest_NotAffectedByStagingUnavailableReason() + public async Task GetNuGetSources_NonStagingRequest_NotAffectedByStagingUnavailableReason() + { + // Negative control: the staging refusal must only fire for requestedChannel == "staging". + // A request for any other channel name must continue to resolve normally even when the + // packaging service is reporting staging-unavailable. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithIdentityChannel("daily"); + var mappings = new[] + { + new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + }; + var dailyChannel = PackageChannel.CreateExplicitChannel( + "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]), + GetStagingChannelUnavailableReasonCallback = () => "Staging unavailable" + }; + + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + executionContext, + NullLogger.Instance); + + var server = new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + packagingService, + executionContext, + NullLogger.Instance); + + var sources = await server.GetNuGetSourcesAsync("daily", packageSourceOverride: null, CancellationToken.None); + + Assert.NotNull(sources); + Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources); + } + + private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( + TemporaryWorkspace workspace, + CliExecutionContext executionContext, + string unavailableReason) + { + // Mirrors what PackagingService does on a daily/local/pr CLI: omits 'staging' from + // GetChannelsAsync and surfaces the actionable reason via GetStagingChannelUnavailableReason. + // We hand back the 'daily' channel because that is the shared explicit channel the + // pre-fix fallback path would have silently picked up. + var mappings = new[] + { + new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + }; + var dailyChannel = PackageChannel.CreateExplicitChannel( + "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]), + GetStagingChannelUnavailableReasonCallback = () => unavailableReason + }; + + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + executionContext, + NullLogger.Instance); + + return new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + packagingService, + executionContext, + NullLogger.Instance); + } + + private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => + new(new DirectoryInfo(Path.GetTempPath()), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), + "test.log", + identityChannel: identityChannel); + + private static PrebuiltAppHostServer CreateServerWithExplicitChannel( + TemporaryWorkspace workspace, + string channelName, + CliExecutionContext executionContext) + { + // channelName is the name of the channel registered in the TestPackagingService — i.e. the + // channel a project's aspire.config.json would resolve to when it requests that name. + var mappings = new[] + { + new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + }; + var channel = PackageChannel.CreateExplicitChannel( + channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + return CreateServerWithChannel(workspace, channel, executionContext); + } + + private static PrebuiltAppHostServer CreateServerWithChannel( + TemporaryWorkspace workspace, + PackageChannel channel, + CliExecutionContext executionContext) + { + var packagingService = new TestPackagingService + { + 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()), + new TestFeatures(), + executionContext, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + return new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + packagingService, + executionContext, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( + 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, packageSourceOverride, CancellationToken.None])!; + 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() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "pr-new" + } + """); + + 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, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), + Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var channel = server.ResolveRequestedChannel(); + + Assert.Equal("pr-new", channel); + } + + [Fact] + public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + 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, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + MockPackagingServiceFactory.Create(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync("13.2.0", []); + + Assert.True(result.Success); + Assert.Null(server.SelectedProjectLayoutPath); + + var appSettingsPath = Path.Combine(workingDirectory, "appsettings.json"); + Assert.True(File.Exists(appSettingsPath)); + + var appSettingsContent = await File.ReadAllTextAsync(appSettingsPath); + Assert.Contains("\"Aspire.Hosting\"", appSettingsContent); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifest() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.2.0", + [IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")]); + + Assert.True(result.Success); + Assert.Null(server.SelectedProjectLayoutPath); + Assert.Equal(2, executionFactory.AttemptCount); + + var manifestPath = Assert.IsType(server.IntegrationProbeManifestPath); + Assert.StartsWith( + Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore"), + manifestPath, + StringComparison.OrdinalIgnoreCase); + + var startInfo = server.CreateStartInfo(123); + Assert.Equal(manifestPath, startInfo.Environment[KnownConfigNames.IntegrationProbeManifestPath]); + Assert.False(startInfo.Environment.ContainsKey(KnownConfigNames.IntegrationLibsPath)); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [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"), + 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 + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [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); + } + } + + [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_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() + { + // 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($"--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 + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithProjectReferencesAndExplicitChannelButNoOverride_UsesAdditionalSourcesNotRestoreConfigFile() { - // Negative control: the staging refusal must only fire for requestedChannel == "staging". - // A request for any other channel name must continue to resolve normally even when the - // packaging service is reporting staging-unavailable. + // 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 executionContext = CreateContextWithIdentityChannel("daily"); - var mappings = new[] + var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(aspireConfigPath, """ + { + "channel": "daily" + } + """); + + var closureFiles = new Dictionary(StringComparer.Ordinal) { - new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + ["MyIntegration.dll"] = "integration-v1" }; - var dailyChannel = PackageChannel.CreateExplicitChannel( - "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); - var packagingService = new TestPackagingService + var dotNetCliRunner = new TestDotNetCliRunner { - GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]), - GetStagingChannelUnavailableReasonCallback = () => "Staging unavailable" + BuildAsyncCallback = (projectFilePath, _, _, _) => + { + generatedProject = XDocument.Load(projectFilePath.FullName); + WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]); + return 0; + } }; - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - executionContext, - NullLogger.Instance); - - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - executionContext, - NullLogger.Instance); - - var sources = await server.GetNuGetSourcesAsync("daily", CancellationToken.None); - - Assert.NotNull(sources); - Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources); - } - - private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( - TemporaryWorkspace workspace, - CliExecutionContext executionContext, - string unavailableReason) - { - // Mirrors what PackagingService does on a daily/local/pr CLI: omits 'staging' from - // GetChannelsAsync and surfaces the actionable reason via GetStagingChannelUnavailableReason. - // We hand back the 'daily' channel because that is the shared explicit channel the - // pre-fix fallback path would have silently picked up. - var mappings = new[] - { - new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") - }; var dailyChannel = PackageChannel.CreateExplicitChannel( - "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); - + name: "daily", + quality: PackageChannelQuality.Both, + mappings: [new PackageMapping("Aspire*", channelSource)], + nuGetPackageCache: new FakeNuGetPackageCache()); var packagingService = new TestPackagingService { - GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]), - GetStagingChannelUnavailableReasonCallback = () => unavailableReason + GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) }; var nugetService = new BundleNuGetService( new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), - executionContext, + TestExecutionContextFactory.CreateTestContext(), NullLogger.Instance); - - return new PrebuiltAppHostServer( + var server = new PrebuiltAppHostServer( workspace.WorkspaceRoot.FullName, "test.sock", new LayoutConfiguration(), nugetService, - new TestDotNetCliRunner(), + dotNetCliRunner, new TestDotNetSdkInstaller(), packagingService, - executionContext, + TestExecutionContextFactory.CreateTestContext(), NullLogger.Instance); - } - - private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => - new(new DirectoryInfo(Path.GetTempPath()), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), - "test.log", - identityChannel: identityChannel); - - private static PrebuiltAppHostServer CreateServerWithExplicitChannel( - TemporaryWorkspace workspace, - string channelName, - CliExecutionContext executionContext) - { - // channelName is the name of the channel registered in the TestPackagingService — i.e. the - // channel a project's aspire.config.json would resolve to when it requests that name. - var mappings = new[] - { - new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") - }; - var channel = PackageChannel.CreateExplicitChannel( - channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); - return CreateServerWithChannel(workspace, channel, executionContext); - } + var workingDirectory = GetWorkingDirectory(server); - private static PrebuiltAppHostServer CreateServerWithChannel( - TemporaryWorkspace workspace, - PackageChannel channel, - CliExecutionContext executionContext) - { - var packagingService = new TestPackagingService + try { - GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) - }; + 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") + ]); - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - executionContext, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + Assert.True(result.Success); + Assert.NotNull(generatedProject); - return new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - executionContext, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } + var ns = generatedProject!.Root!.GetDefaultNamespace(); + Assert.Null(generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()); - private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( - PrebuiltAppHostServer server, string requestedChannel) - { - var method = typeof(PrebuiltAppHostServer).GetMethod( - "TryCreateTemporaryNuGetConfigAsync", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - Assert.NotNull(method); + var restoreSources = generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + Assert.NotNull(restoreSources); + Assert.Contains(channelSource, restoreSources!); - var task = (Task)method.Invoke(server, [requestedChannel, CancellationToken.None])!; - return await task; + // 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 ResolveRequestedChannel_UsesProjectLocalAspireConfig() + 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-new" + "channel": "pr-12345" } """); - 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, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - var channel = server.ResolveRequestedChannel(); - - Assert.Equal("pr-new", channel); - } - - [Fact] - public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); + 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 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, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), - TestExecutionContextFactory.CreateTestContext(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.DefaultExitCode = 1; var workingDirectory = GetWorkingDirectory(server); try { - var result = await server.PrepareAsync("13.2.0", []); - - Assert.True(result.Success); - Assert.Null(server.SelectedProjectLayoutPath); + var result = await server.PrepareAsync( + "13.4.0-pr.12345.gabcdef00", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.12345.gabcdef00")]); - var appSettingsPath = Path.Combine(workingDirectory, "appsettings.json"); - Assert.True(File.Exists(appSettingsPath)); + Assert.False(result.Success); + Assert.NotNull(result.Output); - var appSettingsContent = await File.ReadAllTextAsync(appSettingsPath); - Assert.Contains("\"Aspire.Hosting\"", appSettingsContent); + 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 { @@ -680,33 +1746,87 @@ public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings() } } + [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_WithPackageReferences_SetsOnlyPackageProbeManifest() + 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 (server, executionFactory) = CreatePackageReferenceServer(workspace); + 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.2.0", - [IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")]); + "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.Null(server.SelectedProjectLayoutPath); - Assert.Equal(2, executionFactory.AttemptCount); + Assert.NotNull(generatedProject); - var manifestPath = Assert.IsType(server.IntegrationProbeManifestPath); - Assert.StartsWith( - Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore"), - manifestPath, - StringComparison.OrdinalIgnoreCase); + 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 startInfo = server.CreateStartInfo(123); - Assert.Equal(manifestPath, startInfo.Environment[KnownConfigNames.IntegrationProbeManifestPath]); - Assert.False(startInfo.Environment.ContainsKey(KnownConfigNames.IntegrationLibsPath)); + 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 { @@ -1261,6 +2381,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(); @@ -1278,7 +2405,7 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut nugetService, new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), + packagingService, TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); @@ -1433,6 +2560,29 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() Assert.Equal(executionContext.LogFilePath, startInfo.Environment[KnownConfigNames.CliLogFilePath]); } + 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 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/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 2aef7b05024..e553ca6ca29 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,31 @@ 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, + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) + { + 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/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 + diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs index 2c61c860ed7..53a33b520a4 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,162 @@ 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)); + } + + [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() { @@ -266,6 +423,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) diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index e785a761ba1..c9e85d24996 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -24,8 +24,9 @@ internal sealed class FakeFailingAppHostServerProject(string appDirectoryPath) : public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default, - string? requestedChannel = null) => + string? requestedChannel = 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/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index 5f201532301..8840ee9591f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -8,6 +8,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestPackagingService : IPackagingService { public Func>>? GetChannelsAsyncCallback { get; set; } + public string? LastRequestedChannelName { get; private set; } /// /// Optional callback to control the reason returned by @@ -19,6 +20,8 @@ internal sealed class TestPackagingService : IPackagingService public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { + LastRequestedChannelName = requestedChannelName; + if (GetChannelsAsyncCallback is not null) { return GetChannelsAsyncCallback(cancellationToken); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestTypeScriptStarterProjectFactory.cs index 67efc403466..61621a688b7 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, string? packageSourceOverride = null, CancellationToken cancellationToken = default) { - return buildAndGenerateSdkAsync(directory, cancellationToken); + 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)); + } +}