File: Commands\TerminalPsCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Lists every <c>WithTerminal</c>-enabled resource in the connected AppHost, with current grid
/// size, attached-peer count, and per-replica health. Backs <c>aspire terminal ps</c>.
/// </summary>
/// <remarks>
/// The command:
/// <list type="number">
/// <item>Resolves the running AppHost via <see cref="AppHostConnectionResolver"/>.</item>
/// <item>Verifies the AppHost advertises the <c>terminals.v1</c> capability and falls back to a
///   "not supported" error message when it does not (older AppHosts pre-13.4 lack the entire
///   WithTerminal/<c>terminal attach</c>+<c>terminal ps</c> surface).</item>
/// <item>Calls <see cref="IAppHostAuxiliaryBackchannel.ListTerminalsAsync"/> to enumerate every
///   terminal-enabled resource. Resources whose host process isn't reachable are still listed
///   with a status indicating they are unavailable rather than silently dropped.</item>
/// <item>Renders the result as a Spectre.Console table or — when <c>--format json</c> is supplied
///   — a flat JSON document for scripting.</item>
/// </list>
/// </remarks>
internal sealed class TerminalPsCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
 
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<TerminalPsCommand> _logger;
 
    private static readonly OptionWithLegacy<FileInfo?> s_appHostOption =
        new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
 
    private static readonly Option<OutputFormat> s_formatOption = new("--format")
    {
        Description = "Output format. 'text' (default) renders a table; 'json' emits structured output for scripting."
    };
 
    private static readonly Option<bool> s_verboseOption = new("--verbose", "-v")
    {
        Description = "Include per-peer details for every attached HMP1 viewer (peer id, display name)."
    };
 
    public TerminalPsCommand(
        AppHostConnectionResolver connectionResolver,
        ILogger<TerminalPsCommand> logger,
        CommonCommandServices services)
        : base("ps", "List interactive terminal sessions in the connected AppHost.", services)
    {
        _interactionService = services.InteractionService;
        _logger = logger;
        _connectionResolver = connectionResolver;
 
        Options.Add(s_appHostOption);
        Options.Add(s_formatOption);
        Options.Add(s_verboseOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
        var format = parseResult.GetValue(s_formatOption);
        var verbose = parseResult.GetValue(s_verboseOption);
 
        _logger.LogDebug(
            "Starting 'terminal ps' (format={Format}, verbose={Verbose}, appHost={AppHost})",
            format,
            verbose,
            passedAppHostProjectFile?.FullName ?? "<auto-detected>");
 
        var connectionResult = await _connectionResolver.ResolveConnectionAsync(
            passedAppHostProjectFile,
            SharedCommandStrings.ScanningForRunningAppHosts,
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, "list terminals"),
            SharedCommandStrings.AppHostNotRunning,
            cancellationToken);
 
        if (!connectionResult.Success)
        {
            // Project-resolution failures (bad --apphost path, no AppHost project found, missing SDK)
            // must surface as an error with the correct non-zero exit code so scripts/CI can detect them.
            // For "no running AppHost" (a normal state for `ps`), the JSON branch still emits `[]` so
            // structured consumers don't have to special-case it.
            if (connectionResult.IsProjectResolutionError)
            {
                return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsInformation(connectionResult, _interactionService));
            }
            if (format == OutputFormat.Json)
            {
                _interactionService.DisplayRawText("[]", ConsoleOutput.Standard);
                return CommandResult.Success();
            }
            _interactionService.DisplayMessage(KnownEmojis.Information, connectionResult.ErrorMessage);
            return CommandResult.Success();
        }
 
        var connection = connectionResult.Connection!;
 
        if (!connection.SupportsTerminalsV1)
        {
            // Don't downgrade to 'no terminals' — older AppHosts may still have terminal-enabled
            // resources visible via 'terminal attach', so a misleading empty list would be worse
            // than an explicit incompatibility error.
            _interactionService.DisplayError(
                "The connected AppHost does not support 'aspire terminal ps'. Update Aspire.Hosting to a build that advertises the 'terminals.v1' capability.");
            return CommandResult.Failure(CliExitCodes.AppHostIncompatible);
        }
 
        var response = await _interactionService.ShowStatusAsync(
            "Listing terminal sessions...",
            async () => await connection.ListTerminalsAsync(cancellationToken).ConfigureAwait(false));
 
        if (response.Terminals.Length == 0)
        {
            if (format == OutputFormat.Json)
            {
                _interactionService.DisplayRawText("[]", ConsoleOutput.Standard);
            }
            else
            {
                _interactionService.DisplayMessage(KnownEmojis.Information,
                    "No resources in the connected AppHost are configured for interactive terminals (`.WithTerminal()`).");
            }
            return CommandResult.Success();
        }
 
        _logger.LogDebug(
            "ListTerminalsAsync returned {Count} terminal(s); reachable={Reachable}",
            response.Terminals.Length,
            response.Terminals.Count(t => t.IsHostReachable));
 
        if (format == OutputFormat.Json)
        {
            EmitJson(response, verbose);
        }
        else
        {
            DisplayTable(response, verbose);
        }
 
        return CommandResult.Success();
    }
 
    private void EmitJson(Aspire.Cli.Backchannel.ListTerminalsResponse response, bool verbose)
    {
        var dtos = new List<TerminalPsJsonEntry>(response.Terminals.Length);
        foreach (var terminal in response.Terminals)
        {
            var replicas = new List<TerminalPsJsonReplica>(terminal.Replicas?.Length ?? 0);
            if (terminal.Replicas is not null)
            {
                foreach (var replica in terminal.Replicas)
                {
                    replicas.Add(new TerminalPsJsonReplica
                    {
                        ReplicaIndex = replica.ReplicaIndex,
                        IsAlive = replica.IsAlive,
                        ExitCode = replica.ExitCode,
                        ProducerConnected = replica.ProducerConnected,
                        RestartCount = replica.RestartCount,
                        CurrentColumns = replica.CurrentColumns,
                        CurrentRows = replica.CurrentRows,
                        AttachedPeerCount = replica.AttachedPeerCount,
                        Peers = verbose && replica.Peers is not null
                            ? replica.Peers.Select(p => new TerminalPsJsonPeer
                            {
                                PeerId = p.PeerId,
                                DisplayName = p.DisplayName,
                            }).ToArray()
                            : null,
                    });
                }
            }
 
            dtos.Add(new TerminalPsJsonEntry
            {
                ResourceName = terminal.ResourceName,
                DisplayName = terminal.DisplayName,
                ConfiguredColumns = terminal.ConfiguredColumns,
                ConfiguredRows = terminal.ConfiguredRows,
                IsHostReachable = terminal.IsHostReachable,
                Replicas = replicas.ToArray(),
            });
        }
 
        var json = JsonSerializer.Serialize(dtos, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry);
        _interactionService.DisplayRawText(json, ConsoleOutput.Standard);
    }
 
    private void DisplayTable(Aspire.Cli.Backchannel.ListTerminalsResponse response, bool verbose)
    {
        var table = new Table();
        table.AddBoldColumn("Resource");
        table.AddBoldColumn("Replica");
        table.AddBoldColumn("Status");
        table.AddBoldColumn("Size");
        table.AddBoldColumn("Peers");
        table.AddBoldColumn("Restarts");
 
        foreach (var terminal in response.Terminals)
        {
            // Host-unreachable terminals get one row with a placeholder so users understand why
            // detail is missing rather than silently omitting them.
            if (!terminal.IsHostReachable || terminal.Replicas is null || terminal.Replicas.Length == 0)
            {
                table.AddRow(
                    Markup.Escape(terminal.DisplayName),
                    "-",
                    "[yellow]host unreachable[/]",
                    string.Format(CultureInfo.InvariantCulture, "{0}x{1}", terminal.ConfiguredColumns, terminal.ConfiguredRows),
                    "-",
                    "-");
                continue;
            }
 
            foreach (var replica in terminal.Replicas)
            {
                var status = replica.IsAlive
                    ? "[green]alive[/]"
                    : replica.ExitCode is { } code
                        ? string.Format(CultureInfo.InvariantCulture, "exited ({0})", code)
                        : "waiting";
 
                var size = replica.CurrentColumns is { } cols && replica.CurrentRows is { } rows
                    ? string.Format(CultureInfo.InvariantCulture, "{0}x{1}", cols, rows)
                    : string.Format(CultureInfo.InvariantCulture, "{0}x{1}", terminal.ConfiguredColumns, terminal.ConfiguredRows);
 
                var peerCount = replica.AttachedPeerCount?.ToString(CultureInfo.InvariantCulture) ?? "-";
 
                table.AddRow(
                    Markup.Escape(terminal.DisplayName),
                    replica.ReplicaIndex.ToString(CultureInfo.InvariantCulture),
                    status,
                    size,
                    peerCount,
                    replica.RestartCount.ToString(CultureInfo.InvariantCulture));
            }
        }
 
        _interactionService.DisplayRenderable(table);
 
        if (verbose)
        {
            DisplayPeerDetails(response);
        }
    }
 
    private void DisplayPeerDetails(Aspire.Cli.Backchannel.ListTerminalsResponse response)
    {
        var hasAnyPeer = response.Terminals
            .Any(t => t.Replicas is not null && t.Replicas.Any(r => r.Peers is { Length: > 0 }));
        if (!hasAnyPeer)
        {
            return;
        }
 
        var peers = new Table();
        peers.AddBoldColumn("Resource");
        peers.AddBoldColumn("Replica");
        peers.AddBoldColumn("Peer Id");
        peers.AddBoldColumn("Display Name");
 
        foreach (var terminal in response.Terminals)
        {
            if (terminal.Replicas is null)
            {
                continue;
            }
            foreach (var replica in terminal.Replicas)
            {
                if (replica.Peers is null)
                {
                    continue;
                }
                foreach (var peer in replica.Peers)
                {
                    peers.AddRow(
                        Markup.Escape(terminal.DisplayName),
                        replica.ReplicaIndex.ToString(CultureInfo.InvariantCulture),
                        Markup.Escape(peer.PeerId),
                        Markup.Escape(peer.DisplayName ?? "-"));
                }
            }
        }
 
        _interactionService.DisplayRenderable(peers);
    }
}
 
