Skip to content

Commit 4704f1f

Browse files
CopilotmitchdennyCopilot
authored
Add Package Channel Service API for Aspire CLI (#10801)
* Initial plan * Implement PackageChannelService API and models Co-authored-by: mitchdenny <[email protected]> * WIP * Tests working again. * Add test coverage for NuGetConfigLocator. * WIP * Fix tests. * NuGet config generation. * Fix. * Fix source mapping bug. * WIP. * Add GH CLI to devcontainer to support PR testing. * WIP * WIP * WIP ... prompting working. * Vibe coded nuget drop * PR feedback. * WIP on fixing tests. * Tests passing (some redundant tests removed). * Update src/Aspire.Cli/Templating/DotNetTemplateFactory.cs Co-authored-by: Copilot <[email protected]> * Confirmation to strings. * Nuget file created confirmation message. * Find config file even with spelling variants. * Relocate nuget.config. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]> Co-authored-by: Mitch Denny <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 87857ff commit 4704f1f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+920
-304
lines changed

.devcontainer/devcontainer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"ghcr.io/devcontainers/features/azure-cli:1": {},
99
"ghcr.io/azure/azure-dev/azd:0": {},
1010
"ghcr.io/devcontainers/features/docker-in-docker": {},
11+
"ghcr.io/devcontainers/features/github-cli:1": {},
1112
"ghcr.io/devcontainers/features/dotnet": {
1213
"additionalVersions": [
1314
"8.0.403",

src/Aspire.Cli/CliExecutionContext.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Cli;
5+
6+
internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory)
7+
{
8+
public DirectoryInfo WorkingDirectory { get; } = workingDirectory;
9+
public DirectoryInfo HivesDirectory { get; } = hivesDirectory;
10+
}

src/Aspire.Cli/Commands/AddCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
9494
() => _nuGetPackageCache.GetIntegrationPackagesAsync(
9595
workingDirectory: effectiveAppHostProjectFile.Directory!,
9696
prerelease: true,
97-
source: source,
97+
nugetConfigFile: null,
9898
cancellationToken: cancellationToken)
9999
);
100100

src/Aspire.Cli/Commands/NewCommand.cs

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
using Aspire.Cli.DotNet;
99
using Aspire.Cli.Interaction;
1010
using Aspire.Cli.NuGet;
11+
using Aspire.Cli.Packaging;
1112
using Aspire.Cli.Resources;
1213
using Aspire.Cli.Telemetry;
1314
using Aspire.Cli.Templating;
1415
using Aspire.Cli.Utils;
15-
using Semver;
1616
using Spectre.Console;
1717
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
1818

@@ -127,66 +127,103 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
127127

128128
internal interface INewCommandPrompter
129129
{
130-
Task<NuGetPackage> PromptForTemplatesVersionAsync(IEnumerable<NuGetPackage> candidatePackages, CancellationToken cancellationToken);
130+
Task<(NuGetPackage Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(NuGetPackage Package, PackageChannel Channel)> candidatePackages, CancellationToken cancellationToken);
131131
Task<ITemplate> PromptForTemplateAsync(ITemplate[] validTemplates, CancellationToken cancellationToken);
132132
Task<string> PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken);
133133
Task<string> PromptForOutputPath(string v, CancellationToken cancellationToken);
134134
}
135135

136136
internal class NewCommandPrompter(IInteractionService interactionService) : INewCommandPrompter
137137
{
138-
public virtual async Task<NuGetPackage> PromptForTemplatesVersionAsync(IEnumerable<NuGetPackage> candidatePackages, CancellationToken cancellationToken)
138+
public virtual async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(IEnumerable<(NuGetPackage Package, PackageChannel Channel)> candidatePackages, CancellationToken cancellationToken)
139139
{
140-
var packagesGroupedByReleaseStatus = candidatePackages.GroupBy(p => SemVersion.Parse(p.Version).IsPrerelease ? "Prerelease" : "Released");
141-
var releasedGroup = packagesGroupedByReleaseStatus.FirstOrDefault(g => g.Key == "Released");
142-
var prereleaseGroup = packagesGroupedByReleaseStatus.FirstOrDefault(g => g.Key == "Prerelease");
140+
// Create a hierarchical selection experience:
141+
// - Top-level: all packages from the implicit channel (if any)
142+
// - Then: one entry per remaining channel that opens a sub-menu with that channel's packages
143143

144-
var selections = new List<(string SelectionText, Func<Task<NuGetPackage>> PackageSelector)>();
144+
// Local helpers
145+
static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) item)
146+
{
147+
// Keep it concise: "Id Version"
148+
var pkg = item.Package;
149+
var source = pkg.Source is not null && pkg.Source.Length > 0 ? pkg.Source : item.Channel.Name;
150+
return $"{pkg.Version} ({source})";
151+
}
145152

