Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cf82ee0
fix(cli): honor source for guest language package restore
radical May 16, 2026
a49d604
chore(cli): remove source restore diff noise
radical May 16, 2026
7ef60d4
fix(cli): constrain source override restore behavior
radical May 16, 2026
32d4b3d
Merge origin/main into source restore fix
radical May 18, 2026
0381a4e
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 18, 2026
845de43
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 18, 2026
d60807b
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 18, 2026
9b6d705
fix(cli): keep --source override exclusive for Aspire packages
radical May 19, 2026
c3b0183
fix(cli): warn that aspire-empty --source is one-shot at scaffold
radical May 19, 2026
7518348
fix(cli): include --source/channel context in scaffold restore failures
radical May 19, 2026
e68732a
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 19, 2026
b59e2a0
fix(cli): honor --source override for guest-language starter templates
radical May 19, 2026
1f0c606
chore(cli): rename EmptySourceOverrideNotPersistedWarning resource
radical May 19, 2026
7e76ca4
fix(cli): align --source argument list with temp NuGet.config in Preb…
radical May 19, 2026
6e8bc74
fix(cli): redact credentials from --source in restore-failure output
radical May 19, 2026
f55e41f
fix(cli): auto-discover local Aspire source from requested channel
radical May 19, 2026
7295d17
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 19, 2026
5e069d7
fix(cli): close 5 findings from PR #17166 post-merge review
radical May 19, 2026
057abfa
fix(cli): degrade restore on channel-lookup failure + cover gaps from…
radical May 19, 2026
2b54a5c
Merge origin/main into source restore fix
radical May 19, 2026
968df31
fix(cli): address source-restore review feedback
radical May 19, 2026
d1564d2
fix(cli): persist aspire new source overrides
radical May 19, 2026
ad0f434
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 19, 2026
0bd9fce
fix(cli): reject credentialed new sources before persistence
radical May 19, 2026
94512f5
Merge remote-tracking branch 'origin/main' into radical/issue-17159-a…
radical May 19, 2026
9e82f97
fix(cli): update PR-hive NuGet config snapshots
radical May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integratio
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Comment thread
radical marked this conversation as resolved.
Outdated
string? packageSourceOverride = null)
{
var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken);
var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken);
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Projects/IAppHostServerProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ internal interface IAppHostServerProject
/// <param name="sdkVersion">The Aspire SDK version to use.</param>
/// <param name="integrations">The integration references (NuGet packages and/or project references) required by the app host.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="packageSourceOverride">Optional package source to prefer for Aspire package restore.</param>
/// <returns>The preparation result indicating success/failure and any output.</returns>
Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
Comment thread
radical marked this conversation as resolved.
Outdated
string? packageSourceOverride = null);

/// <summary>
/// Runs the AppHost server process.
Expand Down
138 changes: 111 additions & 27 deletions src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ namespace Aspire.Cli.Projects;
/// </summary>
internal sealed class PrebuiltAppHostServer : IAppHostServerProject
{
private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the constant? It is being used in a few places.


internal const string ClosureMetadataFileName = "closure-metadata.txt";
internal const string ClosureSourcesFileName = "closure-sources.txt";
internal const string ClosureTargetsFileName = "closure-targets.txt";
Expand Down Expand Up @@ -123,7 +125,8 @@ public string GetServerPath()
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? packageSourceOverride = null)
{
var integrationList = integrations.ToList();
var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList();
Expand Down Expand Up @@ -160,6 +163,7 @@ public async Task<AppHostServerPrepareResult> PrepareAsync(
packageRefs,
projectRefs,
requestedChannel,
packageSourceOverride,
cancellationToken).ConfigureAwait(false);

if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked))
Expand All @@ -185,7 +189,7 @@ await IntegrationPackageProbeManifest.WriteAsync(
{
// NuGet-only — use the bundled NuGet service (no SDK required)
_integrationProbeManifestPath = await RestoreNuGetPackagesAsync(
packageRefs, requestedChannel, cancellationToken);
packageRefs, requestedChannel, packageSourceOverride, cancellationToken);
}

