Skip to content

Commit 6920012

Browse files
mitchdennyCopilot
andauthored
Add --list-steps flag to aspire do command (#16085)
* Add --list-steps flag to aspire do command Implement support for 'aspire do --list-steps' that prints pipeline steps in execution order with their dependencies and tags, without executing them. Changes: - Add PipelineStepInfo DTO to BackchannelDataTypes.cs (source-shared) - Add ResolveStepsAsync to DistributedApplicationPipeline for resolving steps without executing them - Add GetPipelineStepsAsync RPC method to AppHostRpcTarget with 'pipeline-steps.v1' capability - Add GetPipelineStepsAsync to IAppHostCliBackchannel interface and implementations - Add --list-steps option to PipelineCommandBase with formatted output - Make DoCommand step argument optional when --list-steps is used Testing: - Unit tests for DoCommand with --list-steps (returns 0, calls GetPipelineStepsAsync, does not execute pipeline, calls RequestStopAsync) - Unit tests for PrintPipelineSteps formatting (dependencies, tags, sequential numbering, empty steps, full pipeline output) - Unit tests for ResolveStepsAsync and GetTopologicalOrder - E2E CLI test using Hex1b terminal automation Fixes #12376 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve --list-steps output formatting Add color to the output: green step numbers, blue 'Depends on:' label, yellow 'Tags:' label, dim 'No dependencies' text. Add hanging indent for long dependency lists so wrapped items align under the first item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix PrintPipelineSteps tests to strip ANSI escape codes The Spectre.Console AnsiConsole emits ANSI escape codes even with AnsiSupport.No in CI environments. Strip ANSI codes before asserting on output content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix hanging indent alignment and use cyan for step names Fix the continuation line indent to align wrapped dependency items directly under the first item (19 chars), not double-indented. Change step name color from bold white to cyan for better visibility. Before: ├─ Depends on: provision-redis-infra, provision-postgres-infra, build-webapi After: ├─ Depends on: provision-redis-infra, provision-postgres-infra, build-webapi Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix wrapped dependency alignment to be computed dynamically Compute the continuation prefix width from the visible length of the first line prefix rather than hardcoding spaces. This ensures wrapped dependency items align correctly under the first item regardless of unicode box-drawing character widths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: backchannel spec compliance and versioning - Use request/response objects (GetPipelineStepsRequest/Response) per the backchannel spec contract rules instead of raw array return - Add capability check for 'pipeline-steps.v1' before calling GetPipelineStepsAsync, throwing AppHostIncompatibleException with a clear message if the AppHost doesn't support it - Scope #pragma warning disable ASPIREPIPELINES001 to just the method that needs it instead of file-wide - Fix ResolveStepsAsync XML doc to accurately state it returns collection order (not topological order) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scope --list-steps output to target step and its dependencies When a target step is specified (e.g. 'aspire do build --list-steps'), only show that step and its transitive dependencies instead of the full pipeline graph. This makes the output much more useful for understanding what a specific target will actually do. - aspire do --list-steps -> all steps (no filter) - aspire do build --list-steps -> build + its deps only - aspire publish --list-steps -> publish + its deps only - aspire deploy --list-steps -> deploy + its deps only Each PipelineCommandBase subclass provides its target step name via GetTargetStepName(). The step name is passed in GetPipelineStepsRequest and the AppHost filters using ComputeTransitiveDependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use System.CommandLine validator for missing step argument Replace InvalidOperationException with a proper command validator so users get a standard parse error with help/usage output instead of an unexpected error when running 'aspire do' without a step argument and without --list-steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7489481 commit 6920012

15 files changed

Lines changed: 900 additions & 11 deletions

src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal interface IAppHostCliBackchannel
2424
Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken);
2525
Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken);
2626
IAsyncEnumerable<CommandOutput> ExecAsync(CancellationToken cancellationToken);
27+
Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step, CancellationToken cancellationToken);
2728
}
2829

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

484+
public async Task<GetPipelineStepsResponse> GetPipelineStepsAsync(string? step, CancellationToken cancellationToken)
485+
{
486+
using var activity = telemetry.StartDiagnosticActivity();
487+
var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
488+
489+
logger.LogDebug("Requesting pipeline steps.");
490+
491+
var response = await rpc.InvokeWithCancellationAsync<GetPipelineStepsResponse>(
492+
"GetPipelineStepsAsync",
493+
[new GetPipelineStepsRequest { Step = step }],
494+
cancellationToken).ConfigureAwait(false);
495+
496+
logger.LogDebug("Received {StepCount} pipeline steps.", response.Steps.Length);
497+
498+
return response;
499+
}
500+
483501
}
484502

