Skip to content

Commit 1007a6c

Browse files
authored
Interactive prompting for aspire new (#8520)
* Tweak tracing code. * aspire new with prompting. * Template selection.
1 parent 543e187 commit 1007a6c

File tree

9 files changed

+184
-105
lines changed

9 files changed

+184
-105
lines changed

src/Aspire.Cli/Backchannel/AppHostBackchannel.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ namespace Aspire.Cli.Backchannel;
1111

1212
internal sealed class AppHostBackchannel(ILogger<AppHostBackchannel> logger, CliRpcTarget target)
1313
{
14-
private readonly ActivitySource _activitySource = new(nameof(Aspire.Cli.Backchannel.AppHostBackchannel), "1.0.0");
14+
private readonly ActivitySource _activitySource = new(nameof(AppHostBackchannel));
1515
private readonly TaskCompletionSource<JsonRpc> _rpcTaskCompletionSource = new();
1616
private Process? _process;
1717

1818
public async Task<long> PingAsync(long timestamp, CancellationToken cancellationToken)
1919
{
20-
using var activity = _activitySource.StartActivity(nameof(PingAsync), ActivityKind.Client);
20+
using var activity = _activitySource.StartActivity();
2121

2222
var rpc = await _rpcTaskCompletionSource.Task;
2323

@@ -37,7 +37,7 @@ public async Task RequestStopAsync(CancellationToken cancellationToken)
3737
// of the AppHost process. The AppHost process will then trigger the shutdown
3838
// which will allow the CLI to await the pending run.
3939

40-
using var activity = _activitySource.StartActivity(nameof(RequestStopAsync), ActivityKind.Client);
40+
using var activity = _activitySource.StartActivity();
4141

4242
var rpc = await _rpcTaskCompletionSource.Task;
4343

@@ -51,7 +51,7 @@ await rpc.InvokeWithCancellationAsync(
5151

5252
public async Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync(CancellationToken cancellationToken)
5353
{
54-
using var activity = _activitySource.StartActivity(nameof(GetDashboardUrlsAsync), ActivityKind.Client);
54+
using var activity = _activitySource.StartActivity();
5555

5656
var rpc = await _rpcTaskCompletionSource.Task;
5757

@@ -67,7 +67,7 @@ await rpc.InvokeWithCancellationAsync(
6767

6868
public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
6969
{
70-
using var activity = _activitySource.StartActivity(nameof(GetResourceStatesAsync), ActivityKind.Client);
70+
using var activity = _activitySource.StartActivity();
7171

7272
var rpc = await _rpcTaskCompletionSource.Task;
7373

@@ -88,7 +88,7 @@ await rpc.InvokeWithCancellationAsync(
8888

8989
public async Task ConnectAsync(Process process, string socketPath, CancellationToken cancellationToken)
9090
{
91-
using var activity = _activitySource.StartActivity(nameof(ConnectAsync), ActivityKind.Client);
91+
using var activity = _activitySource.StartActivity();
9292

9393
_process = process;
9494

@@ -111,7 +111,7 @@ public async Task ConnectAsync(Process process, string socketPath, CancellationT
111111

112112
public async Task<string[]> GetPublishersAsync(CancellationToken cancellationToken)
113113
{
114-
using var activity = _activitySource.StartActivity(nameof(GetPublishersAsync), ActivityKind.Client);
114+
using var activity = _activitySource.StartActivity();
115115

116116
var rpc = await _rpcTaskCompletionSource.Task.ConfigureAwait(false);
117117

@@ -127,7 +127,7 @@ public async Task<string[]> GetPublishersAsync(CancellationToken cancellationTok
127127

128128
public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
129129
{
130-
using var activity = _activitySource.StartActivity(nameof(GetPublishingActivitiesAsync), ActivityKind.Client);
130+
using var activity = _activitySource.StartActivity();
131131

132132
var rpc = await _rpcTaskCompletionSource.Task;
133133

src/Aspire.Cli/Commands/AddCommand.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Aspire.Cli.Commands;
1111

1212
internal sealed class AddCommand : BaseCommand
1313
{
14-
private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli");
14+
private readonly ActivitySource _activitySource = new ActivitySource(nameof(AddCommand));
1515
private readonly DotNetCliRunner _runner;
1616
private readonly INuGetPackageCache _nuGetPackageCache;
1717

@@ -42,7 +42,7 @@ public AddCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache)
4242

4343
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
4444
{
45-
using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal);
45+
using var activity = _activitySource.StartActivity();
4646

4747
try
4848
{
@@ -62,7 +62,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
6262

6363
var packages = await AnsiConsole.Status().StartAsync(
6464
"Searching for Aspire packages...",
65-
context => _nuGetPackageCache.GetPackagesAsync(effectiveAppHostProjectFile, prerelease, source, cancellationToken)
65+
context => _nuGetPackageCache.GetPackagesAsync(effectiveAppHostProjectFile.Directory!, prerelease, source, cancellationToken)
6666
);
6767

6868
var version = parseResult.GetValue<string?>("--version");

src/Aspire.Cli/Commands/NewCommand.cs

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,27 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.CommandLine;
5-
using System.CommandLine.Parsing;
65
using System.Diagnostics;
76
using Aspire.Cli.Utils;
7+
using Semver;
88
using Spectre.Console;
99

1010
namespace Aspire.Cli.Commands;
1111

1212
internal sealed class NewCommand : BaseCommand
1313
{
14-
private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli");
14+
private readonly ActivitySource _activitySource = new ActivitySource(nameof(NewCommand));
1515
private readonly DotNetCliRunner _runner;
16+
private readonly INuGetPackageCache _nuGetPackageCache;
1617

17-
public NewCommand(DotNetCliRunner runner) : base("new", "Create a new Aspire sample project.")
18+
public NewCommand(DotNetCliRunner runner, INuGetPackageCache nuGetPackageCache) : base("new", "Create a new Aspire sample project.")
1819
{
1920
ArgumentNullException.ThrowIfNull(runner, nameof(runner));
21+
ArgumentNullException.ThrowIfNull(nuGetPackageCache, nameof(nuGetPackageCache));
2022
_runner = runner;
23+
_nuGetPackageCache = nuGetPackageCache;
2124

2225
var templateArgument = new Argument<string>("template");
23-
templateArgument.Validators.Add(ValidateProjectTemplate);
2426
templateArgument.Arity = ArgumentArity.ZeroOrOne;
2527
Arguments.Add(templateArgument);
2628

@@ -29,9 +31,6 @@ internal sealed class NewCommand : BaseCommand
2931

3032
var outputOption = new Option<string?>("--output", "-o");
3133
Options.Add(outputOption);
32-
33-
var prereleaseOption = new Option<bool>("--prerelease");
34-
Options.Add(prereleaseOption);
3534

3635
var sourceOption = new Option<string?>("--source", "-s");
3736
Options.Add(sourceOption);
@@ -40,7 +39,7 @@ internal sealed class NewCommand : BaseCommand
4039
Options.Add(templateVersionOption);
4140
}
4241

43-
private static void ValidateProjectTemplate(ArgumentResult result)
42+
private static async Task<(string TemplateName, string TemplateDescription, string? PathAppendage)> GetProjectTemplateAsync(ParseResult parseResult, CancellationToken cancellationToken)
4443
{
4544
// TODO: We need to integrate with the template engine to interrogate
4645
// the list of available templates. For now we will just hard-code
@@ -49,55 +48,91 @@ private static void ValidateProjectTemplate(ArgumentResult result)
4948
// Once we integrate with template engine we will also be able to
5049
// interrogate the various options and add them. For now we will
5150
// keep it simple.
52-
string[] validTemplates = [
53-
"aspire-starter",
54-
"aspire",
55-
"aspire-apphost",
56-
"aspire-servicedefaults",
57-
"aspire-mstest",
58-
"aspire-nunit",
59-
"aspire-xunit"
51+
(string TemplateName, string TemplateDescription, string? PathAppendage)[] validTemplates = [
52+
("aspire-starter", "Aspire Starter App", "src") ,
53+
("aspire", "Aspire Empty App", "src"),
54+
("aspire-apphost", "Aspire App Host", null),
55+
("aspire-servicedefaults", "Aspire Service Defaults", null),
56+
("aspire-mstest", "Aspire Test Project (MSTest)", null),
57+
("aspire-nunit", "Aspire Test Project (NUnit)", null),
58+
("aspire-xunit", "Aspire Test Project (xUnit)", null)
6059
];
6160

62-
var value = result.GetValueOrDefault<string>();
63-
64-
if (value is null)
61+
if (parseResult.GetValue<string?>("template") is { } templateName && validTemplates.SingleOrDefault(t => t.TemplateName == templateName) is { } template)
6562
{
66-
// This is OK, for now we will use the default
67-
// template of aspire-starter, but we might
68-
// be able to do more intelligent selection in the
69-
// future based on what is already in the working directory.
70-
return;
63+
return template;
7164
}
72-
73-
if (value is { } templateName && !validTemplates.Contains(templateName))
65+
else
7466
{
75-
result.AddError($"The specified template '{templateName}' is not valid. Valid templates are [{string.Join(", ", validTemplates)}].");
76-
return;
67+
return await PromptUtils.PromptForSelectionAsync(
68+
"Select a project template:",
69+
validTemplates,
70+
t => $"{t.TemplateName} ({t.TemplateDescription})",
71+
cancellationToken
72+
);
7773
}
7874
}
7975

80-
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
76+
private static async Task<string> GetProjectNameAsync(ParseResult parseResult, CancellationToken cancellationToken)
8177
{
82-
using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal);
78+
if (parseResult.GetValue<string>("--name") is not { } name)
79+
{
80+
var defaultName = new DirectoryInfo(Environment.CurrentDirectory).Name;
81+
name = await PromptUtils.PromptForStringAsync("Enter the project name:",
82+
defaultValue: defaultName,
83+
cancellationToken: cancellationToken);
84+
}
8385

84-
var templateVersion = parseResult.GetValue<string>("--version");
85-
var prerelease = parseResult.GetValue<bool>("--prerelease");
86+
return name;
87+
}
8688

87-
if (templateVersion is not null && prerelease)
89+
private static async Task<string> GetOutputPathAsync(ParseResult parseResult, string? pathAppendage, CancellationToken cancellationToken)
90+
{
91+
if (parseResult.GetValue<string>("--output") is not { } outputPath)
8892
{
89-
AnsiConsole.MarkupLine("[red bold]:thumbs_down: The --version and --prerelease options are mutually exclusive.[/]");
90-
return ExitCodeConstants.FailedToCreateNewProject;
93+
outputPath = await PromptUtils.PromptForStringAsync(
94+
"Enter the output path:",
95+
defaultValue: Path.Combine(Environment.CurrentDirectory, pathAppendage ?? string.Empty),
96+
cancellationToken: cancellationToken
97+
);
9198
}
92-
else if (prerelease)
99+
100+
return Path.GetFullPath(outputPath);
101+
}
102+
103+
private static async Task<string> GetProjectTemplatesVersionAsync(ParseResult parseResult, CancellationToken cancellationToken)
104+
{
105+
if (parseResult.GetValue<string>("--version") is { } version)
93106
{
94-
templateVersion = "*-*";
107+
return version;
95108
}
96-
else if (templateVersion is null)
109+
else
97110
{
98-
templateVersion = VersionHelper.GetDefaultTemplateVersion();
111+
version = await PromptUtils.PromptForStringAsync(
112+
"Project templates version:",
113+
defaultValue: VersionHelper.GetDefaultTemplateVersion(),
114+
validator: (string value) => {
115+
if (SemVersion.TryParse(value, out var parsedVersion))
116+
{
117+
return ValidationResult.Success();
118+
}
119+
120+
return ValidationResult.Error("Invalid version format. Please enter a valid version.");
121+
},
122+
cancellationToken);
123+
124+
return version;
99125
}
126+
}
100127

128+
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
129+
{
130+
using var activity = _activitySource.StartActivity();
131+
132+
var template = await GetProjectTemplateAsync(parseResult, cancellationToken);
133+
var name = await GetProjectNameAsync(parseResult, cancellationToken);
134+
var outputPath = await GetOutputPathAsync(parseResult, template.PathAppendage, cancellationToken);
135+
var version = await GetProjectTemplatesVersionAsync(parseResult, cancellationToken);
101136
var source = parseResult.GetValue<string?>("--source");
102137

103138
var templateInstallResult = await AnsiConsole.Status()
@@ -106,7 +141,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
106141
.StartAsync(
107142
":ice: Getting latest templates...",
108143
async context => {
109-
return await _runner.InstallTemplateAsync("Aspire.ProjectTemplates", templateVersion!, source, true, cancellationToken);
144+
return await _runner.InstallTemplateAsync("Aspire.ProjectTemplates", version, source, true, cancellationToken);
110145
});
111146

112147
if (templateInstallResult.ExitCode != 0)
@@ -117,35 +152,18 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
117152

118153
AnsiConsole.MarkupLine($":package: Using project templates version: {templateInstallResult.TemplateVersion}");
119154

120-
var templateName = parseResult.GetValue<string>("template") ?? "aspire-starter";
121-
122-
if (parseResult.GetValue<string>("--output") is not { } outputPath)
123-
{
124-
outputPath = Environment.CurrentDirectory;
125-
}
126-
else
127-
{
128-
outputPath = Path.GetFullPath(outputPath);
129-
}
130-
131-
if (parseResult.GetValue<string>("--name") is not { } name)
132-
{
133-
var outputPathDirectoryInfo = new DirectoryInfo(outputPath);
134-
name = outputPathDirectoryInfo.Name;
135-
}
136-
137155
int newProjectExitCode = await AnsiConsole.Status()
138156
.Spinner(Spinner.Known.Dots3)
139157
.SpinnerStyle(Style.Parse("purple"))
140158
.StartAsync(
141159
":rocket: Creating new Aspire project...",
142160
async context => {
143161
return await _runner.NewProjectAsync(
144-
templateName,
145-
name,
146-
outputPath,
147-
cancellationToken);
148-
});
162+
template.TemplateName,
163+
name,
164+
outputPath,
165+
cancellationToken);
166+
});
149167

150168
if (newProjectExitCode != 0)
151169
{

src/Aspire.Cli/Commands/PublishCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Aspire.Cli.Commands;
1212

1313
internal sealed class PublishCommand : BaseCommand
1414
{
15-
private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli");
15+
private readonly ActivitySource _activitySource = new ActivitySource(nameof(PublishCommand));
1616
private readonly DotNetCliRunner _runner;
1717

1818
public PublishCommand(DotNetCliRunner runner) : base("publish", "Generates deployment artifacts for an Aspire app host project.")
@@ -34,7 +34,7 @@ internal sealed class PublishCommand : BaseCommand
3434

3535
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
3636
{
37-
using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal);
37+
using var activity = _activitySource.StartActivity();
3838

3939
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
4040
var effectiveAppHostProjectFile = ProjectFileHelper.UseOrFindAppHostProjectFile(passedAppHostProjectFile);

src/Aspire.Cli/Commands/RunCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Aspire.Cli.Commands;
1414

1515
internal sealed class RunCommand : BaseCommand
1616
{
17-
private readonly ActivitySource _activitySource = new ActivitySource("Aspire.Cli");
17+
private readonly ActivitySource _activitySource = new ActivitySource(nameof(RunCommand));
1818
private readonly DotNetCliRunner _runner;
1919

2020
public RunCommand(DotNetCliRunner runner) : base("run", "Run an Aspire app host in development mode.")
@@ -33,7 +33,7 @@ public RunCommand(DotNetCliRunner runner) : base("run", "Run an Aspire app host
3333

3434
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
3535
{
36-
using var activity = _activitySource.StartActivity($"{nameof(ExecuteAsync)}", ActivityKind.Internal);
36+
using var activity = _activitySource.StartActivity();
3737

3838
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
3939
var effectiveAppHostProjectFile = ProjectFileHelper.UseOrFindAppHostProjectFile(passedAppHostProjectFile);

0 commit comments

Comments
 (0)