var appSettingsContent = CreateAppSettingsContent(packageRefs, []);
Expand Down Expand Up @@ -226,13 +230,17 @@ await IntegrationPackageProbeManifest.WriteAsync(
private async Task<string> RestoreNuGetPackagesAsync(
List<IntegrationReference> 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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --source is provided this temp config is generated with <clear />, and then passed to restore, so we stop honoring any nuget.config in the app directory. That breaks cases where the app config contains credentials, private feeds, or package source mappings for non-Aspire integration packages. Can we merge the source override into the effective config (or use an additive source/mapping approach) so the user's config remains in play, and include the requested source/channel/config in restore failures?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — looked into the blast radius before changing this.

Today packageSourceOverride only reaches PrebuiltAppHostServer.PrepareAsync from one call site: ScaffoldingService.PrepareAsync (the aspire new aspire-empty --language <non-csharp> path). The other four callers — SdkGenerateCommand, SdkDumpCommand, GuestAppHostProject (used by aspire run/aspire restore/aspire add), and AppHostServerSession — all pass the default null. Plus, of the two ScaffoldContext construction sites, only CliTemplateFactory.EmptyTemplate forwards inputs.Source; InitCommand does not.

So the <clear />-emitting temp config kicks in at exactly one moment in the user's flow: the initial scaffold restore for aspire new --source. At that point the project directory is being freshly created — there is no project-local nuget.config to clobber. The pre-existing channel-driven <clear /> behavior on later commands is unchanged by this PR.

That makes the credentials / private-feeds / non-Aspire-PSM concern theoretical for the surface this PR introduces. Restore-failure context is a separate ask — that part is fair, and I've taken it on (see below).

If I've missed a path that flows --source into a context where a user-owned nuget.config is present, happy to fix it — please point me at it.

For the restore-failure-context piece of your comment, the next push enriches the PrepareAsync failure output with the --source, channel, and package context, so a failed aspire new --source <X> no longer requires a verbose re-run to see which inputs were in play. New test: PrepareAsync_RestoreFailure_OutputIncludesSourceAndChannelContext.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have any scenarios where our restore would interact with global nuget.config (or a nuget.config in a parent folder) after new? I'm mainly wondering if there's the potential for surprise if aspire new behaves differently from something like aspire add due to new using a specific config?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new is supposed to write a nuget.config file based on the default or specified channel then add should use that nuget.config or channel.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is the behavior the branch now moves toward: aspire new --source writes a project nuget.config so later aspire add/aspire restore can consume the same source state instead of treating --source as one-shot. It deliberately does not import parent/user/global config into the generated project.

One auth detail remains worth calling out: we reject embedded credentials, but sanitized URLs may not bind to user-level packageSourceCredentials if those credentials are keyed by a named source. We should decide whether to adopt an existing ambient source key for the same URL, rely on credential providers, or use another NuGet-native pattern.

var sources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken);

return await _nugetService.RestorePackagesAsync(
packages,
Expand All @@ -253,13 +261,23 @@ private async Task<AppHostServerClosureManifest> BuildIntegrationClosureManifest
List<IntegrationReference> packageRefs,
List<IntegrationReference> 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);
using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken);
var channelSources = temporaryNuGetConfig is null
? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken)
: null;
var projectContent = GenerateIntegrationProjectFile(
packageRefs,
projectRefs,
restoreDir,
channelSources,
useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride),
restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName);
var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName);
await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken);

Expand Down Expand Up @@ -354,7 +372,9 @@ internal static string GenerateIntegrationProjectFile(
List<IntegrationReference> packageRefs,
List<IntegrationReference> projectRefs,
string restoreDir,
IEnumerable<string>? additionalSources = null)
IEnumerable<string>? additionalSources = null,
bool useExactPackageVersions = false,
string? restoreConfigFile = null)
{
var propertyGroup = new XElement("PropertyGroup",
new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework),
Expand All @@ -368,8 +388,12 @@ internal static string GenerateIntegrationProjectFile(
new XElement("AspireClosureTargetsFile", Path.Combine(restoreDir, ClosureTargetsFileName)),
new XElement("AspireProjectRefAssemblyNamesFile", Path.Combine(restoreDir, ProjectRefAssemblyNamesFileName)));

// Add channel sources without replacing the user's nuget.config
if (additionalSources is not null)
if (!string.IsNullOrWhiteSpace(restoreConfigFile))
{
propertyGroup.Add(new XElement("RestoreConfigFile", restoreConfigFile));
}
// Add channel sources without replacing the user's nuget.config.
else if (additionalSources is not null)
{
var sourceList = string.Join(";", additionalSources);
if (sourceList.Length > 0)
Expand All @@ -394,7 +418,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)));
})));
}

Expand Down Expand Up @@ -461,26 +485,18 @@ internal static string GenerateIntegrationProjectFile(
/// <summary>
/// Gets NuGet sources from the resolved channel for bundled restore.
/// </summary>
private async Task<IEnumerable<string>?> GetNuGetSourcesAsync(string? requestedChannel, CancellationToken cancellationToken)
private async Task<IEnumerable<string>?> GetNuGetSourcesAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken)
{
var sources = new List<string>();

try
if (!string.IsNullOrWhiteSpace(packageSourceOverride))
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken);

