Skip to content
18 changes: 18 additions & 0 deletions src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal interface IAppHostCliBackchannel
Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken);
Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken);
IAsyncEnumerable<CommandOutput> ExecAsync(CancellationToken cancellationToken);
Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step, CancellationToken cancellationToken);
}

internal sealed class AppHostCliBackchannel(ILogger<AppHostCliBackchannel> logger, AspireCliTelemetry telemetry) : IAppHostCliBackchannel
Expand Down Expand Up @@ -480,5 +481,22 @@ public async IAsyncEnumerable<CommandOutput> ExecAsync([EnumeratorCancellation]
}
}

public async Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false);

logger.LogDebug("Requesting pipeline steps.");

var response = await rpc.InvokeWithCancellationAsync<GetPipelineStepsResponse>(
"GetPipelineStepsAsync",
[new GetPipelineStepsRequest { Step = step }],
cancellationToken).ConfigureAwait(false);

logger.LogDebug("Received {StepCount} pipeline steps.", response.Steps.Length);

return response;
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ namespace Aspire.Cli.Backchannel;
[JsonSerializable(typeof(ExecuteResourceCommandResponse))]
[JsonSerializable(typeof(WaitForResourceRequest))]
[JsonSerializable(typeof(WaitForResourceResponse))]
[JsonSerializable(typeof(PipelineStepInfo))]
[JsonSerializable(typeof(PipelineStepInfo[]))]
[JsonSerializable(typeof(GetPipelineStepsRequest))]
[JsonSerializable(typeof(GetPipelineStepsResponse))]
internal partial class BackchannelJsonSerializerContext : JsonSerializerContext
{
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")]
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Commands/DeployCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ protected override Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOut

protected override string GetCanceledMessage() => DeployCommandStrings.DeploymentCanceled;

protected override string? GetTargetStepName(ParseResult parseResult) => "deploy";

protected override string GetProgressMessage(ParseResult parseResult)
{
return "Executing step deploy";
Expand Down
20 changes: 18 additions & 2 deletions src/Aspire.Cli/Commands/DoCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,22 @@ internal sealed class DoCommand : PipelineCommandBase
public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfiguration configuration, ILogger<DoCommand> logger, IAnsiConsole ansiConsole)
: base("do", DoCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole)
{
var isExtensionHost = ExtensionHelper.IsExtensionHost(interactionService, out _, out _);
_stepArgument = new Argument<string>("step")
{
Description = DoCommandStrings.StepArgumentDescription,
Arity = isExtensionHost ? ArgumentArity.ZeroOrOne : ArgumentArity.ExactlyOne
Arity = ArgumentArity.ZeroOrOne
};
Arguments.Add(_stepArgument);

Validators.Add(result =>
{
var step = result.GetValue(_stepArgument);
var listSteps = result.GetValue(s_listStepsOption);
if (string.IsNullOrEmpty(step) && !listSteps && !ExtensionHelper.IsExtensionHost(interactionService, out _, out _))
{
result.AddError("The 'step' argument is required when not using --list-steps.");
}
});
}

protected override string OperationCompletedPrefix => DoCommandStrings.OperationCompletedPrefix;
Expand Down Expand Up @@ -92,8 +101,15 @@ protected override async Task<string[]> GetRunArgumentsAsync(string? fullyQualif

protected override string GetCanceledMessage() => DoCommandStrings.OperationCanceled;

protected override string? GetTargetStepName(ParseResult parseResult) => parseResult.GetValue(_stepArgument);

protected override string GetProgressMessage(ParseResult parseResult)
{
if (parseResult.GetValue(s_listStepsOption))
{
return "Listing pipeline steps";
}

var step = parseResult.GetValue(_stepArgument);
return $"Executing step {step}";
}
Expand Down
134 changes: 134 additions & 0 deletions src/Aspire.Cli/Commands/PipelineCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.CommandLine;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
Expand Down Expand Up @@ -60,6 +61,11 @@ internal abstract class PipelineCommandBase : BaseCommand
Description = PublishCommandStrings.NoBuildArgumentDescription
};

protected static readonly Option<bool> s_listStepsOption = new("--list-steps")
{
Description = "List the pipeline steps that would be executed, without running them."
};

protected abstract string OperationCompletedPrefix { get; }
protected abstract string OperationFailedPrefix { get; }

Expand Down Expand Up @@ -95,6 +101,7 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner
Options.Add(s_environmentOption);
Options.Add(s_includeExceptionDetailsOption);
Options.Add(s_noBuildOption);
Options.Add(s_listStepsOption);

if (ExtensionHelper.IsExtensionHost(interactionService, out _, out _))
{
Expand All @@ -115,6 +122,12 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner
protected abstract string GetCanceledMessage();
protected abstract string GetProgressMessage(ParseResult parseResult);

/// <summary>
/// Gets the target pipeline step name for this command, used for --list-steps filtering.
/// Returns null to show all steps.
/// </summary>
protected virtual string? GetTargetStepName(ParseResult parseResult) => null;

/// <summary>
/// Gets command-specific arguments to forward when starting a debug session from the extension context.
/// Subclasses should override to include their specific positional arguments.
Expand Down Expand Up @@ -250,6 +263,30 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
throw new InvalidOperationException("Run completed without returning a backchannel.", innerException);
}, emoji: KnownEmojis.HammerAndWrench);

