Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down Expand Up @@ -34,6 +34,7 @@

<ItemGroup>
<Compile Include="$(RepoRoot)src\Shared\KnownConfigNames.cs" Link="KnownConfigNames.cs" />
<Compile Include="$(SharedDir)PathNormalizer.cs" Link="Utils\PathNormalizer.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
12 changes: 12 additions & 0 deletions src/Aspire.Cli/CliSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Aspire.Cli;

internal class CliSettings
{
[JsonPropertyName("appHostPath")]
public string? AppHostPath { get; set; }
}
13 changes: 8 additions & 5 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
{
var integrationName = parseResult.GetValue<string>("integration");

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

var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
{
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
});

if (effectiveAppHostProjectFile is null)
{
return ExitCodeConstants.FailedToFindProject;
Expand Down Expand Up @@ -145,9 +148,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
_interactionService.DisplayError("The --project option specified a project that does not exist.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files"))
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found."))
{
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))
Expand Down
13 changes: 8 additions & 5 deletions src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
{
using var activity = _activitySource.StartActivity();

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

var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
{
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
});

if (effectiveAppHostProjectFile is null)
{
return ExitCodeConstants.FailedToFindProject;
Expand Down Expand Up @@ -273,9 +276,9 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
_interactionService.DisplayError("The --project option specified a project that does not exist.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files"))
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found."))
{
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))
Expand Down
9 changes: 6 additions & 3 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
{
using var activity = _activitySource.StartActivity();

var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile);
var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () =>
{
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken);
});

if (effectiveAppHostProjectFile is null)
{
Expand Down Expand Up @@ -223,7 +226,7 @@ await _ansiConsole.Live(table).StartAsync(async context =>
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files"))
{
_interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected.");
_interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected.");
return ExitCodeConstants.FailedToFindProject;
}
catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file"))
Expand Down
12 changes: 12 additions & 0 deletions src/Aspire.Cli/JsonSourceGenerationContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Aspire.Cli;

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(CliSettings))]
internal partial class JsonSourceGenerationContext : JsonSerializerContext
{
}
40 changes: 39 additions & 1 deletion src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Microsoft.Extensions.Configuration;

#if DEBUG
using OpenTelemetry;
Expand All @@ -28,9 +29,45 @@ public class Program
{
private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Program));

/// <summary>
/// This method walks up the directory tree looking for the .aspire/settings.json files
/// and then adds them to the host as a configuration source. This means that the settings
/// architecture for the CLI will just be standard .NET configuraiton.
/// </summary>
private static void SetupAppHostOptions(HostApplicationBuilder builder)
{
var settingsFiles = new List<FileInfo>();
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());

while (true)
{
var settingsFilePath = Path.Combine(currentDirectory.FullName, ".aspire", "settings.json");

if (File.Exists(settingsFilePath))
{
var settingsFile = new FileInfo(settingsFilePath);
settingsFiles.Add(settingsFile);
}

if (currentDirectory.Parent is null)
{
break;
}

currentDirectory = currentDirectory.Parent;
}

settingsFiles.Reverse();
foreach (var settingsFile in settingsFiles)
{
builder.Configuration.AddJsonFile(settingsFile.FullName);
}
}

private static IHost BuildApplication(string[] args)
{
var builder = Host.CreateApplicationBuilder();
SetupAppHostOptions(builder);

builder.Logging.ClearProviders();

Expand Down Expand Up @@ -114,7 +151,8 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider)
private static IProjectLocator BuildProjectLocator(IServiceProvider serviceProvider)
{
var logger = serviceProvider.GetRequiredService<ILogger<ProjectLocator>>();
return new ProjectLocator(logger, Directory.GetCurrentDirectory());
var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
return new ProjectLocator(logger, runner, new DirectoryInfo(Directory.GetCurrentDirectory()));
}

public static async Task<int> Main(string[] args)
Expand Down
128 changes: 116 additions & 12 deletions src/Aspire.Cli/Projects/ProjectLocator.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,92 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Projects;

internal interface IProjectLocator
{
FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile);
Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default);
}

internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, string currentDirectory) : IProjectLocator
internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, IDotNetCliRunner runner, DirectoryInfo currentDirectory) : IProjectLocator
{
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
private readonly ActivitySource _activitySource = new(nameof(ProjectLocator));

private async Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken)
{
using var activity = _activitySource.StartActivity();

var appHostProjects = new List<FileInfo>();

logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName);
var projectFiles = searchDirectory.GetFiles("*.csproj", SearchOption.AllDirectories);
logger.LogDebug("Found {ProjectFileCount} project files in {SearchDirectory}", projectFiles.Length, searchDirectory.FullName);