IEnumerable<PackageChannel> explicitChannels;
if (!string.IsNullOrEmpty(requestedChannel))
{
var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase));
explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit);
}
else
{
explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit);
}
sources.Add(packageSourceOverride);
}

foreach (var channel in explicitChannels)
try
{
foreach (var channel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken))
{
if (channel.Mappings is null)
{
Expand All @@ -501,11 +517,52 @@ internal static string GenerateIntegrationProjectFile(
_logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback");
}

if (!string.IsNullOrWhiteSpace(packageSourceOverride) && sources.Count == 1)
{
sources.Add(NuGetOrgSource);
}

return sources.Count > 0 ? sources : null;
}

private async Task<TemporaryNuGetConfig?> TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, CancellationToken cancellationToken)
private async Task<TemporaryNuGetConfig?> TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(packageSourceOverride))
Comment thread
radical marked this conversation as resolved.
{
var mappings = new List<PackageMapping>
{
new("Aspire*", packageSourceOverride)
};
var configureGlobalPackagesFolder = false;

try
{
foreach (var restoreChannel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken))
{
if (restoreChannel.Mappings is null)
{
continue;
}

mappings.AddRange(restoreChannel.Mappings);
Comment thread
radical marked this conversation as resolved.
Outdated
configureGlobalPackagesFolder |= restoreChannel.ConfigureGlobalPackagesFolder;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config");
}

if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages))
{
mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource));
}

return await TemporaryNuGetConfig.CreateAsync(
[.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")],
configureGlobalPackagesFolder);
}

if (string.IsNullOrEmpty(requestedChannel))
{
return null;
Expand Down Expand Up @@ -541,6 +598,33 @@ internal static string GenerateIntegrationProjectFile(
return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder);
}

private async Task<IEnumerable<PackageChannel>> GetExplicitRestoreChannelsAsync(string? requestedChannel, CancellationToken cancellationToken)
{
var channels = await _packagingService.GetChannelsAsync(cancellationToken);
if (!string.IsNullOrEmpty(requestedChannel))
{
var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase));
return matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray();
}

return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray();
}

private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions)
{
if (!ShouldUseExactPackageVersion(packageName, useExactPackageVersions) || version.Length == 0 || version[0] is '[' or '(')
{
return version;
}

return $"[{version}]";
}

private static bool ShouldUseExactPackageVersion(string packageName, bool useExactPackageVersions)
{
return useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase);
}

/// <inheritdoc />
public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
int hostPid,
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Scaffolding/IScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ namespace Aspire.Cli.Scaffolding;
/// <param name="ProjectName">Optional project name.</param>
/// <param name="SdkVersion">Optional Aspire SDK version to use for scaffolding.</param>
/// <param name="Channel">Optional Aspire channel to use for scaffolding.</param>
/// <param name="PackageSourceOverride">Optional package source to prefer when restoring scaffold/code-generation packages.</param>
internal record ScaffoldContext(
LanguageInfo Language,
DirectoryInfo TargetDirectory,
string? ProjectName = null,
string? SdkVersion = null,
string? Channel = null);
string? Channel = null,
string? PackageSourceOverride = null);

/// <summary>
/// Service for scaffolding new AppHost projects.
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Scaffolding/ScaffoldingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ private async Task<bool> ScaffoldGuestLanguageAsync(ScaffoldContext context, Can

var prepareResult = await _interactionService.ShowStatusAsync(
"Preparing Aspire server...",
() => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken),
() => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken, packageSourceOverride: context.PackageSourceOverride),
emoji: KnownEmojis.Gear);
if (!prepareResult.Success)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ private async Task<TemplateResult> ApplyEmptyAppHostTemplateAsync(CallbackTempla
new DirectoryInfo(outputPath),
projectName,
SdkVersion: inputs.Version,
Channel: inputs.Channel);
Channel: inputs.Channel,
PackageSourceOverride: inputs.Source);
Comment thread
radical marked this conversation as resolved.
if (!await _scaffoldingService.ScaffoldAsync(context, cancellationToken))
{
return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject);
Expand Down
3 changes: 2 additions & 1 deletion tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject
public Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
CancellationToken cancellationToken = default) =>
CancellationToken cancellationToken = default,
string? packageSourceOverride = null) =>
throw new NotSupportedException();

public (string SocketPath, Process Process, OutputCollector OutputCollector) Run(
Expand Down
Loading
Loading