internal sealed class TerminalPsJsonEntry
{
    public required string ResourceName { get; init; }
    public required string DisplayName { get; init; }
    public required int ConfiguredColumns { get; init; }
    public required int ConfiguredRows { get; init; }
    public required bool IsHostReachable { get; init; }
    public required TerminalPsJsonReplica[] Replicas { get; init; }
}
 
internal sealed class TerminalPsJsonReplica
{
    public required int ReplicaIndex { get; init; }
    public required bool IsAlive { get; init; }
    public int? ExitCode { get; init; }
    public required bool ProducerConnected { get; init; }
    public required int RestartCount { get; init; }
    public int? CurrentColumns { get; init; }
    public int? CurrentRows { get; init; }
    public int? AttachedPeerCount { get; init; }
 
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public TerminalPsJsonPeer[]? Peers { get; init; }
}
 
internal sealed class TerminalPsJsonPeer
{
    public required string PeerId { get; init; }
    public string? DisplayName { get; init; }
}
 
[JsonSerializable(typeof(List<TerminalPsJsonEntry>))]
[JsonSerializable(typeof(TerminalPsJsonEntry))]
[JsonSerializable(typeof(TerminalPsJsonReplica))]
[JsonSerializable(typeof(TerminalPsJsonPeer))]
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class TerminalPsJsonContext : JsonSerializerContext
{
}