146-
foreach (var releasedPackage in releasedGroup ?? Enumerable.Empty<NuGetPackage>())
153+
async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync(
154+
PackageChannel channel,
155+
IEnumerable<(NuGetPackage Package, PackageChannel Channel)> items,
156+
CancellationToken ct)
147157
{
148-
selections.Add(($"{releasedPackage.Version} ({releasedPackage.Source})", () => Task.FromResult(releasedPackage!)));
158+
// Show a sub-menu for this channel's packages
159+
var packageChoices = items
160+
.Select(i => (
161+
Label: FormatPackageLabel(i),
162+
Result: i
163+
))
164+
.ToArray();
165+
166+
var selection = await interactionService.PromptForSelectionAsync(
167+
NewCommandStrings.SelectATemplateVersion,
168+
packageChoices,
169+
c => c.Label,
170+
ct);
171+
172+
return selection.Result;
149173
}
150174

151-
if (releasedGroup is not null && prereleaseGroup is not null)
175+
// Group incoming items by channel instance
176+
var byChannel = candidatePackages
177+
.GroupBy(cp => cp.Channel)
178+
.ToArray();
179+
180+
var implicitGroup = byChannel.FirstOrDefault(g => g.Key.Type is Packaging.PackageChannelType.Implicit);
181+
var explicitGroups = byChannel
182+
.Where(g => g.Key.Type is Packaging.PackageChannelType.Explicit)
183+
.ToArray();
184+
185+
// Build the root menu as tuples of (label, action)
186+
var rootChoices = new List<(string Label, Func<CancellationToken, Task<(NuGetPackage, PackageChannel)>> Action)>();
187+
188+
if (implicitGroup is not null)
152189
{
153-
// If we have prerelease packages (and there are released packages) we
154-
// want to show a sub-menu option which we will use to prompt the user.
155-
// To make this work the first prompt returns a function which is invoke
156-
// which will either return the package or trigger another prompt for
157-
// sub-packages. This is the sub-prompt logic.
158-
selections.Add((NewCommandStrings.UsePrereleaseTemplates, async () =>
190+
// Add each implicit package directly to the root
191+
foreach (var item in implicitGroup)
159192
{
160-
return await interactionService.PromptForSelectionAsync(
161-
NewCommandStrings.SelectATemplateVersion,
162-
prereleaseGroup,
163-
(p) => $"{p.Version} ({p.Source})",
164-
cancellationToken
165-
);
193+
var captured = item; // avoid modified-closure issues
194+
rootChoices.Add((
195+
Label: FormatPackageLabel((captured.Package, captured.Channel)),
196+
Action: ct => Task.FromResult((captured.Package, captured.Channel))
197+
));
166198
}
199+
}
200+
201+
// Add a submenu entry for each explicit channel
202+
foreach (var channelGroup in explicitGroups)
203+
{
204+
var channel = channelGroup.Key;
205+
var items = channelGroup.ToArray();
206+
207+
rootChoices.Add((
208+
Label: channel.Name,
209+
Action: ct => PromptForChannelPackagesAsync(channel, items, ct)
167210
));
168211
}
169-
else if (prereleaseGroup is not null)
212+
213+
// If for some reason we have no choices, fall back to the first candidate
214+
if (rootChoices.Count == 0)
170215
{
171-
// Fallback behavior if we happen to have NuGet feeds configured such
172-
// that we only have access to prerelease template packages - in this
173-
// case we just want to display them rather than having a special
174-
// expander menu.
175-
foreach (var prereleasePackage in prereleaseGroup)
176-
{
177-
selections.Add(($"{prereleasePackage.Version} ({prereleasePackage.Source})", () => Task.FromResult(prereleasePackage)));
178-
}
216+
return candidatePackages.First();
179217
}
180218

181-
var selection = await interactionService.PromptForSelectionAsync(
182-
NewCommandStrings.SelectATemplateVersion,
183-
selections,
184-
s => s.SelectionText,
185-
cancellationToken
186-
);
219+
// Prompt user for the top-level selection
220+
var topSelection = await interactionService.PromptForSelectionAsync(
221+
NewCommandStrings.SelectATemplateVersion,
222+
rootChoices,
223+
c => c.Label,
224+
cancellationToken);
187225

188-
var package = await selection.PackageSelector();
189-
return package;
226+
return await topSelection.Action(cancellationToken);
190227
}
191228

192229
public virtual async Task<string> PromptForOutputPath(string path, CancellationToken cancellationToken)

src/Aspire.Cli/Configuration/ConfigurationService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace Aspire.Cli.Configuration;
1010

11-
internal sealed class ConfigurationService(IConfiguration configuration, DirectoryInfo currentDirectory, FileInfo globalSettingsFile) : IConfigurationService
11+
internal sealed class ConfigurationService(IConfiguration configuration, CliExecutionContext executionContext, FileInfo globalSettingsFile) : IConfigurationService
1212
{
1313
public async Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default)
1414
{
@@ -93,7 +93,7 @@ private string GetSettingsFilePath(bool isGlobal)
9393

9494
private string FindNearestSettingsFile()
9595
{
96-
var searchDirectory = currentDirectory;
96+
var searchDirectory = executionContext.WorkingDirectory;
9797

9898
// Walk up the directory tree to find existing settings file
9999
while (searchDirectory is not null)
@@ -109,7 +109,7 @@ private string FindNearestSettingsFile()
109109
}
110110

111111
// If no existing settings file found, create one in current directory
112-
return ConfigurationHelper.BuildPathToSettingsJsonFile(currentDirectory.FullName);
112+
return ConfigurationHelper.BuildPathToSettingsJsonFile(executionContext.WorkingDirectory.FullName);
113113
}
114114

115115
public async Task<Dictionary<string, string>> GetAllConfigurationAsync(CancellationToken cancellationToken = default)

src/Aspire.Cli/DotNet/DotNetCliRunner.cs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ internal interface IDotNetCliRunner
2929
Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary<string, string>? env, TaskCompletionSource<IAppHostBackchannel>? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3030
Task<int> CheckHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3131
Task<int> TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
32-
Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
32+
Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3333
Task<int> NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3434
Task<int> BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3535
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
36-
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
36+
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken);
3737
}
3838

