File: DevTunnelCli.cs
Web Access
Project: src\src\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj (Aspire.Hosting.DevTunnels)
// 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.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Hosting.DevTunnels;
 
internal sealed class DevTunnelCli
{
    public const int ResourceConflictsWithExistingExitCode = 1;
    public const int ResourceNotFoundExitCode = 2;
 
    private readonly string _cliPath;
 
    public static string GetCliPath(IConfiguration configuration) => configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] ?? "devtunnel";
 
    public DevTunnelCli(string filePath)
    {
        if (string.IsNullOrWhiteSpace(filePath))
        {
            throw new ArgumentException("CLI path must be provided", nameof(filePath));
        }
 
        _cliPath = filePath;
    }
 
    public Task<int> UserLoginMicrosoftAsync(CancellationToken cancellationToken = default)
        => RunAsync(["user", "login", "--entra", "--json", "--nologo"], null, null, useShellExecute: true, cancellationToken);
 
    public Task<int> UserLoginGitHubAsync(CancellationToken cancellationToken = default)
        => RunAsync(["user", "login", "--github", "--json", "--nologo"], null, null, useShellExecute: true, cancellationToken);
 
    public Task<int> UserLogoutAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, CancellationToken cancellationToken = default)
        => RunAsync(new ArgsBuilder(["user", "logout", "--json", "--nologo"])
        , outputWriter, errorWriter, cancellationToken);
 
    public Task<int> UserStatusAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, CancellationToken cancellationToken = default)
        => RunAsync(["user", "show", "--json", "--nologo"], outputWriter, errorWriter, cancellationToken);
 
    public Task<int> CreateTunnelAsync(
        string? tunnelId = null,
        DevTunnelOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelOptions();
        return RunAsync(new ArgsBuilder(["create"])
            .AddIfNotNull(tunnelId)
            .AddIfNotNull("--description", options.Description)
            .AddIfTrue("--allow-anonymous", options.AllowAnonymous)
            .AddValues("--labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> UpdateTunnelAsync(
        string tunnelId,
        DevTunnelOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelOptions();
        return RunAsync(new ArgsBuilder(["update", tunnelId])
            .AddIfNotNull("--description", options.Description)
            .AddValues("--add-labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> ListAccessAsync(
        string tunnelId,
        int? portNumber = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        return RunAsync(new ArgsBuilder(["access", "list", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> ResetAccessAsync(
        string tunnelId,
        int? portNumber = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        return RunAsync(new ArgsBuilder(["access", "reset", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> CreateAccessAsync(
        string tunnelId,
        int? portNumber = null,
        bool anonymous = false,
        bool deny = false,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        if (!anonymous && !deny)
        {
            throw new ArgumentException($"Must specify either {nameof(anonymous)} or {nameof(deny)} as true, or both, but not neither.");
        }
 
        return RunAsync(new ArgsBuilder(["access", "create", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .AddIfTrue("--deny", deny)
            .AddIfTrue("--anonymous", anonymous)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> DeleteTunnelAsync(string tunnelId, TextWriter? outputWriter = null, TextWriter? errorWriter = null, CancellationToken cancellationToken = default)
        => RunAsync(["delete", tunnelId, "--json", "--nologo"], outputWriter, errorWriter, cancellationToken);
 
    public Task<int> ShowTunnelAsync(string tunnelId, TextWriter? outputWriter = null, TextWriter? errorWriter = null, CancellationToken cancellationToken = default)
        => RunAsync(["show", tunnelId, "--json", "--nologo"], outputWriter, errorWriter, cancellationToken);
 
    public Task<int> CreatePortAsync(
        string tunnelId,
        int portNumber,
        DevTunnelPortOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelPortOptions();
        return RunAsync(new ArgsBuilder(["port", "create", tunnelId])
            .Add("--port-number", portNumber.ToString(CultureInfo.InvariantCulture))
            .AddIfNotNull("--protocol", options.Protocol)
            .AddValues("--labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    public Task<int> UpdatePortAsync(
        string tunnelId,
        int portNumber,
        DevTunnelPortOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelPortOptions();
        return RunAsync(new ArgsBuilder(["port", "update", tunnelId])
            .Add("--port-number", portNumber.ToString(CultureInfo.InvariantCulture))
            .AddIfNotNull("--description", options.Description)
            .AddValues("--add-labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, cancellationToken);
    }
 
    private Task<int> RunAsync(string[] args, TextWriter? outputWriter = null, TextWriter? errorWriter = null, CancellationToken cancellationToken = default)
        => RunAsync(args, outputWriter, errorWriter, useShellExecute: false, cancellationToken);
 
    private Task<int> RunAsync(string[] args, TextWriter? outputWriter = null, TextWriter? errorWriter = null, bool useShellExecute = false, CancellationToken cancellationToken = default)
    {
        return RunAsync((isError, line) =>
        {
            if (isError)
            {
                errorWriter?.WriteLine(line);
            }
            else
            {
                outputWriter?.WriteLine(line);
            }
        }, args, useShellExecute, cancellationToken);
    }
 
    private async Task<int> RunAsync(Action<bool, string> onOutput, string[] args, bool useShellExecute = false, CancellationToken cancellationToken = default)
    {
        using var process = new Process
        {
            StartInfo = BuildStartInfo(args, useShellExecute),
            EnableRaisingEvents = true
        };
 
        var stdoutTask = Task.CompletedTask;
        var stderrTask = Task.CompletedTask;
 
        try
        {
            if (!process.Start())
            {
                throw new InvalidOperationException("Failed to start devtunnel process.");
            }
 
            if (!useShellExecute)
            {
                stdoutTask = PumpAsync(process.StandardOutput, line => onOutput(false, line), cancellationToken);
                stderrTask = PumpAsync(process.StandardError, line => onOutput(true, line), cancellationToken);
            }
 
            using var ctr = cancellationToken.Register(() =>
            {
                try
                {
                    if (!process.HasExited)
                    {
                        process.Kill(entireProcessTree: true);
                    }
                }
                catch
                {
                    // ignored
                }
            });
 
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
            if (!useShellExecute)
            {
                await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
            }
 
            return process.ExitCode;
        }
        finally
        {
            if (!useShellExecute)
            {
                try
                {
                    await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
                }
                catch
                {
                    // ignored
                }
            }
        }
    }
 
    private ProcessStartInfo BuildStartInfo(IEnumerable<string> args, bool useShellExecute = false)
    {
        var psi = new ProcessStartInfo
        {
            FileName = _cliPath,
            UseShellExecute = useShellExecute,
            RedirectStandardOutput = !useShellExecute,
            RedirectStandardError = !useShellExecute,
            RedirectStandardInput = false,
            CreateNoWindow = true,
            WindowStyle = ProcessWindowStyle.Hidden
        };
 
        // Prefer ArgumentList to avoid quoting issues.
        foreach (var a in args)
        {
            if (string.IsNullOrWhiteSpace(a))
            {
                continue;
            }
 
            psi.ArgumentList.Add(a);
        }
 
        // Ensure consistent encoding on Windows terminals
        if (!useShellExecute && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            psi.StandardOutputEncoding = Encoding.UTF8;
            psi.StandardErrorEncoding = Encoding.UTF8;
        }
 
        return psi;
    }
 
    private static async Task PumpAsync(StreamReader reader, Action<string> onLine, CancellationToken cancellationToken)
    {
        while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
            if (line is null)
            {
                break;
            }
            if (line.Length > 0)
            {
                onLine(line);
            }
        }
    }
}
 
internal sealed class ArgsBuilder(IEnumerable<string> initialArgs)
{
    private readonly List<string> _args = [.. initialArgs];
 
    public ArgsBuilder Add(string value)
    {
        _args.Add(value);
        return this;
    }
 
    public ArgsBuilder Add(string name, string value)
    {
        _args.Add(name);
        _args.Add(value);
        return this;
    }
 
    public ArgsBuilder AddIfNotNull(string? value)
    {
        if (!string.IsNullOrEmpty(value))
        {
            _args.Add(value);
        }
        return this;
    }
 
    public ArgsBuilder AddIfNotNull(string name, string? value)
    {
        if (!string.IsNullOrEmpty(value))
        {
            _args.Add(name);
            _args.Add(value);
        }
        return this;
    }
 
    public ArgsBuilder AddIfTrue(string name, bool? condition)
    {
        if (condition == true)
        {
            _args.Add(name);
        }
        return this;
    }
 
    public ArgsBuilder AddIfFalse(string name, bool? condition)
    {
        if (condition == false)
        {
            _args.Add(name);
        }
        return this;
    }
 
    internal ArgsBuilder AddValues(string name, List<string>? values, char? separator = null)
    {
        if (values is null || values.Count == 0)
        {
            return this;
        }
 
        if (separator is null)
        {
            // Add each value separately, e.g. --label a --label b
            foreach (var v in values)
            {
                if (!string.IsNullOrWhiteSpace(v))
                {
                    _args.Add(name);
                    _args.Add(v);
                }
            }
            return this;
        }
        else
        {
            // Build a single separated string of values, e.g. --labels a,b
            // Assumes values themselves do not contain the separator character.
            var tokens = values.Where(l => !string.IsNullOrWhiteSpace(l));
            var joined = string.Join(separator.Value, tokens);
            if (!string.IsNullOrWhiteSpace(joined))
            {
                _args.Add(name);
                _args.Add(joined);
            }
        }
 
        return this;
    }
 
    public static implicit operator string[](ArgsBuilder builder) => [.. builder._args];
}