Skip to content

Commit 172ec29

Browse files
aspire new bootstrap (#7899)
* CLI runner. * Fix duplicate project template in list. * Apply suggestions from code review --------- Co-authored-by: David Fowler <[email protected]>
1 parent 9ee27d9 commit 172ec29

File tree

4 files changed

+186
-42
lines changed

4 files changed

+186
-42
lines changed

src/Aspire.Cli/AppHostRunner.cs

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,12 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Diagnostics;
5-
using System.Globalization;
6-
74
namespace Aspire.Cli;
85

9-
internal sealed class AppHostRunner
6+
internal sealed class AppHostRunner(DotNetCliRunner cli)
107
{
11-
internal Func<int> GetCurrentProcessId { get; set; } = () => Environment.ProcessId;
12-
138
public async Task<int> RunAppHostAsync(FileInfo appHostProjectFile, string[] args, CancellationToken cancellationToken)
149
{
15-
var startInfo = new ProcessStartInfo("dotnet", $"run --project \"{appHostProjectFile.FullName}\"")
16-
{
17-
UseShellExecute = false,
18-
CreateNoWindow = true
19-
};
20-
21-
if (args.Length > 0)
22-
{
23-
startInfo.Arguments += " -- " + string.Join(" ", args);
24-
}
25-
26-
// The AppHost uses this environment variable to signal to the CliOrphanDetector which process
27-
// it should monitor in order to know when to stop the CLI. As long as the process still exists
28-
// the orphan detector will allow the CLI to keep running. If the environment variable does
29-
// not exist the orphan detector will exit.
30-
startInfo.EnvironmentVariables["ASPIRE_CLI_PID"] = GetCurrentProcessId().ToString(CultureInfo.InvariantCulture);
31-
32-
using var process = new Process { StartInfo = startInfo };
33-
var started = process.Start();
34-
35-
if (!started)
36-
{
37-
return ExitCodeConstants.FailedToDotnetRunAppHost;
38-
}
39-
40-
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
41-
42-
if (!process.HasExited)
43-
{
44-
process.Kill(false); // DCP should clean everything else up.
45-
}
46-
47-
return process.ExitCode;
10+
return await cli.RunAsync(appHostProjectFile, args, cancellationToken).ConfigureAwait(false);
4811
}
4912
}

src/Aspire.Cli/DotNetCliRunner.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
using System.Diagnostics;
5+
using System.Globalization;
6+
7+
namespace Aspire.Cli;
8+
9+
internal sealed class DotNetCliRunner
10+
{
11+
internal Func<int> GetCurrentProcessId { get; set; } = () => Environment.ProcessId;
12+
13+
public async Task<int> RunAsync(FileInfo projectFile, string[] args, CancellationToken cancellationToken)
14+
{
15+
string[] cliArgs = ["run", "--project", projectFile.FullName, "--", ..args];
16+
return await ExecuteAsync(cliArgs, cancellationToken).ConfigureAwait(false);
17+
}
18+
19+
public async Task<int> InstallTemplateAsync(string packageName, string version, bool force, CancellationToken cancellationToken)
20+
{
21+
string[] forceArgs = force ? ["--force"] : [];
22+
string[] cliArgs = ["new", "install", $"{packageName}::{version}", ..forceArgs];
23+
return await ExecuteAsync(cliArgs, cancellationToken).ConfigureAwait(false);
24+
}
25+
26+
public async Task<int> NewProjectAsync(string templateName, string name, string outputPath, CancellationToken cancellationToken)
27+
{
28+
string[] cliArgs = ["new", templateName, "--name", name, "--output", outputPath];
29+
return await ExecuteAsync(cliArgs, cancellationToken).ConfigureAwait(false);
30+
}
31+
32+
public async Task<int> ExecuteAsync(string[] args, CancellationToken cancellationToken)
33+
{
34+
var startInfo = new ProcessStartInfo("dotnet")
35+
{
36+
UseShellExecute = false,
37+
CreateNoWindow = true
38+
};
39+
40+
foreach (var a in args)
41+
{
42+
startInfo.ArgumentList.Add(a);
43+
}
44+
45+
// The AppHost uses this environment variable to signal to the CliOrphanDetector which process
46+
// it should monitor in order to know when to stop the CLI. As long as the process still exists
47+
// the orphan detector will allow the CLI to keep running. If the environment variable does
48+
// not exist the orphan detector will exit.
49+
startInfo.EnvironmentVariables["ASPIRE_CLI_PID"] = GetCurrentProcessId().ToString(CultureInfo.InvariantCulture);
50+
51+
using var process = new Process { StartInfo = startInfo };
52+
var started = process.Start();
53+
54+
if (!started)
55+
{
56+
return ExitCodeConstants.FailedToDotnetRunAppHost;
57+
}
58+
59+
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
60+
61+
if (!process.HasExited)
62+
{
63+
process.Kill(false);
64+
}
65+
66+
return process.ExitCode;
67+
}
68+
}

src/Aspire.Cli/ExitCodeConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ internal static class ExitCodeConstants
88
public const int Success = 0;
99
public const int InvalidCommand = 1;
1010
public const int FailedToDotnetRunAppHost = 2;
11+
public const int FailedToInstallTemplates = 3;
12+
public const int FailedToCreateNewProject = 4;
1113
}

src/Aspire.Cli/Program.cs

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,38 @@
55
using System.CommandLine.Parsing;
66
using Microsoft.Extensions.DependencyInjection;
77
using Microsoft.Extensions.Hosting;
8+
using Microsoft.Extensions.Logging;
89

910
namespace Aspire.Cli;
1011