src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ namespace Aspire.Cli.Backchannel;
7676
[JsonSerializable(typeof(ExecuteResourceCommandResponse))]
7777
[JsonSerializable(typeof(WaitForResourceRequest))]
7878
[JsonSerializable(typeof(WaitForResourceResponse))]
79+
[JsonSerializable(typeof(PipelineStepInfo))]
80+
[JsonSerializable(typeof(PipelineStepInfo[]))]
81+
[JsonSerializable(typeof(GetPipelineStepsRequest))]
82+
[JsonSerializable(typeof(GetPipelineStepsResponse))]
7983
internal partial class BackchannelJsonSerializerContext : JsonSerializerContext
8084
{
8185
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")]

src/Aspire.Cli/Commands/DeployCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ protected override Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOut
7777

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

80+
protected override string? GetTargetStepName(ParseResult parseResult) => "deploy";
81+
8082
protected override string GetProgressMessage(ParseResult parseResult)
8183
{
8284
return "Executing step deploy";

src/Aspire.Cli/Commands/DoCommand.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,22 @@ internal sealed class DoCommand : PipelineCommandBase
2424
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)
2525
: base("do", DoCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole)
2626
{
27-
var isExtensionHost = ExtensionHelper.IsExtensionHost(interactionService, out _, out _);
2827
_stepArgument = new Argument<string>("step")
2928
{
3029
Description = DoCommandStrings.StepArgumentDescription,
31-
Arity = isExtensionHost ? ArgumentArity.ZeroOrOne : ArgumentArity.ExactlyOne
30+
Arity = ArgumentArity.ZeroOrOne
3231
};
3332
Arguments.Add(_stepArgument);
33+
34+
Validators.Add(result =>
35+
{
36+
var step = result.GetValue(_stepArgument);
37+
var listSteps = result.GetValue(s_listStepsOption);
38+
if (string.IsNullOrEmpty(step) && !listSteps && !ExtensionHelper.IsExtensionHost(interactionService, out _, out _))
39+
{
40+
result.AddError("The 'step' argument is required when not using --list-steps.");
41+
}
42+
});
3443
}
3544

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

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

104+
protected override string? GetTargetStepName(ParseResult parseResult) => parseResult.GetValue(_stepArgument);
105+
95106
protected override string GetProgressMessage(ParseResult parseResult)
96107
{
108+
if (parseResult.GetValue(s_listStepsOption))
109+
{
110+
return "Listing pipeline steps";
111+
}
112+
97113
var step = parseResult.GetValue(_stepArgument);
98114
return $"Executing step {step}";
99115
}

src/Aspire.Cli/Commands/PipelineCommandBase.cs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.CommandLine;
55
using System.Diagnostics;
66
using System.Globalization;
7+
using System.Text;
78
using Aspire.Cli.Backchannel;
89
using Aspire.Cli.Configuration;
910
using Aspire.Cli.DotNet;
@@ -60,6 +61,11 @@ internal abstract class PipelineCommandBase : BaseCommand
6061
Description = PublishCommandStrings.NoBuildArgumentDescription
6162
};
6263

64+
protected static readonly Option<bool> s_listStepsOption = new("--list-steps")
65+
{
66+
Description = "List the pipeline steps that would be executed, without running them."
67+
};
68+
6369
protected abstract string OperationCompletedPrefix { get; }
6470
protected abstract string OperationFailedPrefix { get; }
6571

@@ -95,6 +101,7 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner
95101
Options.Add(s_environmentOption);
96102
Options.Add(s_includeExceptionDetailsOption);
97103
Options.Add(s_noBuildOption);
104+
Options.Add(s_listStepsOption);
98105

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

125+
/// <summary>
126+
/// Gets the target pipeline step name for this command, used for --list-steps filtering.
127+
/// Returns null to show all steps.
128+
/// </summary>
129+
protected virtual string? GetTargetStepName(ParseResult parseResult) => null;
130+
118131
/// <summary>
119132
/// Gets command-specific arguments to forward when starting a debug session from the extension context.
120133
/// Subclasses should override to include their specific positional arguments.
@@ -250,6 +263,30 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
250263
throw new InvalidOperationException("Run completed without returning a backchannel.", innerException);
251264
}, emoji: KnownEmojis.HammerAndWrench);
252265

266+
// If --list-steps was specified, get pipeline steps and print them instead of executing
267+
var listSteps = parseResult.GetValue(s_listStepsOption);
268+
if (listSteps)
269+
{
270+
StopTerminalProgressBar();
271+
272+
// Check that the AppHost supports this capability before calling
273+
var capabilities = await backchannel.GetCapabilitiesAsync(cancellationToken);
274+
if (!capabilities.Contains("pipeline-steps.v1"))
275+
{
276+
throw new AppHostIncompatibleException(
277+
"The AppHost does not support --list-steps. Update the AppHost to a newer version of Aspire.",
278+
"pipeline-steps.v1");
279+
}
280+
281+
var targetStep = GetTargetStepName(parseResult);
282+
var response = await backchannel.GetPipelineStepsAsync(targetStep, cancellationToken);
283+
PrintPipelineSteps(response.Steps);
284+
285+
await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
286+
await pendingRun;
287+
return ExitCodeConstants.Success;
288+
}
289+
253290
var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);
254291

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