foreach (var projectFile in projectFiles)
{
logger.LogDebug("Checking project file {ProjectFile}", projectFile.FullName);
var information = await runner.GetAppHostInformationAsync(projectFile, cancellationToken);

if (information.ExitCode == 0 && information.IsAspireHost)
{
logger.LogDebug("Found AppHost project file {ProjectFile} in {SearchDirectory}", projectFile.FullName, searchDirectory.FullName);
appHostProjects.Add(projectFile);
}
else
{
logger.LogTrace("Project file {ProjectFile} in {SearchDirectory} is not an Aspire host", projectFile.FullName, searchDirectory.FullName);
}
}

return appHostProjects;
}

private async Task<FileInfo?> GetAppHostProjectFileFromSettingsAsync(CancellationToken cancellationToken)
{
var searchDirectory = currentDirectory;

while (true)
{
var settingsFile = new FileInfo(Path.Combine(searchDirectory.FullName, ".aspire", "settings.json"));

if (settingsFile.Exists)
{
using var stream = settingsFile.OpenRead();
var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);

if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath )
{

var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath);
var appHostFile = new FileInfo(qualifiedAppHostPath);

if (appHostFile.Exists)
{
return appHostFile;
}
else
{
throw new ProjectLocatorException($"AppHost file was specified in '{settingsFile.FullName}' but it does not exist.");
}
}
}

if (searchDirectory.Parent is not null)
{
searchDirectory = searchDirectory.Parent;
}
else
{
return null;
}
}
}

public async Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default)
{
logger.LogDebug("Finding project file in {CurrentDirectory}", currentDirectory);

Expand All @@ -29,22 +103,52 @@ internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, string curr
return projectFile;
}

projectFile = await GetAppHostProjectFileFromSettingsAsync(cancellationToken);

if (projectFile is not null)
{
return projectFile;
}

logger.LogDebug("No project file specified, searching for *.csproj files in {CurrentDirectory}", currentDirectory);
var projectFilePaths = Directory.GetFiles(currentDirectory, "*.csproj");
var appHostProjects = await FindAppHostProjectFilesAsync(currentDirectory, cancellationToken);

logger.LogDebug("Found {ProjectFileCount} project files.", projectFilePaths.Length);
logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count);

return projectFilePaths switch {
{ Length: 0 } => throw new ProjectLocatorException("No project file found."),
{ Length: > 1 } => throw new ProjectLocatorException("Multiple project files found."),
{ Length: 1 } => new FileInfo(projectFilePaths[0]),
var selectedAppHost = appHostProjects.Count switch {
0 => throw new ProjectLocatorException("No project file found."),
> 1 => throw new ProjectLocatorException("Multiple project files found."),
1 => appHostProjects[0],
_ => throw new ProjectLocatorException("Unexpected number of project files found.")
};

await CreateSettingsFileIfNotExistsAsync(selectedAppHost, cancellationToken);
return selectedAppHost;
}

private async Task CreateSettingsFileIfNotExistsAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
var settingsFile = new FileInfo(Path.Combine(currentDirectory.FullName, ".aspire", "settings.json"));

if (!settingsFile.Exists)
{
if (!settingsFile.Directory!.Exists)
{
settingsFile.Directory.Create();
}

var settings = new CliSettings
{
AppHostPath = Path.GetRelativePath(settingsFile.Directory.FullName, projectFile.FullName)
};

using var stream = settingsFile.OpenWrite();
await JsonSerializer.SerializeAsync(stream, settings, JsonSourceGenerationContext.Default.CliSettings, cancellationToken);
}
}
}

internal class ProjectLocatorException : System.Exception
{
public ProjectLocatorException() { }
public ProjectLocatorException(string message) : base(message) { }
public ProjectLocatorException(string message, System.Exception inner) : base(message, inner) { }
}
}
11 changes: 6 additions & 5 deletions tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
Expand Down Expand Up @@ -77,7 +78,7 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode(

private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator
{
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
{
throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.");
}
Expand Down Expand Up @@ -107,17 +108,17 @@ public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, Cancellation
}
}

private sealed class NoProjectFileProjectLocator : Aspire.Cli.Projects.IProjectLocator
private sealed class NoProjectFileProjectLocator : IProjectLocator
{
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
{
throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.");
}
}

private sealed class MultipleProjectFilesProjectLocator : Aspire.Cli.Projects.IProjectLocator
private sealed class MultipleProjectFilesProjectLocator : IProjectLocator
{
public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile)
public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken)
{
throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.");
}
Expand Down
Loading