File: TerminalHostArgs.cs
Web Access
Project: src\src\Aspire.TerminalHost\Aspire.TerminalHost.csproj (Aspire.TerminalHost)
// 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.Text;
 
namespace Aspire.TerminalHost;
 
/// <summary>
/// Parsed command-line arguments for the Aspire terminal host.
/// </summary>
/// <remarks>
/// <para>
/// Each <c>aspire.terminalhost</c> process serves exactly <strong>one</strong> replica's
/// terminal session. The "which replica is this?" question is intentionally opaque to the
/// host: the AppHost encodes the replica identity in the UDS paths it passes in (typically
/// as a per-replica directory like <c>{base}/{i}/dcp.sock</c>) and the host just listens
/// on whatever paths it's told. If a target resource has <c>N</c> replicas, the AppHost
/// spawns <c>N</c> independent terminal host processes, each with its own
/// producer/consumer/control UDS triple.
/// </para>
/// <para>
/// Connection direction note: on the producer side the terminal host <strong>listens</strong>
/// and DCP <strong>dials</strong>. On the consumer side the terminal host <strong>listens</strong>
/// and viewers (Dashboard, CLI) <strong>dial</strong>. Same shape on both ends.
/// </para>
/// </remarks>
internal sealed class TerminalHostArgs
{
    public required string ProducerUdsPath { get; init; }
    public required string ConsumerUdsPath { get; init; }
    public required string ControlUdsPath { get; init; }
    public int Columns { get; init; } = 120;
    public int Rows { get; init; } = 30;
 
    /// <summary>
    /// Parses command-line arguments. The argument shape is:
    /// <list type="bullet">
    ///   <item><c>--producer-uds PATH</c> (required) — path the host LISTENS on; DCP dials.</item>
    ///   <item><c>--consumer-uds PATH</c> (required) — path the host LISTENS on; viewers dial.</item>
    ///   <item><c>--control-uds PATH</c> (required) — path the host LISTENS on; AppHost dials for status/shutdown RPC.</item>
    ///   <item><c>--columns N</c> (optional, default 120)</item>
    ///   <item><c>--rows N</c> (optional, default 30)</item>
    ///   <item><c>--shell NAME</c> (optional, accepted for compatibility and ignored)</item>
    /// </list>
    /// Every option is single-valued and may only be specified once; duplicates throw
    /// <see cref="TerminalHostArgsException"/>. We use <c>System.CommandLine</c> so the
    /// host inherits the same parsing model as the rest of the Aspire CLI (consistent
    /// error messages, --help output, etc.).
    /// </summary>
    public static TerminalHostArgs Parse(string[] args)
    {
        ArgumentNullException.ThrowIfNull(args);
 
        var producerOption = SingleValueOption<string>("--producer-uds", required: true,
            "Path the terminal host LISTENS on for the DCP-driven PTY producer stream.");
        var consumerOption = SingleValueOption<string>("--consumer-uds", required: true,
            "Path the terminal host LISTENS on for viewer (Dashboard / CLI) consumer connections.");
        var controlOption = SingleValueOption<string>("--control-uds", required: true,
            "Path the terminal host LISTENS on for the AppHost control RPC channel.");
 
        var columnsOption = SingleValueOption<int>("--columns", required: false,
            "Initial PTY width in columns (default 120).", defaultValue: 120);
        var rowsOption = SingleValueOption<int>("--rows", required: false,
            "Initial PTY height in rows (default 30).", defaultValue: 30);
        // Older Aspire.Hosting packages can run with a newer CLI-provided terminal host
        // and still emit --shell. Accept the argument so that mixed-version AppHosts start,
        // but ignore it because DCP launches the resource process that owns the PTY.
        var shellOption = SingleValueOption<string?>("--shell", required: false,
            "Legacy shell hint accepted for compatibility and ignored.");
 
        // Cols / rows must be positive. System.CommandLine has no built-in range validator
        // for ints, so attach one explicitly. Skip when the value couldn't be converted at
        // all (e.g. --columns abc) - that error is already surfaced through
        // ParseResult.Errors and calling GetValueOrDefault would rethrow it.
        columnsOption.Validators.Add(r =>
        {
            if (r.Tokens.Count > 0
                && int.TryParse(r.Tokens[0].Value, System.Globalization.NumberStyles.Integer,
                    System.Globalization.CultureInfo.InvariantCulture, out var value)
                && value < 1)
            {
                r.AddError("--columns must be >= 1.");
            }
        });
        rowsOption.Validators.Add(r =>
        {
            if (r.Tokens.Count > 0
                && int.TryParse(r.Tokens[0].Value, System.Globalization.NumberStyles.Integer,
                    System.Globalization.CultureInfo.InvariantCulture, out var value)
                && value < 1)
            {
                r.AddError("--rows must be >= 1.");
            }
        });
 
        // Treat this as a leaf command - no subcommands. The terminal host doesn't ship
        // help text to end users (it is spawned by DCP, not invoked directly), but using
        // RootCommand keeps the parsing model consistent with the rest of the CLI.
        var command = new RootCommand("Aspire terminal host (per-replica HMP v1 broker).")
        {
            producerOption,
            consumerOption,
            controlOption,
            columnsOption,
            rowsOption,
            shellOption,
        };
        // The terminal host argv comes from DCP only; treat unknown flags as a hard error
        // so we don't silently accept garbage and start with the wrong configuration.
        command.TreatUnmatchedTokensAsErrors = true;
 
        var parseResult = command.Parse(args);
        if (parseResult.Errors.Count > 0)
        {
            var message = new StringBuilder();
            foreach (var error in parseResult.Errors)
            {
                if (message.Length > 0)
                {
                    message.Append("; ");
                }
                message.Append(error.Message);
            }
            throw new TerminalHostArgsException(message.ToString());
        }
 
        return new TerminalHostArgs
        {
            ProducerUdsPath = parseResult.GetValue(producerOption)!,
            ConsumerUdsPath = parseResult.GetValue(consumerOption)!,
            ControlUdsPath = parseResult.GetValue(controlOption)!,
            Columns = parseResult.GetValue(columnsOption),
            Rows = parseResult.GetValue(rowsOption),
        };
    }
 
    private static Option<T> SingleValueOption<T>(
        string name,
        bool required,
        string description,
        T? defaultValue = default)
    {
        var option = new Option<T>(name)
        {
            Description = description,
            Required = required,
            Arity = ArgumentArity.ExactlyOne,
            AllowMultipleArgumentsPerToken = false,
        };
 
        if (!required && defaultValue is not null)
        {
            option.DefaultValueFactory = _ => defaultValue;
        }
 
        // System.CommandLine accepts repeated occurrences of single-valued options and
        // silently keeps the last value (last-write-wins). For this host every flag
        // identifies a per-replica resource (UDS path, dimensions), so a duplicate
        // is unambiguously a misuse by the caller - reject it uniformly across all flags.
        option.Validators.Add(r =>
        {
            if (r.IdentifierTokenCount > 1)
            {
                r.AddError($"{name} may only be specified once.");
            }
        });
 
        return option;
    }
}
 
/// <summary>
/// Thrown when the terminal host receives malformed command-line arguments.
/// </summary>
internal sealed class TerminalHostArgsException(string message) : Exception(message);