// If --list-steps was specified, get pipeline steps and print them instead of executing
var listSteps = parseResult.GetValue(s_listStepsOption);
if (listSteps)
{
StopTerminalProgressBar();

// Check that the AppHost supports this capability before calling
var capabilities = await backchannel.GetCapabilitiesAsync(cancellationToken);
if (!capabilities.Contains("pipeline-steps.v1"))
{
throw new AppHostIncompatibleException(
"The AppHost does not support --list-steps. Update the AppHost to a newer version of Aspire.",
"pipeline-steps.v1");
}

var targetStep = GetTargetStepName(parseResult);
var response = await backchannel.GetPipelineStepsAsync(targetStep, cancellationToken);
PrintPipelineSteps(response.Steps);

await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
await pendingRun;
return ExitCodeConstants.Success;
Comment on lines +266 to +287
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --list-steps path calls GetPipelineStepsAsync unconditionally. If the user runs a newer CLI against an older AppHost that doesn't advertise/support pipeline-steps.v1, this will likely fail with RemoteMethodNotFoundException and be reported as an "unexpected error". Consider checking backchannel.GetCapabilitiesAsync(...) for pipeline-steps.v1 (or catching RemoteMethodNotFoundException here) and converting it into an AppHostIncompatibleException with a clear message.

Copilot uses AI. Check for mistakes.
}

var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);

// Check if debug or trace logging is enabled
Expand Down Expand Up @@ -361,6 +398,103 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
}
}

/// <summary>
/// Prints pipeline steps in a numbered tree format showing dependencies and tags.
/// </summary>
internal void PrintPipelineSteps(PipelineStepInfo[] steps)
{
if (steps.Length == 0)
{
_ansiConsole.MarkupLine("[dim]No pipeline steps found.[/]");
return;
}

for (var i = 0; i < steps.Length; i++)
{
var step = steps[i];

_ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [cyan]{step.Name.EscapeMarkup()}[/]");

var hasDeps = step.DependsOn.Length > 0;
var hasTags = step.Tags.Length > 0;

if (!hasDeps && !hasTags)
{
_ansiConsole.MarkupLine("[dim] └─ No dependencies[/]");
}
else
{
if (hasDeps)
{
var connector = hasTags ? "├" : "└";
var continuation = hasTags ? "│" : " ";
var firstLinePrefix = $" {connector}─ [blue]Depends on:[/] ";
// Build continuation prefix that aligns wrapped items under the first dep value.
// Replace the connector with the continuation char and pad the rest with spaces.
var visibleWidth = StripMarkup(firstLinePrefix).Length;
var continuationPrefix = " " + continuation + new string(' ', visibleWidth - 4);
var wrappedDeps = FormatWithHangingIndent(step.DependsOn, firstLinePrefix, continuationPrefix);
_ansiConsole.MarkupLine(wrappedDeps);
}

if (hasTags)
{
var tagsText = string.Join(", ", step.Tags);
_ansiConsole.MarkupLine($" └─ [yellow]Tags:[/] {tagsText.EscapeMarkup()}");
}
}

if (i < steps.Length - 1)
{
_ansiConsole.WriteLine();
}
}
}

/// <summary>
/// Formats a list of items with a prefix on the first line and hanging indent on continuation lines.
/// Items are comma-separated and wrapped so each line stays readable.
/// </summary>
private static string FormatWithHangingIndent(string[] items, string firstLinePrefix, string continuationPrefix, int maxLineLength = 100)
{
if (items.Length == 0)
{
return firstLinePrefix;
}

var sb = new StringBuilder();
sb.Append(firstLinePrefix);

// Track visible length (without markup tags) for line wrapping
var visiblePrefixLength = StripMarkup(firstLinePrefix).Length;
var currentLineLength = visiblePrefixLength;

for (var i = 0; i < items.Length; i++)
{
var item = items[i].EscapeMarkup();
var separator = i < items.Length - 1 ? ", " : "";
var chunk = item + separator;

if (i > 0 && currentLineLength + chunk.Length > maxLineLength)
{
sb.AppendLine();
sb.Append(continuationPrefix);
currentLineLength = StripMarkup(continuationPrefix).Length;
}

sb.Append(chunk);
currentLineLength += chunk.Length;
}

return sb.ToString();
}