1112
public class Program
1213
{
13-
private static IHost BuildApplication()
14+
private static IHost BuildApplication(ParseResult parseResult)
1415
{
1516
var builder = Host.CreateApplicationBuilder();
17+
18+
var debugOption = parseResult.GetValue<bool>("--debug");
19+
20+
if (!debugOption)
21+
{
22+
builder.Logging.ClearProviders();
23+
}
24+
1625
builder.Services.AddTransient<AppHostRunner>();
26+
builder.Services.AddTransient<DotNetCliRunner>();
1727
var app = builder.Build();
1828
return app;
1929
}
2030

2131
private static RootCommand GetRootCommand()
2232
{
2333
var rootCommand = new RootCommand(".NET Aspire CLI");
34+
var debugOption = new Option<bool>("--debug", "-d");
35+
debugOption.Recursive = true;
36+
rootCommand.Options.Add(debugOption);
2437
ConfigureDevCommand(rootCommand);
2538
ConfigurePackCommand(rootCommand);
39+
ConfigureNewCommand(rootCommand);
2640
return rootCommand;
2741
}
2842

@@ -73,7 +87,7 @@ private static void ConfigureDevCommand(Command parentCommand)
7387
command.Options.Add(projectOption);
7488

7589
command.SetAction(async (parseResult, ct) => {
76-
using var app = BuildApplication();
90+
using var app = BuildApplication(parseResult);
7791
_ = app.RunAsync(ct).ConfigureAwait(false);
7892

7993
var runner = app.Services.GetRequiredService<AppHostRunner>();
@@ -116,7 +130,7 @@ private static void ConfigurePackCommand(Command parentCommand)
116130
command.Options.Add(outputPath);
117131

118132
command.SetAction(async (parseResult, ct) => {
119-
using var app = BuildApplication();
133+
using var app = BuildApplication(parseResult);
120134
_ = app.RunAsync(ct).ConfigureAwait(false);
121135

122136
var runner = app.Services.GetRequiredService<AppHostRunner>();
@@ -133,6 +147,103 @@ private static void ConfigurePackCommand(Command parentCommand)
133147
parentCommand.Subcommands.Add(command);
134148
}
135149

150+
private static void ValidateProjectTemplate(ArgumentResult result)
151+
{
152+
// TODO: We need to integrate with the template engine to interrogate
153+
// the list of available templates. For now we will just hard-code
154+
// the acceptable options.
155+
//
156+
// Once we integrate with template engine we will also be able to
157+
// interrogate the various options and add them. For now we will
158+
// keep it simple.
159+
string[] validTemplates = [
160+
"aspire-starter",
161+
"aspire",
162+
"aspire-apphost",
163+
"aspire-servicedefaults",
164+
"aspire-mstest",
165+
"aspire-nunit",
166+
"aspire-xunit"
167+
];
168+
169+
var value = result.GetValueOrDefault<string>();
170+
171+
if (value is null)
172+
{
173+
// This is OK, for now we will use the default
174+
// template of aspire-starter, but we might
175+
// be able to do more intelligent selection in the
176+
// future based on what is already in the working directory.
177+
return;
178+
}
179+
180+
if (value is { } templateName && !validTemplates.Contains(templateName))
181+
{
182+
result.AddError($"The specified template '{templateName}' is not valid. Valid templates are [{string.Join(", ", validTemplates)}].");
183+
return;
184+
}
185+
}
186+
187+
private static void ConfigureNewCommand(Command parentCommand)
188+
{
189+
var command = new Command("new", "Create a new .NET Aspire-related project.");
190+
var templateArgument = new Argument<string>("template");
191+
templateArgument.Validators.Add(ValidateProjectTemplate);
192+
templateArgument.Arity = ArgumentArity.ZeroOrOne;
193+
command.Arguments.Add(templateArgument);
194+
195+
var nameOption = new Option<string>("--name", "-n");
196+
command.Options.Add(nameOption);
197+
198+
var outputOption = new Option<string?>("--output", "-o");
199+
command.Options.Add(outputOption);
200+
201+
command.SetAction(async (parseResult, ct) => {
202+
using var app = BuildApplication(parseResult);
203+
_ = app.RunAsync(ct).ConfigureAwait(false);
204+
205+
var cliRunner = app.Services.GetRequiredService<DotNetCliRunner>();
206+
var templateInstallExitCode = await cliRunner.InstallTemplateAsync("Aspire.ProjectTemplates", "*-*", true, ct).ConfigureAwait(false);
207+
208+
if (templateInstallExitCode != 0)
209+
{
210+
return ExitCodeConstants.FailedToInstallTemplates;
211+
}
212+
213+
var templateName = parseResult.GetValue<string>("template") ?? "aspire-starter";
214+
215+
if (parseResult.GetValue<string>("--output") is not { } outputPath)
216+
{
217+
outputPath = Environment.CurrentDirectory;
218+
}
219+
else
220+
{
221+
outputPath = Path.GetFullPath(outputPath);
222+
}
223+
224+
if (parseResult.GetValue<string>("--name") is not { } name)
225+
{
226+
var outputPathDirectoryInfo = new DirectoryInfo(outputPath);
227+
name = outputPathDirectoryInfo.Name;
228+
}
229+
230+
var newProjectExitCode = await cliRunner.NewProjectAsync(
231+
templateName,
232+
name,
233+
outputPath,
234+
ct).ConfigureAwait(false);
235+
236+
if (newProjectExitCode != 0)
237+
{
238+
return ExitCodeConstants.FailedToCreateNewProject;
239+
}
240+
241+
return 0;
242+
});
243+
244+
parentCommand.Subcommands.Add(command);
245+
}
246+
136247
public static async Task<int> Main(string[] args)
137248
{
138249
var rootCommand = GetRootCommand();

0 commit comments

Comments
 (0)