401+
/// <summary>
402+
/// Prints pipeline steps in a numbered tree format showing dependencies and tags.
403+
/// </summary>
404+
internal void PrintPipelineSteps(PipelineStepInfo[] steps)
405+
{
406+
if (steps.Length == 0)
407+
{
408+
_ansiConsole.MarkupLine("[dim]No pipeline steps found.[/]");
409+
return;
410+
}
411+
412+
for (var i = 0; i < steps.Length; i++)
413+
{
414+
var step = steps[i];
415+
416+
_ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [cyan]{step.Name.EscapeMarkup()}[/]");
417+
418+
var hasDeps = step.DependsOn.Length > 0;
419+
var hasTags = step.Tags.Length > 0;
420+
421+
if (!hasDeps && !hasTags)
422+
{
423+
_ansiConsole.MarkupLine("[dim] └─ No dependencies[/]");
424+
}
425+
else
426+
{
427+
if (hasDeps)
428+
{
429+
var connector = hasTags ? "├" : "└";
430+
var continuation = hasTags ? "│" : " ";
431+
var firstLinePrefix = $" {connector}─ [blue]Depends on:[/] ";
432+
// Build continuation prefix that aligns wrapped items under the first dep value.
433+
// Replace the connector with the continuation char and pad the rest with spaces.
434+
var visibleWidth = StripMarkup(firstLinePrefix).Length;
435+
var continuationPrefix = " " + continuation + new string(' ', visibleWidth - 4);
436+
var wrappedDeps = FormatWithHangingIndent(step.DependsOn, firstLinePrefix, continuationPrefix);
437+
_ansiConsole.MarkupLine(wrappedDeps);
438+
}
439+
440+
if (hasTags)
441+
{
442+
var tagsText = string.Join(", ", step.Tags);
443+
_ansiConsole.MarkupLine($" └─ [yellow]Tags:[/] {tagsText.EscapeMarkup()}");
444+
}
445+
}
446+
447+
if (i < steps.Length - 1)
448+
{
449+
_ansiConsole.WriteLine();
450+
}
451+
}
452+
}
453+
454+
/// <summary>
455+
/// Formats a list of items with a prefix on the first line and hanging indent on continuation lines.
456+
/// Items are comma-separated and wrapped so each line stays readable.
457+
/// </summary>
458+
private static string FormatWithHangingIndent(string[] items, string firstLinePrefix, string continuationPrefix, int maxLineLength = 100)
459+
{
460+
if (items.Length == 0)
461+
{
462+
return firstLinePrefix;
463+
}
464+
465+
var sb = new StringBuilder();
466+
sb.Append(firstLinePrefix);
467+
468+
// Track visible length (without markup tags) for line wrapping
469+
var visiblePrefixLength = StripMarkup(firstLinePrefix).Length;
470+
var currentLineLength = visiblePrefixLength;
471+
472+
for (var i = 0; i < items.Length; i++)
473+
{
474+
var item = items[i].EscapeMarkup();
475+
var separator = i < items.Length - 1 ? ", " : "";
476+
var chunk = item + separator;
477+
478+
if (i > 0 && currentLineLength + chunk.Length > maxLineLength)
479+
{
480+
sb.AppendLine();
481+
sb.Append(continuationPrefix);
482+
currentLineLength = StripMarkup(continuationPrefix).Length;
483+
}
484+
485+
sb.Append(chunk);
486+
currentLineLength += chunk.Length;
487+
}
488+
489+
return sb.ToString();
490+
}
491+
492+
private static string StripMarkup(string text)
493+
{
494+
// Remove Spectre markup tags like [bold], [/], [blue], etc.
495+
return System.Text.RegularExpressions.Regex.Replace(text, @"\[/?[^\]]*\]", "");
496+
}
497+
364498
/// <summary>
365499
/// Conditionally converts markdown to Spectre markup based on the EnableMarkdown flag in the activity data.
366500
/// </summary>

src/Aspire.Cli/Commands/PublishCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,7 @@ protected override Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOut
8585

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

88+
protected override string? GetTargetStepName(ParseResult parseResult) => "publish";
89+
8890
protected override string GetProgressMessage(ParseResult parseResult) => "Executing step publish";
8991
}

src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ public Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken)
191191

