// 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.CodeAnalysis;
using System.Globalization;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Backchannel;
/// <summary>
/// Result of resolving an AppHost connection.
/// </summary>
internal sealed class AppHostConnectionResult
{
public IAppHostAuxiliaryBackchannel? Connection { get; init; }
[MemberNotNullWhen(true, nameof(Connection))]
[MemberNotNullWhen(false, nameof(ErrorMessage))]
public bool Success => Connection is not null;
public string? ErrorMessage { get; init; }
public int? ExitCode { get; init; }
[MemberNotNullWhen(true, nameof(ExitCode))]
public bool IsProjectResolutionError => ExitCode is CliExitCodes.FailedToFindProject or CliExitCodes.SdkNotInstalled;
}
/// <summary>
/// Discovers and resolves connections to running AppHosts when the socket path is not known.
/// Scans for running AppHosts and prompts the user to select one if multiple are found.
/// Used by CLI commands (stop, resources, logs, telemetry) that need to find a running AppHost.
/// For managing a specific instance when the socket path is known, use <see cref="Projects.RunningInstanceManager"/> instead.
/// </summary>
internal sealed class AppHostConnectionResolver(
IAuxiliaryBackchannelMonitor backchannelMonitor,
IInteractionService interactionService,
IProjectLocator projectLocator,
CliExecutionContext executionContext,
ICliHostEnvironment hostEnvironment,
ILogger<AppHostConnectionResolver> logger,
ProfilingTelemetry profilingTelemetry)
{
/// <summary>
/// Resolves all running AppHost connections using socket-first discovery.
/// Used when stopping all running AppHosts (e.g., via --all flag).
/// </summary>
/// <param name="scanningMessage">Message to display while scanning for AppHosts.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All resolved connections, or an empty array if none found.</returns>
public async Task<AppHostConnectionResult[]> ResolveAllConnectionsAsync(
string scanningMessage,
CancellationToken cancellationToken)
{
var connections = await interactionService.ShowStatusAsync(
scanningMessage,
async () =>
{
await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
return backchannelMonitor.Connections.ToList();
});
if (connections.Count == 0)
{
return [];
}
return connections.Select(c => new AppHostConnectionResult { Connection = c }).ToArray();
}
/// <summary>
/// Resolves an AppHost connection using socket-first discovery.
/// </summary>
/// <param name="projectFile">Optional project file. If specified, uses fast path to find matching socket.</param>
/// <param name="scanningMessage">Message to display while scanning for AppHosts.</param>
/// <param name="selectPrompt">Prompt to display when multiple AppHosts are found.</param>
/// <param name="notFoundMessage">Message to display when no AppHosts are found.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="restrictToCurrentWorktree">
/// Whether AppHosts running in a different git worktree are hidden from interactive selection.
/// AppHosts elsewhere in the same worktree remain selectable.
/// </param>
/// <returns>The resolved connection, or null with an error message.</returns>
public async Task<AppHostConnectionResult> ResolveConnectionAsync(
FileInfo? projectFile,
string scanningMessage,
string selectPrompt,
string notFoundMessage,
CancellationToken cancellationToken,
bool restrictToCurrentWorktree = false)
{
// Fast path: If --apphost was specified, check directly for its socket
if (projectFile is not null)
{
var explicitDirectory = Directory.Exists(projectFile.FullName);
if (explicitDirectory)
{
try
{
var searchResult = await projectLocator.UseOrFindAppHostProjectFileAsync(
projectFile,
MultipleAppHostProjectsFoundBehavior.Throw,
createSettingsFile: false,
cancellationToken).ConfigureAwait(false);
projectFile = searchResult.SelectedProjectFile;
}
catch (ProjectLocatorException ex)
{
var (exitCode, errorMessage) = ProjectLocatorErrorHelper.GetExitCodeAndMessage(ex, projectOptionSpecifiedAsDirectory: true);
return new AppHostConnectionResult
{
ErrorMessage = errorMessage,
ExitCode = exitCode,
};
}
if (projectFile is null)
{
return new AppHostConnectionResult
{
ErrorMessage = InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsNoAppHosts,
ExitCode = CliExitCodes.FailedToFindProject,
};
}
}
else if (!projectFile.Exists)
{
return new AppHostConnectionResult
{
ErrorMessage = InteractionServiceStrings.ProjectOptionDoesntExist,
ExitCode = CliExitCodes.FailedToFindProject,
};
}
var matchingSockets = AppHostSocketManager.FindSockets(
projectFile.FullName,
executionContext.HomeDirectory.FullName,
Environment.ProcessId,
logger);
// Try each matching socket until we get a connection
foreach (var appHostSocket in matchingSockets)
{
try
{
var connection = await AppHostAuxiliaryBackchannel.ConnectAsync(
appHostSocket, logger, profilingTelemetry, cancellationToken).ConfigureAwait(false);
if (connection is not null)
{
var result = new AppHostConnectionResult { Connection = connection };
StoreAppHostCliLogFilePath(result);
return result;
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to connect to socket at {SocketPath}", appHostSocket.SocketPath);
}
}
// Display the path the user supplied (not the symlink-resolved lookup path) so the
// error message stays relative to the working directory and matches what they typed.
var displayPath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName);
return new AppHostConnectionResult
{
ErrorMessage = string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.AppHostNotRunningAtPath, displayPath)
};
}
// Socket-first approach: Scan for running AppHosts via their sockets
// This is fast because it only looks at ~/.aspire/backchannels/ directory
// rather than recursively searching the entire directory tree for project files
var connections = await interactionService.ShowStatusAsync(
scanningMessage,
async () =>
{
await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
return backchannelMonitor.Connections.ToList();
});
if (connections.Count == 0)
{
return new AppHostConnectionResult { ErrorMessage = notFoundMessage };
}
// Filter to in-scope AppHosts (within working directory)
var workingDirectory = executionContext.WorkingDirectory.FullName;
var inScopeConnections = connections.Where(c => c.IsInScope).ToList();
var outOfScopeConnections = connections.Where(c => !c.IsInScope).ToList();
IAppHostAuxiliaryBackchannel? selectedConnection = null;
if (inScopeConnections.Count == 1)
{
// Only one in-scope AppHost, use it
selectedConnection = inScopeConnections[0];
}
else if (inScopeConnections.Count > 1)
{
if (!hostEnvironment.SupportsInteractiveInput)
{
// Can't prompt the user to pick an AppHost in non-interactive mode;
// fail with an actionable message instead of letting the prompt throw.
return new AppHostConnectionResult
{
ErrorMessage = SharedCommandStrings.MultipleAppHostsNonInteractive,
ExitCode = CliExitCodes.FailedToFindProject,
};
}
selectedConnection = await PromptForAppHostSelectionAsync(
inScopeConnections,
SharedCommandStrings.MultipleInScopeAppHosts,
selectPrompt,
path => Path.GetRelativePath(workingDirectory, path),
cancellationToken);
}
else if (outOfScopeConnections.Count > 0)
{
// "Out of scope" combines two independent conditions: the AppHost is not under the
// working directory, and/or it belongs to a different git worktree. Strict callers
// (aspire stop) only want the worktree half enforced - an AppHost elsewhere in the
// same checkout is still theirs to act on, and hiding it silently breaks running
// `aspire stop` from a sibling directory such as repo/tests.
var selectableConnections = restrictToCurrentWorktree
? outOfScopeConnections.Where(c => IsInWorktreeOfWorkingDirectory(c, workingDirectory)).ToList()
: outOfScopeConnections;
if (selectableConnections.Count == 0)
{
// Every running AppHost lives in a different worktree. "Use 'aspire run' to start
// one first" would be wrong here - one is already running - so point at the two
// escape hatches that can reach it instead.
return new AppHostConnectionResult
{
ErrorMessage = SharedCommandStrings.AppHostNotRunningInCurrentWorktree,
ExitCode = CliExitCodes.FailedToFindProject,
};
}
if (!hostEnvironment.SupportsInteractiveInput)
{
// Treat out-of-scope AppHosts as not found when the caller cannot prompt.
// Explicit --apphost and --all flows bypass this path.
return new AppHostConnectionResult
{
ErrorMessage = notFoundMessage,
ExitCode = CliExitCodes.FailedToFindProject,
};
}
selectedConnection = await PromptForAppHostSelectionAsync(
selectableConnections,
SharedCommandStrings.NoInScopeAppHostsShowingAll,
selectPrompt,
path => path,
cancellationToken);
}
if (selectedConnection is null)
{
return new AppHostConnectionResult { ErrorMessage = notFoundMessage };
}
var selectedResult = new AppHostConnectionResult { Connection = selectedConnection };
StoreAppHostCliLogFilePath(selectedResult);
return selectedResult;
}
/// <summary>
/// Whether an out-of-scope connection belongs to the same git worktree as the working
/// directory. This is the worktree half of
/// <see cref="AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory"/> without the
/// path-containment half, so callers can restrict selection to the current worktree
/// while still offering AppHosts that simply live outside the working directory.
/// </summary>
private static bool IsInWorktreeOfWorkingDirectory(IAppHostAuxiliaryBackchannel connection, string workingDirectory)
{
if (connection.AppHostInfo?.AppHostPath is not { Length: > 0 } appHostPath)
{
// A connection that never reported its path cannot be attributed to a worktree,
// so a caller asking for strict scope must not be offered it.
return false;
}
// Resolve symlinks on both operands for the same reason the in-scope check does: the OS
// reports a process working directory physically (macOS temp dirs under /var -> /private/var)
// while an AppHost reports its own path unresolved, and the worktree walk compares ancestors.
return GitWorktree.IsSameWorktreeScope(
PathNormalizer.ResolveSymlinks(appHostPath),
PathNormalizer.ResolveSymlinks(workingDirectory));
}
/// <summary>
/// Stores the app host's CLI log file path on the execution context so that
/// <see cref="Commands.BaseCommand"/> can display it alongside the current CLI's log path on failure.
/// </summary>
internal void StoreAppHostCliLogFilePath(AppHostConnectionResult result)
{
if (result.Success && result.Connection.AppHostInfo?.CliLogFilePath is { } cliLogFilePath)
{
executionContext.AppHostCliLogFilePath = cliLogFilePath;
}
}
/// <summary>
/// Displays an informational message, prompts the user to select from available AppHost connections,
/// and displays the selected AppHost with a success indicator.
/// </summary>
private async Task<IAppHostAuxiliaryBackchannel?> PromptForAppHostSelectionAsync(
List<IAppHostAuxiliaryBackchannel> candidateConnections,
string contextMessage,
string selectPrompt,
Func<string, string> formatPath,
CancellationToken cancellationToken)
{
interactionService.DisplayMessage(KnownEmojis.Information, contextMessage);
// Order by most recently started first
var choices = candidateConnections
.OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
.Select(c =>
{
var appHostPath = c.AppHostInfo?.AppHostPath ?? "Unknown";
return (Display: formatPath(appHostPath), Connection: c);
})
.ToList();
var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c.EscapeMarkup(),
echoSelected: false,
cancellationToken: cancellationToken);
var selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.UsingAppHost, selectedDisplay));
return selectedConnection;
}
}