private static string StripMarkup(string text)
{
// Remove Spectre markup tags like [bold], [/], [blue], etc.
return System.Text.RegularExpressions.Regex.Replace(text, @"\[/?[^\]]*\]", "");
}

/// <summary>
/// Conditionally converts markdown to Spectre markup based on the EnableMarkdown flag in the activity data.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,7 @@ protected override Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOut

protected override string GetCanceledMessage() => InteractionServiceStrings.OperationCancelled;

protected override string? GetTargetStepName(ParseResult parseResult) => "publish";

protected override string GetProgressMessage(ParseResult parseResult) => "Executing step publish";
}
50 changes: 49 additions & 1 deletion src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ public Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken)

_ = cancellationToken;
return Task.FromResult(new string[] {
"baseline.v2"
"baseline.v2",
"pipeline-steps.v1"
});
}
#pragma warning restore CA1822
Expand All @@ -205,4 +206,51 @@ public async Task UpdatePromptResponseAsync(string promptId, PublishingPromptInp
{
await activityReporter.CompleteInteractionAsync(promptId, answers, updateResponse: true, cancellationToken).ConfigureAwait(false);
}

public async Task<GetPipelineStepsResponse> GetPipelineStepsAsync(GetPipelineStepsRequest? request = null, CancellationToken cancellationToken = default)
{
logger.LogDebug("Resolving pipeline steps for list-steps request.");

#pragma warning disable ASPIREPIPELINES001
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>() as DistributedApplicationPipeline
?? throw new InvalidOperationException("Pipeline is not a DistributedApplicationPipeline.");

var model = serviceProvider.GetRequiredService<DistributedApplicationModel>();
var executionContext = serviceProvider.GetRequiredService<DistributedApplicationExecutionContext>();

var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken);

var resolvedSteps = await pipeline.ResolveStepsAsync(pipelineContext).ConfigureAwait(false);

// If a target step is specified, filter to its transitive dependencies
if (!string.IsNullOrEmpty(request?.Step))
{
var stepsByName = resolvedSteps.ToDictionary(s => s.Name, StringComparer.Ordinal);
if (stepsByName.TryGetValue(request.Step, out var targetStep))
{
resolvedSteps = DistributedApplicationPipeline.ComputeTransitiveDependencies(targetStep, stepsByName);
}
else
{
var availableSteps = string.Join(", ", resolvedSteps.Select(s => $"'{s.Name}'"));
throw new InvalidOperationException(
$"Step '{request.Step}' not found in pipeline. Available steps: {availableSteps}");
}
}

var orderedSteps = DistributedApplicationPipeline.GetTopologicalOrder(resolvedSteps);
#pragma warning restore ASPIREPIPELINES001

return new GetPipelineStepsResponse
{
Steps = orderedSteps.Select(step => new PipelineStepInfo
{
Name = step.Name,
Description = step.Description,
DependsOn = [.. step.DependsOnSteps],
Tags = [.. step.Tags],
ResourceName = step.Resource?.Name
}).ToArray()
};
}
}
54 changes: 54 additions & 0 deletions src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,60 @@ internal class PublishingPromptInputAnswer
public string? Value { get; set; }
}

/// <summary>
/// Represents metadata about a pipeline step for display purposes (e.g., --list-steps).
/// </summary>
internal sealed class PipelineStepInfo
{
/// <summary>
/// Gets the unique name of the step.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// Gets the description of the step.
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Gets the names of steps that this step depends on.
/// </summary>
public string[] DependsOn { get; init; } = [];

/// <summary>
/// Gets the tags that categorize this step.
/// </summary>
public string[] Tags { get; init; } = [];

/// <summary>
/// Gets the name of the resource this step is associated with, if any.
/// </summary>
public string? ResourceName { get; init; }
}

/// <summary>
/// Request for getting pipeline step metadata.
/// </summary>
internal sealed class GetPipelineStepsRequest
{
/// <summary>
/// Gets or sets the target step name to filter to (including transitive dependencies).
/// When null, all steps are returned.
/// </summary>
public string? Step { get; init; }
}

/// <summary>
/// Response containing pipeline step metadata.
/// </summary>
internal sealed class GetPipelineStepsResponse
{
/// <summary>
/// Gets the pipeline steps in topological (execution) order.
/// </summary>
public required PipelineStepInfo[] Steps { get; init; }
}

/// <summary>
/// Represents the connection information for the Dashboard MCP server.
/// </summary>
Expand Down
Loading
Loading