192192
_ = cancellationToken;
193193
return Task.FromResult(new string[] {
194-
"baseline.v2"
194+
"baseline.v2",
195+
"pipeline-steps.v1"
195196
});
196197
}
197198
#pragma warning restore CA1822
@@ -205,4 +206,51 @@ public async Task UpdatePromptResponseAsync(string promptId, PublishingPromptInp
205206
{
206207
await activityReporter.CompleteInteractionAsync(promptId, answers, updateResponse: true, cancellationToken).ConfigureAwait(false);
207208
}
209+
210+
public async Task<GetPipelineStepsResponse> GetPipelineStepsAsync(GetPipelineStepsRequest? request = null, CancellationToken cancellationToken = default)
211+
{
212+
logger.LogDebug("Resolving pipeline steps for list-steps request.");
213+
214+
#pragma warning disable ASPIREPIPELINES001
215+
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>() as DistributedApplicationPipeline
216+
?? throw new InvalidOperationException("Pipeline is not a DistributedApplicationPipeline.");
217+
218+
var model = serviceProvider.GetRequiredService<DistributedApplicationModel>();
219+
var executionContext = serviceProvider.GetRequiredService<DistributedApplicationExecutionContext>();
220+
221+
var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken);
222+
223+
var resolvedSteps = await pipeline.ResolveStepsAsync(pipelineContext).ConfigureAwait(false);
224+
225+
// If a target step is specified, filter to its transitive dependencies
226+
if (!string.IsNullOrEmpty(request?.Step))
227+
{
228+
var stepsByName = resolvedSteps.ToDictionary(s => s.Name, StringComparer.Ordinal);
229+
if (stepsByName.TryGetValue(request.Step, out var targetStep))
230+
{
231+
resolvedSteps = DistributedApplicationPipeline.ComputeTransitiveDependencies(targetStep, stepsByName);
232+
}
233+
else
234+
{
235+
var availableSteps = string.Join(", ", resolvedSteps.Select(s => $"'{s.Name}'"));
236+
throw new InvalidOperationException(
237+
$"Step '{request.Step}' not found in pipeline. Available steps: {availableSteps}");
238+
}
239+
}
240+
241+
var orderedSteps = DistributedApplicationPipeline.GetTopologicalOrder(resolvedSteps);
242+
#pragma warning restore ASPIREPIPELINES001
243+
244+
return new GetPipelineStepsResponse
245+
{
246+
Steps = orderedSteps.Select(step => new PipelineStepInfo
247+
{
248+
Name = step.Name,
249+
Description = step.Description,
250+
DependsOn = [.. step.DependsOnSteps],
251+
Tags = [.. step.Tags],
252+
ResourceName = step.Resource?.Name
253+
}).ToArray()
254+
};
255+
}
208256
}

src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,60 @@ internal class PublishingPromptInputAnswer
688688
public string? Value { get; set; }
689689
}
690690

691+
/// <summary>
692+
/// Represents metadata about a pipeline step for display purposes (e.g., --list-steps).
693+
/// </summary>
694+
internal sealed class PipelineStepInfo
695+
{
696+
/// <summary>
697+
/// Gets the unique name of the step.
698+
/// </summary>
699+
public required string Name { get; init; }
700+
701+
/// <summary>
702+
/// Gets the description of the step.
703+
/// </summary>
704+
public string? Description { get; init; }
705+
706+
/// <summary>
707+
/// Gets the names of steps that this step depends on.
708+
/// </summary>
709+
public string[] DependsOn { get; init; } = [];
710+
711+
/// <summary>
712+
/// Gets the tags that categorize this step.
713+
/// </summary>
714+
public string[] Tags { get; init; } = [];
715+
716+
/// <summary>
717+
/// Gets the name of the resource this step is associated with, if any.
718+
/// </summary>
719+
public string? ResourceName { get; init; }
720+
}
721+
722+
/// <summary>
723+
/// Request for getting pipeline step metadata.
724+
/// </summary>
725+
internal sealed class GetPipelineStepsRequest
726+
{
727+
/// <summary>
728+
/// Gets or sets the target step name to filter to (including transitive dependencies).
729+
/// When null, all steps are returned.
730+
/// </summary>
731+
public string? Step { get; init; }
732+
}
733+
734+
/// <summary>
735+
/// Response containing pipeline step metadata.
736+
/// </summary>
737+
internal sealed class GetPipelineStepsResponse
738+
{
739+
/// <summary>
740+
/// Gets the pipeline steps in topological (execution) order.
741+
/// </summary>
742+
public required PipelineStepInfo[] Steps { get; init; }
743+
}
744+
691745
/// <summary>
692746
/// Represents the connection information for the Dashboard MCP server.
693747
/// </summary>

0 commit comments

Comments
 (0)