// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.CommandLine.Help;
using System.CommandLine.Invocation;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Commands;
internal sealed class ResourceCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement;
private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor;
private readonly IProjectLocator _projectLocator;
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger<ResourceCommand> _logger;
private static readonly Argument<string> s_resourceArgument = new("resource")
{
Description = ResourceCommandStrings.CommandResourceArgumentDescription,
Arity = ArgumentArity.ExactlyOne,
DefaultValueFactory = _ => string.Empty
};
private static readonly Argument<string> s_commandArgument = new("command")
{
Description = ResourceCommandStrings.CommandNameArgumentDescription,
Arity = ArgumentArity.ExactlyOne,
DefaultValueFactory = _ => string.Empty
};
private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
private static readonly Option<bool> s_includeHiddenOption = new("--include-hidden")
{
Description = SharedCommandStrings.IncludeHiddenOptionDescription
};
// Hidden integration point for VS Code. It sends the current partial prompt state to
// the AppHost and receives the dynamically-loaded command input metadata as JSON.
private static readonly Option<bool> s_loadArgumentsOption = new("--load-arguments")
{
Hidden = true
};
/// <summary>
/// Well-known commands with their display metadata.
/// The command names are passed through unchanged; entries only customize progress, success, and error text.
/// </summary>
private static readonly Dictionary<string, (string ProgressVerb, string BaseVerb, string PastTenseVerb)> s_wellKnownCommands = new(StringComparers.CommandName)
{
["start"] = ("Starting", "start", "started"),
["stop"] = ("Stopping", "stop", "stopped"),
["restart"] = ("Restarting", "restart", "restarted"),
["rebuild"] = ("Rebuilding", "rebuild", "rebuilt"),
["set-parameter"] = ("Setting parameter for", "set parameter for", "set"),
["delete-parameter"] = ("Deleting parameter for", "delete parameter for", "deleted"),
["parameter-set"] = ("Setting parameter for", "set parameter for", "set"),
["parameter-delete"] = ("Deleting parameter for", "delete parameter for", "deleted"),
};
private static readonly Dictionary<string, string> s_legacyCommandNameMap = new(StringComparers.CommandName)
{
["parameter-set"] = "set-parameter",
["parameter-delete"] = "delete-parameter",
};
public ResourceCommand(
IAuxiliaryBackchannelMonitor backchannelMonitor,
IProjectLocator projectLocator,
AppHostConnectionResolver connectionResolver,
ILogger<ResourceCommand> logger,
CommonCommandServices services)
: base("resource", ResourceCommandStrings.CommandDescription, services)
{
_backchannelMonitor = backchannelMonitor;
_projectLocator = projectLocator;
_connectionResolver = connectionResolver;
_logger = logger;
Arguments.Add(s_resourceArgument);
Arguments.Add(s_commandArgument);
Options.Add(s_appHostOption);
Options.Add(s_includeHiddenOption);
Options.Add(s_loadArgumentsOption);
Options.Add(new HelpOption { Action = new ResourceCommandHelpAction(this) });
TreatUnmatchedTokensAsErrors = false;
Validators.Add(result =>
{
var resourceName = result.GetValue(s_resourceArgument);
if (string.IsNullOrEmpty(resourceName) || IsOptionLikeToken(resourceName))
{
result.AddError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.ArgumentRequired, s_resourceArgument.Name));
return;
}
var commandName = result.GetValue(s_commandArgument);
if (string.IsNullOrEmpty(commandName) || IsOptionLikeToken(commandName))
{
result.AddError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.ArgumentRequired, s_commandArgument.Name));
}
});
}
protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
// Route human-readable status messages to stderr so that structured command output
// (e.g., JSON) on stdout remains valid and pipeable (e.g., | jq).
// Always route to stderr unconditionally because we cannot know ahead of time whether
// the resource command will produce structured output.
InteractionService.Console = ConsoleOutput.Error;
var resourceName = parseResult.GetValue(s_resourceArgument)!;
var commandName = parseResult.GetValue(s_commandArgument)!;
var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
var includeHidden = parseResult.GetValue(s_includeHiddenOption);
var loadArguments = parseResult.GetValue(s_loadArgumentsOption);
var capturedArguments = parseResult.UnmatchedTokens.ToArray();
var result = await _connectionResolver.ResolveConnectionAsync(
passedAppHostProjectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction),
SharedCommandStrings.AppHostNotRunning,
cancellationToken);
if (!result.Success)
{
return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsError(result, InteractionService, CliExitCodes.FailedToFindProject));
}
var connection = result.Connection!;
var command = await GetCommandMetadataAsync(connection, resourceName, commandName, includeHidden, cancellationToken).ConfigureAwait(false);
var commandArgumentsResult = CreateCommandArguments(command, capturedArguments, loadArguments ? CommandArgumentParseMode.LoadArguments : CommandArgumentParseMode.Execute);
if (commandArgumentsResult.ErrorMessage is { } errorMessage)
{
return CommandResult.Failure(CliExitCodes.InvalidCommand, errorMessage);
}
var commandArguments = commandArgumentsResult.Arguments;
if (loadArguments)
{
return await LoadCommandArgumentsAsync(parseResult, connection, resourceName, commandName, commandArguments, cancellationToken).ConfigureAwait(false);
}
// Use display metadata for well-known command names.
if (s_wellKnownCommands.TryGetValue(commandName, out var knownCommand))
{
return CommandResult.FromExitCode(await ResourceCommandHelper.ExecuteResourceCommandAsync(
connection,
InteractionService,
_logger,
resourceName,
commandName,
knownCommand.ProgressVerb,
knownCommand.BaseVerb,
knownCommand.PastTenseVerb,
commandArguments,
cancellationToken));
}
return CommandResult.FromExitCode(await ResourceCommandHelper.ExecuteGenericCommandAsync(
connection,
InteractionService,
_logger,
resourceName,
commandName,
commandArguments,
cancellationToken));
}
private static async Task<CommandResult> LoadCommandArgumentsAsync(
ParseResult parseResult,
IAppHostAuxiliaryBackchannel connection,
string resourceName,
string commandName,
JsonNode? commandArguments,
CancellationToken cancellationToken)
{
var response = await connection.ExecuteResourceCommandAsync(
resourceName,
commandName,
new ExecuteResourceCommandOptions
{
Arguments = commandArguments,
ValidateOnly = true,
NonInteractive = true,
ReturnArgumentInputs = true
},
cancellationToken).ConfigureAwait(false);
if (response.ArgumentInputs is null)
{
// Surface a concrete diagnostic so partial-state failures (e.g. AppHost returned a
// response with no ArgumentInputs and no Message) are self-describing in the VS Code
// extension log channel rather than producing a silent FailedToExecuteResourceCommand.
var errorMessage = response.Message is { Length: > 0 } message
? message
: "AppHost returned no loaded argument inputs.";
return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, errorMessage);
}
var argumentInputs = response.ArgumentInputs.Select(ResourceSnapshotMapper.MapCommandArgumentInput).ToArray();
var json = JsonSerializer.Serialize(argumentInputs, ResourcesCommandJsonContext.Ndjson.ResourceCommandArgumentJsonArray);
parseResult.InvocationConfiguration.Output.WriteLine(json);
return CommandResult.Success();
}
private static async Task<ResourceSnapshotCommand?> GetCommandMetadataAsync(IAppHostAuxiliaryBackchannel connection, string resourceName, string commandName, bool includeHidden, CancellationToken cancellationToken)
{
var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false);
var resources = ResourceSnapshotMapper.ResolveResources(resourceName, snapshots);
var lookupCommandName = s_legacyCommandNameMap.GetValueOrDefault(commandName, commandName);
return resources
.SelectMany(static resource => resource.Commands)
.FirstOrDefault(command => string.Equals(command.Name, lookupCommandName, StringComparisons.CommandName));
}
private static async Task<(string Name, string Description)[]> GetAvailableCommandMetadataAsync(IAppHostAuxiliaryBackchannel connection, string resourceName, bool includeHidden, CancellationToken cancellationToken)
{
var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false);
var resources = ResourceSnapshotMapper.ResolveResources(resourceName, snapshots);
return resources
.SelectMany(static resource => resource.Commands)
.Where(ResourceSnapshotMapper.IsCommandAvailableToApi)
.GroupBy(static command => command.Name, StringComparers.CommandName)
.OrderBy(static group => group.Key, StringComparers.CommandName)
.Select(static group =>
{
var description = group
.Select(static command => command.Description ?? command.DisplayName)
.FirstOrDefault(static description => !string.IsNullOrEmpty(description));
return (group.Key, description ?? string.Empty);
})
.ToArray();
}
private static (JsonNode? Arguments, string? ErrorMessage) CreateCommandArguments(ResourceSnapshotCommand? command, string[] capturedArguments, CommandArgumentParseMode parseMode)
{
capturedArguments = RemoveDelimiter(capturedArguments);
if (capturedArguments.Length == 0)
{
if (command?.ArgumentInputs is { Length: > 0 } inputs)
{
return CreateCommandArguments(inputs, capturedArguments, parseMode);
}
return (null, null);
}
if (command?.ArgumentInputs is not { Length: > 0 } argumentInputs)
{
// Without command metadata there are no options to give System.CommandLine, so do not infer any values.
// Forward tokens as unknown names and let hosting-side validation reject them.
return (CreateUnknownArguments(capturedArguments), null);
}
return CreateCommandArguments(argumentInputs, capturedArguments, parseMode);
}
private static (JsonObject Arguments, string? ErrorMessage) CreateCommandArguments(ResourceSnapshotCommandArgument[] argumentInputs, string[] capturedArguments, CommandArgumentParseMode parseMode)
{
var arguments = new JsonObject();
var options = new Dictionary<ResourceSnapshotCommandArgument, Option>();
var parserCommand = new Command("resource-command")
{
TreatUnmatchedTokensAsErrors = true
};
foreach (var argument in argumentInputs)
{
var option = CreateCommandArgumentOption(argument, parseMode);
options.Add(argument, option);
parserCommand.Options.Add(option);
}
parserCommand.Validators.Add(result =>
{
var missingRequiredOptions = argumentInputs
.Where(argument => ShouldValidateRequiredOption(argument, parseMode) && result.GetResult(options[argument]) is not { Implicit: false })
.Select(argument => $"--{ToKebabCase(argument.Name)}")
.ToArray();
if (missingRequiredOptions.Length == 1)
{
result.AddError($"Required option '{missingRequiredOptions[0]}' was not provided.");
}
else if (missingRequiredOptions.Length > 1)
{
result.AddError($"Required options were not provided: {string.Join(", ", missingRequiredOptions.Select(static optionName => $"'{optionName}'"))}.");
}
});
// Parse the resource command tail with System.CommandLine as a second pass. The first pass parses Aspire CLI
// options and leaves resource command tokens in ParseResult.UnmatchedTokens; this pass parses those remaining
// tokens against options generated from ResourceSnapshotCommand.ArgumentInputs.
var parseResult = parserCommand.Parse(capturedArguments);
if (parseResult.Errors.Count > 0)
{
var unrecognizedCommandOptions = GroupUnrecognizedCommandOptions(parseResult.UnmatchedTokens);
if (unrecognizedCommandOptions.Length > 0)
{
return (arguments, FormatUnrecognizedCommandOptions(unrecognizedCommandOptions));
}
return (arguments, string.Join(Environment.NewLine, parseResult.Errors.Select(static error => error.Message)));
}
foreach (var argument in argumentInputs)
{
var option = options[argument];
if (parseResult.GetResult(option) is not { Implicit: false })
{
continue;
}
if (option is Option<bool> boolOption)
{
arguments[argument.Name] = parseResult.GetValue(boolOption).ToString().ToLowerInvariant();
}
else if (option is Option<double?> numberOption)
{
var value = parseResult.GetValue(numberOption);
arguments[argument.Name] = value?.ToString(CultureInfo.InvariantCulture);
}
else if (option is Option<string?> stringOption)
{
arguments[argument.Name] = parseResult.GetValue(stringOption);
}
}
foreach (var unmatchedToken in parseResult.UnmatchedTokens)
{
// Metadata-backed command inputs are options only. Any leftover token is forwarded as an unknown argument
// name so hosting-side validation reports it instead of binding it positionally.
// Example: `#name` becomes `{ "#name": null }`, which is not a declared command input.
arguments[unmatchedToken] = null;
}
return (arguments, null);
}
private static string[] RemoveDelimiter(string[] capturedArguments)
{
if (capturedArguments.Length == 0 || capturedArguments[0] is not "--")
{
return capturedArguments;
}
return capturedArguments[1..];
}
private static JsonObject CreateUnknownArguments(string[] capturedArguments)
{
var arguments = new JsonObject();
foreach (var token in GroupOptionLikeArguments(capturedArguments))
{
arguments[token] = null;
}
return arguments;
}
private static Option CreateCommandArgumentOption(ResourceSnapshotCommandArgument argument, CommandArgumentParseMode parseMode)
{
// Resource command input names are exposed as both exact-name and kebab-case System.CommandLine options:
// - "timeoutMilliseconds" accepts "--timeoutMilliseconds" and "--timeout-milliseconds"
// - "LogLevel" accepts "--LogLevel" and "--log-level"
// - "url" accepts "--url"
var optionName = ToKebabCase(argument.Name);
// Dynamic inputs are loaded and validated by the AppHost using the current prompt state.
// Keep their values as strings so stale snapshot metadata cannot reject a value before
// the AppHost has a chance to enable the input or refresh its choice list.
var parseAsString = parseMode == CommandArgumentParseMode.LoadArguments || argument.DynamicLoading is not null;
Option option = (IsBooleanInput(argument), IsNumberInput(argument), parseAsString) switch
{
(true, _, false) => new Option<bool>($"--{optionName}")
{
DefaultValueFactory = _ => bool.TryParse(argument.Value, out var value) && value
},
(_, true, false) => new Option<double?>($"--{optionName}")
{
Arity = ArgumentArity.ExactlyOne,
AllowMultipleArgumentsPerToken = false,
DefaultValueFactory = _ => double.TryParse(argument.Value, CultureInfo.InvariantCulture, out var value) ? value : null
},
_ => new Option<string?>($"--{optionName}")
{
Arity = ArgumentArity.ExactlyOne,
AllowMultipleArgumentsPerToken = false,
DefaultValueFactory = _ => argument.Value
}
};
if (option is Option<bool> boolOption)
{
boolOption.Arity = ArgumentArity.ZeroOrOne;
boolOption.AllowMultipleArgumentsPerToken = false;
}
option.Description = argument.Description ?? argument.Label;
option.Required = ShouldValidateRequiredOption(argument, parseMode);
if (ShouldValidateChoiceOptions(argument, parseMode) && argument.Options is { Count: > 0 } options)
{
option.Validators.Add(result =>
{
var value = result.GetValueOrDefault<string?>();
if (value is not null && !options.ContainsKey(value))
{
result.AddError($"Option '--{optionName}' only accepts the following values: {string.Join(", ", options.Keys)}.");
}
});
}
if (ShouldValidateDisabledOption(argument, parseMode))
{
option.Validators.Add(result =>
{
if (result is { Implicit: false })
{
result.AddError($"Option '--{optionName}' is disabled.");
}
});
}
var exactName = $"--{argument.Name}";
if (!string.Equals(exactName, $"--{optionName}", StringComparison.Ordinal))
{
option.Aliases.Add(exactName);
}
return option;
}
// Static CLI validation only runs for non-dynamic inputs during real execution. In --load-arguments
// mode the caller sends partial prompt state such as `--category=fruit`, and dynamic inputs can
// change their required/disabled state or choice set after the AppHost runs their load callback.
private static bool ShouldValidateRequiredOption(ResourceSnapshotCommandArgument argument, CommandArgumentParseMode parseMode)
{
return parseMode == CommandArgumentParseMode.Execute &&
argument.DynamicLoading is null &&
argument.Required &&
string.IsNullOrEmpty(argument.Value);
}
private static bool ShouldValidateChoiceOptions(ResourceSnapshotCommandArgument argument, CommandArgumentParseMode parseMode)
{
return parseMode == CommandArgumentParseMode.Execute &&
argument.DynamicLoading is null &&
!argument.AllowCustomChoice;
}
private static bool ShouldValidateDisabledOption(ResourceSnapshotCommandArgument argument, CommandArgumentParseMode parseMode)
{
return parseMode == CommandArgumentParseMode.Execute &&
argument.DynamicLoading is null &&
argument.Disabled;
}
private static bool IsBooleanInput(ResourceSnapshotCommandArgument argument)
{
return string.Equals(argument.InputType, "Boolean", StringComparison.OrdinalIgnoreCase);
}
private static bool IsNumberInput(ResourceSnapshotCommandArgument argument)
{
return string.Equals(argument.InputType, "Number", StringComparison.OrdinalIgnoreCase);
}
private static bool IsOptionLikeToken(string value)
{
return value is not "--" && value.StartsWith("-", StringComparison.Ordinal);
}
private static string[] GroupOptionLikeArguments(IReadOnlyList<string> arguments)
{
var groupedArguments = new List<string>();
for (var i = 0; i < arguments.Count; i++)
{
var argument = arguments[i];
if (IsOptionLikeToken(argument) &&
!argument.Contains('=') &&
i + 1 < arguments.Count &&
!IsOptionLikeToken(arguments[i + 1]))
{
groupedArguments.Add($"{argument} {arguments[i + 1]}");
i++;
}
else
{
groupedArguments.Add(argument);
}
}
return [.. groupedArguments];
}
private static string[] GroupUnrecognizedCommandOptions(IReadOnlyList<string> arguments)
{
var groupedArguments = new List<string>();
for (var i = 0; i < arguments.Count; i++)
{
var argument = arguments[i];
if (!IsOptionLikeToken(argument))
{
continue;
}
if (!argument.Contains('=') &&
i + 1 < arguments.Count &&
!IsOptionLikeToken(arguments[i + 1]))
{
groupedArguments.Add($"{argument} {arguments[i + 1]}");
i++;
}
else
{
groupedArguments.Add(argument);
}
}
return [.. groupedArguments];
}
private static string FormatUnrecognizedCommandOptions(string[] optionNames)
{
return optionNames.Length == 1
? $"Unrecognized command option '{optionNames[0]}'."
: $"Unrecognized command options: {string.Join(", ", optionNames.Select(static optionName => $"'{optionName}'"))}.";
}
private static string ToKebabCase(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var builder = new StringBuilder(value.Length + 4);
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (char.IsUpper(ch))
{
if (i > 0 && builder[^1] != '-')
{
builder.Append('-');
}
builder.Append(char.ToLowerInvariant(ch));
}
else
{
builder.Append(ch);
}
}
return builder.ToString();
}
private enum CommandArgumentParseMode
{
// Real command execution can fail fast on static metadata because the caller is submitting
// a complete invocation. Dynamic inputs are the exception; their validation waits for the
// AppHost because their metadata can change after load callbacks run.
Execute,
// Dynamic-input probing sends partial prompt state to the AppHost, such as only
// `--category=fruit`. Missing required values, disabled dependent inputs, and stale choice
// options must be accepted here so the AppHost can return the next metadata shape.
LoadArguments
}
private sealed class ResourceCommandHelpAction(ResourceCommand command) : AsynchronousCommandLineAction
{
private readonly HelpAction _defaultHelpAction = new();
public override async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var request = ResourceCommandHelpParser.Parse(parseResult, s_resourceArgument, s_commandArgument, s_appHostOption);
if (request is null)
{
var exitCode = _defaultHelpAction.Invoke(parseResult);
if (TryGetResourceOnlyHelp(parseResult, out var resourceName))
{
try
{
await WriteAvailableCommandsAsync(parseResult, resourceName, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
command._logger.LogDebug(ex, "Failed to augment resource help with available resource commands.");
}
}
return exitCode;
}
var result = await command._connectionResolver.ResolveConnectionAsync(
request.AppHostProjectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction),
SharedCommandStrings.AppHostNotRunning,
cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
return _defaultHelpAction.Invoke(parseResult);
}
var includeHidden = parseResult.GetValue(s_includeHiddenOption);
var resourceCommand = await GetCommandMetadataAsync(result.Connection!, request.ResourceName, request.CommandName, includeHidden, cancellationToken).ConfigureAwait(false);
if (resourceCommand is null)
{
return _defaultHelpAction.Invoke(parseResult);
}
WriteResourceCommandHelp(parseResult.InvocationConfiguration.Output, parseResult.CommandResult, request.ResourceName, resourceCommand);
return CliExitCodes.Success;
}
private async Task WriteAvailableCommandsAsync(ParseResult parseResult, string resourceName, CancellationToken cancellationToken)
{
var connection = await ResolveConnectionForAvailableCommandsAsync(parseResult, cancellationToken).ConfigureAwait(false);
if (connection is null)
{
return;
}
var includeHidden = parseResult.GetValue(s_includeHiddenOption);
var commands = await GetAvailableCommandMetadataAsync(connection, resourceName, includeHidden, cancellationToken).ConfigureAwait(false);
if (commands.Length == 0)
{
return;
}
GroupedHelpWriter.WriteTwoColumnSection(
parseResult.InvocationConfiguration.Output,
ResourceCommandStrings.AvailableResourceCommands,
commands,
maxWidth: 120,
trailingBlankLine: false);
}
private async Task<IAppHostAuxiliaryBackchannel?> ResolveConnectionForAvailableCommandsAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var appHostProjectFile = parseResult.GetValue(s_appHostOption);
if (appHostProjectFile is not null)
{
return await ResolveExplicitConnectionForAvailableCommandsAsync(appHostProjectFile, cancellationToken).ConfigureAwait(false);
}
var inScopeConnections = await command.InteractionService.ShowStatusAsync(
SharedCommandStrings.ScanningForRunningAppHosts,
async () =>
{
await command._backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
return command._backchannelMonitor.Connections.Where(static connection => connection.IsInScope).ToList();
});
return inScopeConnections.Count == 1 ? inScopeConnections[0] : null;
}
private async Task<IAppHostAuxiliaryBackchannel?> ResolveExplicitConnectionForAvailableCommandsAsync(FileInfo appHostProjectFile, CancellationToken cancellationToken)
{
FileInfo? selectedAppHostProjectFile = appHostProjectFile;
if (Directory.Exists(appHostProjectFile.FullName))
{
var searchResult = await command._projectLocator.UseOrFindAppHostProjectFileAsync(
appHostProjectFile,
MultipleAppHostProjectsFoundBehavior.Throw,
createSettingsFile: false,
cancellationToken).ConfigureAwait(false);
selectedAppHostProjectFile = searchResult.SelectedProjectFile;
}
else if (!appHostProjectFile.Exists)
{
return null;
}
if (selectedAppHostProjectFile is null)
{
return null;
}
var targetPath = Path.GetFullPath(selectedAppHostProjectFile.FullName);
var matchingConnections = await command.InteractionService.ShowStatusAsync(
SharedCommandStrings.ScanningForRunningAppHosts,
async () =>
{
await command._backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
return command._backchannelMonitor.Connections
.Where(connection => IsMatchingAppHostPath(connection.AppHostInfo?.AppHostPath, targetPath))
.ToList();
});
return matchingConnections.Count == 1 ? matchingConnections[0] : null;
}
private static bool IsMatchingAppHostPath(string? appHostPath, string targetPath)
{
return !string.IsNullOrEmpty(appHostPath) &&
string.Equals(Path.GetFullPath(appHostPath), targetPath, StringComparison.OrdinalIgnoreCase);
}
private static bool TryGetResourceOnlyHelp(ParseResult parseResult, [NotNullWhen(true)] out string? resourceName)
{
// Resource-only help is `aspire resource <resource> --help`. Because the command argument has a default,
// System.CommandLine can bind the next option token (or an option value like --apphost's path) as the
// command. Treat those as "no command" so resource-scoped help can still show the resource's commands.
var resourceArgumentResult = parseResult.GetResult(s_resourceArgument);
resourceName = resourceArgumentResult?.Tokens.Count > 0 ? resourceArgumentResult.Tokens[0].Value : null;
var commandArgumentResult = parseResult.GetResult(s_commandArgument);
var commandName = commandArgumentResult?.Tokens.Count > 0 ? commandArgumentResult.Tokens[0].Value : null;
var appHostOptionValue = GetOptionTokenValue(parseResult, s_appHostOption.InnerOption) ?? GetOptionTokenValue(parseResult, s_appHostOption.LegacyOption);
var hasResourceName = !string.IsNullOrEmpty(resourceName) && !IsOptionLikeToken(resourceName);
// The command slot is considered empty when it has no token, when it captured an option like --help,
// or when it captured the value for --apphost/--project instead of an actual resource command name.
var hasNoCommandName = string.IsNullOrEmpty(commandName) ||
IsOptionLikeToken(commandName) ||
string.Equals(commandName, appHostOptionValue, StringComparison.Ordinal);
return hasResourceName && hasNoCommandName;
}
private static string? GetOptionTokenValue(ParseResult parseResult, Option<FileInfo?> option)
{
var result = parseResult.GetResult(option);
return result?.Tokens.Count > 0 ? result.Tokens[0].Value : null;
}
private static void WriteResourceCommandHelp(TextWriter writer, System.CommandLine.Parsing.CommandResult commandResult, string resourceName, ResourceSnapshotCommand command)
{
var cliOptionNames = GetCliOptionNames(commandResult);
writer.WriteLine(command.Description is { Length: > 0 } ? command.Description : command.DisplayName ?? command.Name);
writer.WriteLine();
GroupedHelpWriter.WriteUsage(
writer,
string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.CommandSpecificHelpUsageSyntax, resourceName, command.Name));
if (command.ArgumentInputs.Length > 0)
{
GroupedHelpWriter.WriteTwoColumnSection(
writer,
ResourceCommandStrings.CommandSpecificHelpCommandOptions,
command.ArgumentInputs.Select(argument => (GetCommandOptionLabel(argument), GetArgumentDescription(argument, cliOptionNames))),
maxWidth: 120);
}
GroupedHelpWriter.WriteTwoColumnSection(
writer,
HelpGroupStrings.Options,
GetVisibleCliOptions(commandResult).Select(static option => (GroupedHelpWriter.FormatOptionLabel(option, includeValueName: true), option.Description ?? string.Empty)),
maxWidth: 120,
trailingBlankLine: false);
}
private static IEnumerable<Option> GetVisibleCliOptions(System.CommandLine.Parsing.CommandResult commandResult)
{
var seenOptionNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in commandResult.Command.Options)
{
if (!option.Hidden && seenOptionNames.Add(option.Name))
{
yield return option;
}
}
var current = commandResult.Parent;
while (current is System.CommandLine.Parsing.CommandResult parentCommandResult)
{
foreach (var option in parentCommandResult.Command.Options)
{
if (option.Recursive && !option.Hidden && seenOptionNames.Add(option.Name))
{
yield return option;
}
}
current = parentCommandResult.Parent;
}
}
private static HashSet<string> GetCliOptionNames(System.CommandLine.Parsing.CommandResult commandResult)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddOptionNames(commandResult.Command.Options, includeOnlyRecursive: false, names);
var current = commandResult.Parent;
while (current is System.CommandLine.Parsing.CommandResult parentCommandResult)
{
AddOptionNames(parentCommandResult.Command.Options, includeOnlyRecursive: true, names);
current = parentCommandResult.Parent;
}
return names;
}
private static void AddOptionNames(IEnumerable<Option> options, bool includeOnlyRecursive, HashSet<string> names)
{
foreach (var option in options)
{
if (includeOnlyRecursive && !option.Recursive)
{
continue;
}
AddLongOptionName(option.Name, names);
foreach (var alias in option.Aliases)
{
AddLongOptionName(alias, names);
}
}
}
private static void AddLongOptionName(string optionName, HashSet<string> names)
{
if (optionName.StartsWith("--", StringComparison.Ordinal))
{
names.Add(optionName[2..]);
}
else if (!optionName.StartsWith("-", StringComparison.Ordinal))
{
names.Add(optionName);
}
}
private static string GetCommandOptionLabel(ResourceSnapshotCommandArgument argument)
{
var optionName = ToKebabCase(argument.Name);
return IsBooleanInput(argument)
? $"--{optionName}"
: $"--{optionName} <{ResourceCommandStrings.CommandSpecificHelpValuePlaceholder}>";
}
private static string GetArgumentDescription(ResourceSnapshotCommandArgument argument, HashSet<string> cliOptionNames)
{
var parts = new List<string>();
if (argument.Description is { Length: > 0 })
{
parts.Add(argument.Description);
}
else if (argument.Label is { Length: > 0 })
{
parts.Add(argument.Label);
}
if (argument.Required && string.IsNullOrEmpty(argument.Value))
{
parts.Add(ResourceCommandStrings.CommandSpecificHelpRequired);
}
if (argument.Options is { Count: > 0 } options)
{
parts.Add(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.CommandSpecificHelpAllowedValues, string.Join(", ", options.Keys)));
}
if (argument.Value is { Length: > 0 } value)
{
parts.Add(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.CommandSpecificHelpDefaultValue, value));
}
var optionName = ToKebabCase(argument.Name);
if (cliOptionNames.Contains(optionName))
{
var valuePlaceholder = IsBooleanInput(argument)
? string.Empty
: $" <{ResourceCommandStrings.CommandSpecificHelpValuePlaceholder}>";
parts.Add(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.CommandSpecificHelpDelimiterHint, optionName, valuePlaceholder));
}
return string.Join(" ", parts);
}
}
}