3939
internal sealed class DotNetCliRunnerInvocationOptions
@@ -45,7 +45,7 @@ internal sealed class DotNetCliRunnerInvocationOptions
4545
public bool StartDebugSession { get; set; }
4646
}
4747

48-
internal class DotNetCliRunner(ILogger<DotNetCliRunner> logger, IServiceProvider serviceProvider, AspireCliTelemetry telemetry, IConfiguration configuration, IFeatures features, IInteractionService interactionService) : IDotNetCliRunner
48+
internal class DotNetCliRunner(ILogger<DotNetCliRunner> logger, IServiceProvider serviceProvider, AspireCliTelemetry telemetry, IConfiguration configuration, IFeatures features, IInteractionService interactionService, CliExecutionContext executionContext) : IDotNetCliRunner
4949
{
5050

5151
internal Func<int> GetCurrentProcessId { get; set; } = () => Environment.ProcessId;
@@ -267,11 +267,12 @@ public async Task<int> TrustHttpCertificateAsync(DotNetCliRunnerInvocationOption
267267
cancellationToken: cancellationToken);
268268
}
269269

270-
public async Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
270+
public async Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
271271
{
272272
using var activity = telemetry.ActivitySource.StartActivity(nameof(InstallTemplateAsync), ActivityKind.Client);
273273

274-
List<string> cliArgs = ["new", "install", $"{packageName}::{version}"];
274+
// NOTE: The change to @ over :: for template version separator (now enforced in .NET 10.0 SDK).
275+
List<string> cliArgs = ["new", "install", $"{packageName}@{version}"];
275276

276277
if (force)
277278
{
@@ -298,6 +299,14 @@ public async Task<int> TrustHttpCertificateAsync(DotNetCliRunnerInvocationOption
298299
existingStandardErrorCallback?.Invoke(line);
299300
};
300301

302+
// The dotnet new install command does not support the --configfile option so if we
303+
// are installing packages based on a channel config we'll be passing in a nuget config
304+
// file which is dynamically generated in a temporary folder. We'll use that temporary
305+
// folder as the working directory for the command. If we are using an implicit channel
306+
// then we just use the current execution context for the CLI and inherit whatever
307+
// NuGet.configs that may or may not be laying around.
308+
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;
309+
301310
var exitCode = await ExecuteAsync(
302311
args: [.. cliArgs],
303312
env: new Dictionary<string, string>
@@ -307,7 +316,7 @@ public async Task<int> TrustHttpCertificateAsync(DotNetCliRunnerInvocationOption
307316
[KnownConfigNames.DotnetCliUiLanguage] = "en-US"
308317
},
309318
projectFile: null,
310-
workingDirectory: new DirectoryInfo(Environment.CurrentDirectory),
319+
workingDirectory: workingDirectory,
311320
backchannelCompletionSource: null,
312321
options: options,
313322
cancellationToken: cancellationToken);
@@ -705,7 +714,7 @@ public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageN
705714
return result;
706715
}
707716

708-
public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
717+
public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken)
709718
{
710719
using var activity = telemetry.ActivitySource.StartActivity();
711720
List<string> cliArgs = [
@@ -720,10 +729,10 @@ public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageN
720729
"json"
721730
];
722731

723-
if (nugetSource is not null)
732+
if (nugetConfigFile is not null)
724733
{
725-
cliArgs.Add("--source");
726-
cliArgs.Add(nugetSource);
734+
cliArgs.Add("--configfile");
735+
cliArgs.Add(nugetConfigFile.FullName);
727736
}
728737

729738
if (prerelease)

0 commit comments

Comments
 (0)