From 4c3a89a02e5f5a51419a4c2d90aa332ddc77c2e7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 17:14:05 +1000 Subject: [PATCH 1/8] 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> --- .../Backchannel/AppHostCliBackchannel.cs | 18 ++ .../BackchannelJsonSerializerContext.cs | 2 + src/Aspire.Cli/Commands/DoCommand.cs | 14 +- .../Commands/PipelineCommandBase.cs | 67 ++++++ .../Backchannel/AppHostRpcTarget.cs | 30 ++- .../Backchannel/BackchannelDataTypes.cs | 31 +++ .../DistributedApplicationPipeline.cs | 29 ++- .../ListStepsTests.cs | 61 +++++ .../Commands/DoCommandTests.cs | 227 ++++++++++++++++++ .../Commands/PipelineCommandListStepsTests.cs | 150 ++++++++++++ ...PublishCommandPromptingIntegrationTests.cs | 3 + .../TestServices/TestAppHostCliBackchannel.cs | 18 ++ .../DistributedApplicationPipelineTests.cs | 123 ++++++++++ 13 files changed, 764 insertions(+), 9 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index d4165297f71..f59f912f5a8 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -24,6 +24,7 @@ internal interface IAppHostCliBackchannel Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); + Task GetPipelineStepsAsync(CancellationToken cancellationToken); } internal sealed class AppHostCliBackchannel(ILogger logger, AspireCliTelemetry telemetry) : IAppHostCliBackchannel @@ -480,5 +481,22 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] } } + public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + { + using var activity = telemetry.StartDiagnosticActivity(); + var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Requesting pipeline steps."); + + var steps = await rpc.InvokeWithCancellationAsync( + "GetPipelineStepsAsync", + [], + cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Received {StepCount} pipeline steps.", steps.Length); + + return steps; + } + } diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index a16f972dac1..521502d7efe 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -76,6 +76,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(ExecuteResourceCommandResponse))] [JsonSerializable(typeof(WaitForResourceRequest))] [JsonSerializable(typeof(WaitForResourceResponse))] +[JsonSerializable(typeof(PipelineStepInfo))] +[JsonSerializable(typeof(PipelineStepInfo[]))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index c7b49606e3d..09b0d84f01a 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -24,11 +24,10 @@ 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 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("step") { Description = DoCommandStrings.StepArgumentDescription, - Arity = isExtensionHost ? ArgumentArity.ZeroOrOne : ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne }; Arguments.Add(_stepArgument); } @@ -56,6 +55,12 @@ protected override async Task GetRunArgumentsAsync(string? fullyQualif cancellationToken: cancellationToken); } + // Step is required when not using --list-steps + if (string.IsNullOrEmpty(step) && !parseResult.GetValue(s_listStepsOption)) + { + throw new InvalidOperationException("The 'step' argument is required when not using --list-steps."); + } + if (!string.IsNullOrEmpty(step)) { baseArgs.AddRange(["--step", step]); @@ -94,6 +99,11 @@ protected override async Task GetRunArgumentsAsync(string? fullyQualif protected override string GetProgressMessage(ParseResult parseResult) { + if (parseResult.GetValue(s_listStepsOption)) + { + return "Listing pipeline steps"; + } + var step = parseResult.GetValue(_stepArgument); return $"Executing step {step}"; } diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index c36dcc951a7..b2b0b7c52f9 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -60,6 +60,11 @@ internal abstract class PipelineCommandBase : BaseCommand Description = PublishCommandStrings.NoBuildArgumentDescription }; + protected static readonly Option 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; } @@ -95,6 +100,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 _)) { @@ -250,6 +256,20 @@ protected override async Task 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(); + + var steps = await backchannel.GetPipelineStepsAsync(cancellationToken); + PrintPipelineSteps(steps); + + await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false); + await pendingRun; + return ExitCodeConstants.Success; + } + var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken); // Check if debug or trace logging is enabled @@ -361,6 +381,53 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } + /// + /// Prints pipeline steps in a numbered tree format showing dependencies and tags. + /// + 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]{i + 1}. {step.Name.EscapeMarkup()}[/]"); + + var hasDeps = step.DependsOn.Length > 0; + var hasTags = step.Tags.Length > 0; + + if (!hasDeps && !hasTags) + { + _ansiConsole.MarkupLine(" └─ No dependencies"); + } + else + { + if (hasDeps) + { + var depsText = string.Join(", ", step.DependsOn); + var connector = hasTags ? "├" : "└"; + _ansiConsole.MarkupLine($" {connector}─ Depends on: {depsText.EscapeMarkup()}"); + } + + if (hasTags) + { + var tagsText = string.Join(", ", step.Tags); + _ansiConsole.MarkupLine($" └─ Tags: {tagsText.EscapeMarkup()}"); + } + } + + if (i < steps.Length - 1) + { + _ansiConsole.WriteLine(); + } + } + } + /// /// Conditionally converts markdown to Spectre markup based on the EnableMarkdown flag in the activity data. /// diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 08e0081bb2a..24c78d19985 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Exec; @@ -191,7 +193,8 @@ public Task GetCapabilitiesAsync(CancellationToken cancellationToken) _ = cancellationToken; return Task.FromResult(new string[] { - "baseline.v2" + "baseline.v2", + "pipeline-steps.v1" }); } #pragma warning restore CA1822 @@ -205,4 +208,29 @@ public async Task UpdatePromptResponseAsync(string promptId, PublishingPromptInp { await activityReporter.CompleteInteractionAsync(promptId, answers, updateResponse: true, cancellationToken).ConfigureAwait(false); } + + public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + { + logger.LogDebug("Resolving pipeline steps for list-steps request."); + + var pipeline = serviceProvider.GetRequiredService() as DistributedApplicationPipeline + ?? throw new InvalidOperationException("Pipeline is not a DistributedApplicationPipeline."); + + var model = serviceProvider.GetRequiredService(); + var executionContext = serviceProvider.GetRequiredService(); + + var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken); + + var resolvedSteps = await pipeline.ResolveStepsAsync(pipelineContext).ConfigureAwait(false); + var orderedSteps = DistributedApplicationPipeline.GetTopologicalOrder(resolvedSteps); + + return orderedSteps.Select(step => new PipelineStepInfo + { + Name = step.Name, + Description = step.Description, + DependsOn = [.. step.DependsOnSteps], + Tags = [.. step.Tags], + ResourceName = step.Resource?.Name + }).ToArray(); + } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 8dfb541e50a..8733795202d 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -688,6 +688,37 @@ internal class PublishingPromptInputAnswer public string? Value { get; set; } } +/// +/// Represents metadata about a pipeline step for display purposes (e.g., --list-steps). +/// +internal sealed class PipelineStepInfo +{ + /// + /// Gets the unique name of the step. + /// + public required string Name { get; init; } + + /// + /// Gets the description of the step. + /// + public string? Description { get; init; } + + /// + /// Gets the names of steps that this step depends on. + /// + public string[] DependsOn { get; init; } = []; + + /// + /// Gets the tags that categorize this step. + /// + public string[] Tags { get; init; } = []; + + /// + /// Gets the name of the resource this step is associated with, if any. + /// + public string? ResourceName { get; init; } +} + /// /// Represents the connection information for the Dashboard MCP server. /// diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 7af228f283c..3b2e77c1635 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -379,6 +379,26 @@ public void AddPipelineConfiguration(Func ca } public async Task ExecuteAsync(PipelineContext context) + { + var allSteps = await ResolveStepsAsync(context).ConfigureAwait(false); + + if (allSteps.Count == 0) + { + return; + } + + var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context); + + // Build dependency graph and execute with readiness-based scheduler + await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false); + } + + /// + /// Resolves all pipeline steps (from built-in steps and resource annotations), + /// normalizes dependencies, validates, and returns the steps in topological order + /// without executing them. + /// + internal async Task> ResolveStepsAsync(PipelineContext context) { var annotationSteps = await CollectStepsFromAnnotationsAsync(context).ConfigureAwait(false); var allSteps = _steps.Concat(annotationSteps).ToList(); @@ -389,7 +409,7 @@ public async Task ExecuteAsync(PipelineContext context) if (allSteps.Count == 0) { - return; + return allSteps; } ValidateSteps(allSteps); @@ -401,10 +421,7 @@ public async Task ExecuteAsync(PipelineContext context) // Capture resolved pipeline data for diagnostics (before filtering) _lastResolvedSteps = allSteps; - var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context); - - // Build dependency graph and execute with readiness-based scheduler - await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false); + return allSteps; } /// @@ -1199,7 +1216,7 @@ private static int GetExecutionLevelRecursive( /// /// Gets the topological order of steps for execution. /// - private static List GetTopologicalOrder(List steps) + internal static List GetTopologicalOrder(List steps) { var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal); var visited = new HashSet(StringComparer.Ordinal); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs new file mode 100644 index 00000000000..3e5f52229f5 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for the aspire do --list-steps feature. +/// Verifies that the CLI can list pipeline steps without executing them. +/// +public sealed class ListStepsTests(ITestOutputHelper output) +{ + [Fact] + public async Task DoListStepsShowsPipelineSteps() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot); + + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliInDockerAsync(installMode, counter); + + // Create a new Aspire project + await auto.AspireNewAsync("ListStepsApp", counter); + + // Navigate to the AppHost project + await auto.TypeAsync("cd ListStepsApp/ListStepsApp.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Run aspire do deploy --list-steps + await auto.TypeAsync("aspire do deploy --list-steps"); + await auto.EnterAsync(); + + // Wait for the output to contain step information + // The output should contain numbered steps with dependencies + await auto.WaitUntilAsync(s => + s.ContainsText("Depends on:") || s.ContainsText("No dependencies"), + timeout: TimeSpan.FromMinutes(3), + description: "waiting for --list-steps output with step dependency information"); + + await auto.WaitForSuccessPromptAsync(counter); + + // Exit the terminal + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index d8e29487c77..c26f0851f13 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Backchannel; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -280,4 +281,230 @@ public async Task DoCommandFailsWithInvalidProjectFile() // Assert Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + + [Fact] + public async Task DoCommandWithListStepsReturnsZero() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var requestStopCalled = new TaskCompletionSource(); + var getPipelineStepsCalled = new TaskCompletionSource(); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, + + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + }, + + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = requestStopCalled, + GetPipelineStepsAsyncCalled = getPipelineStepsCalled + }; + backchannelCompletionSource?.SetResult(backchannel); + await requestStopCalled.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + // Act - no step argument needed with --list-steps + var result = command.Parse("do --list-steps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Assert + Assert.Equal(0, exitCode); + Assert.True(getPipelineStepsCalled.Task.IsCompleted, "GetPipelineStepsAsync should have been called"); + Assert.True(requestStopCalled.Task.IsCompleted, "RequestStopAsync should have been called"); + } + + [Fact] + public async Task DoCommandWithListStepsAndStepArgumentReturnsZero() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var requestStopCalled = new TaskCompletionSource(); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, + + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + }, + + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = requestStopCalled + }; + backchannelCompletionSource?.SetResult(backchannel); + await requestStopCalled.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + // Act - step argument with --list-steps + var result = command.Parse("do deploy --list-steps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Assert + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DoCommandWithListStepsDoesNotExecutePipeline() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var publishingActivitiesRequested = false; + var requestStopCalled = new TaskCompletionSource(); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, + + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + }, + + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = requestStopCalled, + GetPublishingActivitiesAsyncCallback = (ct) => + { + publishingActivitiesRequested = true; + return AsyncEnumerable.Empty(); + } + }; + backchannelCompletionSource?.SetResult(backchannel); + await requestStopCalled.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("do --list-steps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Assert - pipeline should NOT have been executed + Assert.Equal(0, exitCode); + Assert.False(publishingActivitiesRequested, "Publishing activities should not be requested when --list-steps is used"); + } + + [Fact] + public async Task DoCommandListStepsDisplaysCustomSteps() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var requestStopCalled = new TaskCompletionSource(); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, + + GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + }, + + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => + { + var backchannel = new TestAppHostBackchannel + { + RequestStopAsyncCalled = requestStopCalled, + GetPipelineStepsAsyncCallback = (ct) => Task.FromResult(new PipelineStepInfo[] + { + new() { Name = "parameter-prompt" }, + new() { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] }, + new() { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] }, + new() { Name = "deploy-webapi", DependsOn = ["provision-redis-infra", "build-webapi"], Tags = ["deploy-compute"] } + }) + }; + backchannelCompletionSource?.SetResult(backchannel); + await requestStopCalled.Task.DefaultTimeout(); + return 0; + } + }; + + return runner; + }; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("do --list-steps"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DoCommandWithHelpShowsListStepsOption() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("do --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs b/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs new file mode 100644 index 00000000000..2f013002992 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Commands; + +public class PipelineCommandListStepsTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void PrintPipelineSteps_WithNoDependencies_ShowsNoDependencies() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([new PipelineStepInfo { Name = "parameter-prompt" }]); + + var output = writer.ToString(); + Assert.Contains("1. parameter-prompt", output); + Assert.Contains("No dependencies", output); + } + + [Fact] + public void PrintPipelineSteps_WithDependencies_ShowsDependsOn() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "parameter-prompt" }, + new PipelineStepInfo { Name = "build-webapi", DependsOn = ["parameter-prompt"] } + ]); + + var output = writer.ToString(); + Assert.Contains("2. build-webapi", output); + Assert.Contains("Depends on: parameter-prompt", output); + } + + [Fact] + public void PrintPipelineSteps_WithMultipleDependencies_ShowsAllDependencies() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["build-webapi", "provision-redis"] } + ]); + + var output = writer.ToString(); + Assert.Contains("Depends on: build-webapi, provision-redis", output); + } + + [Fact] + public void PrintPipelineSteps_WithTags_ShowsTags() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] } + ]); + + var output = writer.ToString(); + Assert.Contains("Tags: build-compute", output); + } + + [Fact] + public void PrintPipelineSteps_WithDepsAndTags_ShowsBothConnectors() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] } + ]); + + var output = writer.ToString(); + Assert.Contains("Depends on: parameter-prompt", output); + Assert.Contains("Tags: provision-infra", output); + } + + [Fact] + public void PrintPipelineSteps_WithEmptySteps_ShowsNoStepsMessage() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([]); + + var output = writer.ToString(); + Assert.Contains("No pipeline steps found", output); + } + + [Fact] + public void PrintPipelineSteps_NumbersStepsSequentially() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "step-a" }, + new PipelineStepInfo { Name = "step-b" }, + new PipelineStepInfo { Name = "step-c" } + ]); + + var output = writer.ToString(); + Assert.Contains("1. step-a", output); + Assert.Contains("2. step-b", output); + Assert.Contains("3. step-c", output); + } + + [Fact] + public void PrintPipelineSteps_FullPipelineOutput() + { + var (command, writer) = CreateCommandWithCapturedOutput(); + + command.PrintPipelineSteps([ + new PipelineStepInfo { Name = "parameter-prompt" }, + new PipelineStepInfo { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] }, + new PipelineStepInfo { Name = "provision-postgres-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] }, + new PipelineStepInfo { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] }, + new PipelineStepInfo { Name = "build-frontend", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] }, + new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["provision-redis-infra", "provision-postgres-infra", "build-webapi"], Tags = ["deploy-compute"] }, + new PipelineStepInfo { Name = "deploy-frontend", DependsOn = ["build-frontend", "deploy-webapi"], Tags = ["deploy-compute"] }, + ]); + + var output = writer.ToString(); + Assert.Contains("1. parameter-prompt", output); + Assert.Contains("7. deploy-frontend", output); + Assert.Contains("No dependencies", output); + Assert.Contains("Depends on: provision-redis-infra, provision-postgres-infra, build-webapi", output); + Assert.Contains("Tags: provision-infra", output); + Assert.Contains("Tags: build-compute", output); + Assert.Contains("Tags: deploy-compute", output); + } + + private (PipelineCommandBase Command, StringWriter Writer) CreateCommandWithCapturedOutput() + { + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.No, + Interactive = InteractionSupport.No + }); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper); + services.AddSingleton(console); + var provider = services.BuildServiceProvider(); + return (provider.GetRequiredService(), writer); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 9be6f55cfdf..6a3ab91c680 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -837,6 +837,9 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] await Task.CompletedTask; // Suppress CS1998 yield break; } + + public Task GetPipelineStepsAsync(CancellationToken cancellationToken) => + Task.FromResult(Array.Empty()); } // Data structures for tracking prompts diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index 04be45b8aca..9e316691dd5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -29,6 +29,9 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public TaskCompletionSource? GetCapabilitiesAsyncCalled { get; set; } public Func>? GetCapabilitiesAsyncCallback { get; set; } + public TaskCompletionSource? GetPipelineStepsAsyncCalled { get; set; } + public Func>? GetPipelineStepsAsyncCallback { get; set; } + public Task RequestStopAsync(CancellationToken cancellationToken) { RequestStopAsyncCalled?.SetResult(); @@ -250,4 +253,19 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] await Task.Delay(1, cancellationToken).ConfigureAwait(false); yield return new CommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; } + + public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + { + GetPipelineStepsAsyncCalled?.SetResult(); + if (GetPipelineStepsAsyncCallback is not null) + { + return await GetPipelineStepsAsyncCallback(cancellationToken).ConfigureAwait(false); + } + + return [ + new PipelineStepInfo { Name = "process-parameters", Description = "Prompts for parameter values" }, + new PipelineStepInfo { Name = "build-webapi", DependsOn = ["process-parameters"], Tags = ["build-compute"] }, + new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["build-webapi"], Tags = ["deploy-compute"] } + ]; + } } diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 3d8313f6ad5..29d4b0c26e9 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -2269,4 +2269,127 @@ private sealed class DummyProject : IProjectMetadata private sealed class CustomResource(string name) : Resource(name) { } + + [Fact] + public async Task ResolveStepsAsync_ReturnsAllSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("custom-step", _ => Task.CompletedTask); + + var context = CreateDeployingContext(builder.Build()); + var steps = await pipeline.ResolveStepsAsync(context).DefaultTimeout(); + + // Should include built-in steps + our custom step + Assert.Contains(steps, s => s.Name == "custom-step"); + Assert.Contains(steps, s => s.Name == "deploy"); + Assert.Contains(steps, s => s.Name == "process-parameters"); + } + + [Fact] + public async Task ResolveStepsAsync_NormalizesRequiredByToDependsOn() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var pipeline = new DistributedApplicationPipeline(); + var step = new PipelineStep + { + Name = "my-step", + Action = _ => Task.CompletedTask, + }; + step.RequiredBy("deploy"); + pipeline.AddStep(step); + + var context = CreateDeployingContext(builder.Build()); + var steps = await pipeline.ResolveStepsAsync(context).DefaultTimeout(); + + // "deploy" should now depend on "my-step" due to RequiredBy normalization + var deployStep = steps.Single(s => s.Name == "deploy"); + Assert.Contains("my-step", deployStep.DependsOnSteps); + } + + [Fact] + public async Task ResolveStepsAsync_PreservesTags() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep(new PipelineStep + { + Name = "tagged-step", + Action = _ => Task.CompletedTask, + Tags = ["build-compute", "custom-tag"] + }); + + var context = CreateDeployingContext(builder.Build()); + var steps = await pipeline.ResolveStepsAsync(context).DefaultTimeout(); + + var taggedStep = steps.Single(s => s.Name == "tagged-step"); + Assert.Contains("build-compute", taggedStep.Tags); + Assert.Contains("custom-tag", taggedStep.Tags); + } + + [Fact] + public async Task ResolveStepsAsync_DoesNotExecuteStepActions() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var stepExecuted = false; + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("should-not-execute", _ => + { + stepExecuted = true; + return Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ResolveStepsAsync(context).DefaultTimeout(); + + Assert.False(stepExecuted, "Step action should not be executed during resolution"); + } + + [Fact] + public void GetTopologicalOrder_ReturnsStepsInDependencyOrder() + { + var steps = new List + { + new() { Name = "deploy", Action = _ => Task.CompletedTask, DependsOnSteps = { "build" } }, + new() { Name = "build", Action = _ => Task.CompletedTask, DependsOnSteps = { "init" } }, + new() { Name = "init", Action = _ => Task.CompletedTask } + }; + + var ordered = DistributedApplicationPipeline.GetTopologicalOrder(steps); + + var initIndex = ordered.FindIndex(s => s.Name == "init"); + var buildIndex = ordered.FindIndex(s => s.Name == "build"); + var deployIndex = ordered.FindIndex(s => s.Name == "deploy"); + + Assert.True(initIndex < buildIndex, "init should come before build"); + Assert.True(buildIndex < deployIndex, "build should come before deploy"); + } + + [Fact] + public void GetTopologicalOrder_IsDeterministic() + { + var steps = new List + { + new() { Name = "c-step", Action = _ => Task.CompletedTask }, + new() { Name = "a-step", Action = _ => Task.CompletedTask }, + new() { Name = "b-step", Action = _ => Task.CompletedTask } + }; + + var ordered1 = DistributedApplicationPipeline.GetTopologicalOrder(steps); + var ordered2 = DistributedApplicationPipeline.GetTopologicalOrder(steps); + + Assert.Equal(ordered1.Select(s => s.Name), ordered2.Select(s => s.Name)); + } } From 8b3ce2ac5b8dd9aa4fb1bfdd1e0d6830fc89084e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 18:01:56 +1000 Subject: [PATCH 2/8] 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> --- .../Commands/PipelineCommandBase.cs | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index b2b0b7c52f9..da6b7e085d7 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -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; @@ -396,28 +397,31 @@ internal void PrintPipelineSteps(PipelineStepInfo[] steps) { var step = steps[i]; - _ansiConsole.MarkupLine($"[bold]{i + 1}. {step.Name.EscapeMarkup()}[/]"); + _ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [bold]{step.Name.EscapeMarkup()}[/]"); var hasDeps = step.DependsOn.Length > 0; var hasTags = step.Tags.Length > 0; if (!hasDeps && !hasTags) { - _ansiConsole.MarkupLine(" └─ No dependencies"); + _ansiConsole.MarkupLine("[dim] └─ No dependencies[/]"); } else { if (hasDeps) { - var depsText = string.Join(", ", step.DependsOn); var connector = hasTags ? "├" : "└"; - _ansiConsole.MarkupLine($" {connector}─ Depends on: {depsText.EscapeMarkup()}"); + var continuation = hasTags ? "│" : " "; + // " ├─ Depends on: " is 19 chars; hanging indent aligns continuation items + const string hangingIndent = " "; + var wrappedDeps = FormatWithHangingIndent(step.DependsOn, $" {connector}─ [blue]Depends on:[/] ", $" {continuation} {hangingIndent}"); + _ansiConsole.MarkupLine(wrappedDeps); } if (hasTags) { var tagsText = string.Join(", ", step.Tags); - _ansiConsole.MarkupLine($" └─ Tags: {tagsText.EscapeMarkup()}"); + _ansiConsole.MarkupLine($" └─ [yellow]Tags:[/] {tagsText.EscapeMarkup()}"); } } @@ -428,6 +432,50 @@ internal void PrintPipelineSteps(PipelineStepInfo[] steps) } } + /// + /// 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. + /// + 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, @"\[/?[^\]]*\]", ""); + } + /// /// Conditionally converts markdown to Spectre markup based on the EnableMarkdown flag in the activity data. /// From b72f06eef6e37b0d197c0b2be945f15214aa345a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 18:12:28 +1000 Subject: [PATCH 3/8] 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> --- .../Commands/PipelineCommandListStepsTests.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs b/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs index 2f013002992..191c56aa85b 100644 --- a/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PipelineCommandListStepsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.RegularExpressions; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; @@ -9,8 +10,13 @@ namespace Aspire.Cli.Tests.Commands; -public class PipelineCommandListStepsTests(ITestOutputHelper outputHelper) +public partial class PipelineCommandListStepsTests(ITestOutputHelper outputHelper) { + [GeneratedRegex(@"\x1B\[[0-9;]*m")] + private static partial Regex AnsiEscapeRegex(); + + private static string StripAnsi(string text) => AnsiEscapeRegex().Replace(text, ""); + [Fact] public void PrintPipelineSteps_WithNoDependencies_ShowsNoDependencies() { @@ -18,7 +24,7 @@ public void PrintPipelineSteps_WithNoDependencies_ShowsNoDependencies() command.PrintPipelineSteps([new PipelineStepInfo { Name = "parameter-prompt" }]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("1. parameter-prompt", output); Assert.Contains("No dependencies", output); } @@ -33,7 +39,7 @@ public void PrintPipelineSteps_WithDependencies_ShowsDependsOn() new PipelineStepInfo { Name = "build-webapi", DependsOn = ["parameter-prompt"] } ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("2. build-webapi", output); Assert.Contains("Depends on: parameter-prompt", output); } @@ -47,7 +53,7 @@ public void PrintPipelineSteps_WithMultipleDependencies_ShowsAllDependencies() new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["build-webapi", "provision-redis"] } ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("Depends on: build-webapi, provision-redis", output); } @@ -60,7 +66,7 @@ public void PrintPipelineSteps_WithTags_ShowsTags() new PipelineStepInfo { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] } ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("Tags: build-compute", output); } @@ -73,7 +79,7 @@ public void PrintPipelineSteps_WithDepsAndTags_ShowsBothConnectors() new PipelineStepInfo { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] } ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("Depends on: parameter-prompt", output); Assert.Contains("Tags: provision-infra", output); } @@ -85,7 +91,7 @@ public void PrintPipelineSteps_WithEmptySteps_ShowsNoStepsMessage() command.PrintPipelineSteps([]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("No pipeline steps found", output); } @@ -100,7 +106,7 @@ public void PrintPipelineSteps_NumbersStepsSequentially() new PipelineStepInfo { Name = "step-c" } ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("1. step-a", output); Assert.Contains("2. step-b", output); Assert.Contains("3. step-c", output); @@ -121,7 +127,7 @@ public void PrintPipelineSteps_FullPipelineOutput() new PipelineStepInfo { Name = "deploy-frontend", DependsOn = ["build-frontend", "deploy-webapi"], Tags = ["deploy-compute"] }, ]); - var output = writer.ToString(); + var output = StripAnsi(writer.ToString()); Assert.Contains("1. parameter-prompt", output); Assert.Contains("7. deploy-frontend", output); Assert.Contains("No dependencies", output); From dcabade0e8475e04e7c3dc8def4548bc58aebf1e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 18:38:41 +1000 Subject: [PATCH 4/8] Fix hanging indent alignment and use cyan for step names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/Aspire.Cli/Commands/PipelineCommandBase.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index da6b7e085d7..2d3b04d13f9 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -397,7 +397,7 @@ internal void PrintPipelineSteps(PipelineStepInfo[] steps) { var step = steps[i]; - _ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [bold]{step.Name.EscapeMarkup()}[/]"); + _ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [cyan]{step.Name.EscapeMarkup()}[/]"); var hasDeps = step.DependsOn.Length > 0; var hasTags = step.Tags.Length > 0; @@ -412,9 +412,11 @@ internal void PrintPipelineSteps(PipelineStepInfo[] steps) { var connector = hasTags ? "├" : "└"; var continuation = hasTags ? "│" : " "; - // " ├─ Depends on: " is 19 chars; hanging indent aligns continuation items - const string hangingIndent = " "; - var wrappedDeps = FormatWithHangingIndent(step.DependsOn, $" {connector}─ [blue]Depends on:[/] ", $" {continuation} {hangingIndent}"); + var firstLinePrefix = $" {connector}─ [blue]Depends on:[/] "; + // Continuation aligns items under the first dep value: + // " ├─ Depends on: " = 19 visible chars + var continuationPrefix = $" {continuation} "; + var wrappedDeps = FormatWithHangingIndent(step.DependsOn, firstLinePrefix, continuationPrefix); _ansiConsole.MarkupLine(wrappedDeps); } From 345dcdc0368bd067822a0a8644ac051a1cc27a32 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 19:01:48 +1000 Subject: [PATCH 5/8] 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> --- src/Aspire.Cli/Commands/PipelineCommandBase.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 2d3b04d13f9..60d0177b486 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -413,9 +413,10 @@ internal void PrintPipelineSteps(PipelineStepInfo[] steps) var connector = hasTags ? "├" : "└"; var continuation = hasTags ? "│" : " "; var firstLinePrefix = $" {connector}─ [blue]Depends on:[/] "; - // Continuation aligns items under the first dep value: - // " ├─ Depends on: " = 19 visible chars - var continuationPrefix = $" {continuation} "; + // 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); } From 8f8bc335fd65bef32055f95333692188c680f0d8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 12 Apr 2026 19:23:36 +1000 Subject: [PATCH 6/8] 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> --- .../Backchannel/AppHostCliBackchannel.cs | 12 ++++----- .../BackchannelJsonSerializerContext.cs | 2 ++ .../Commands/PipelineCommandBase.cs | 13 ++++++++-- .../Backchannel/AppHostRpcTarget.cs | 25 +++++++++++-------- .../Backchannel/BackchannelDataTypes.cs | 16 ++++++++++++ .../DistributedApplicationPipeline.cs | 5 ++-- .../Commands/DoCommandTests.cs | 12 +++++---- ...PublishCommandPromptingIntegrationTests.cs | 4 +-- .../TestServices/TestAppHostCliBackchannel.cs | 19 ++++++++------ 9 files changed, 73 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index f59f912f5a8..38a9b5d26a1 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -24,7 +24,7 @@ internal interface IAppHostCliBackchannel Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); - Task GetPipelineStepsAsync(CancellationToken cancellationToken); + Task GetPipelineStepsAsync(CancellationToken cancellationToken); } internal sealed class AppHostCliBackchannel(ILogger logger, AspireCliTelemetry telemetry) : IAppHostCliBackchannel @@ -481,21 +481,21 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] } } - public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false); logger.LogDebug("Requesting pipeline steps."); - var steps = await rpc.InvokeWithCancellationAsync( + var response = await rpc.InvokeWithCancellationAsync( "GetPipelineStepsAsync", - [], + [new GetPipelineStepsRequest()], cancellationToken).ConfigureAwait(false); - logger.LogDebug("Received {StepCount} pipeline steps.", steps.Length); + logger.LogDebug("Received {StepCount} pipeline steps.", response.Steps.Length); - return steps; + return response; } } diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 521502d7efe..397c4194ae1 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -78,6 +78,8 @@ namespace Aspire.Cli.Backchannel; [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.")] diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 60d0177b486..9eadb3c1f83 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -263,8 +263,17 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { StopTerminalProgressBar(); - var steps = await backchannel.GetPipelineStepsAsync(cancellationToken); - PrintPipelineSteps(steps); + // 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 response = await backchannel.GetPipelineStepsAsync(cancellationToken); + PrintPipelineSteps(response.Steps); await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false); await pendingRun; diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 24c78d19985..824c9094516 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Exec; @@ -209,10 +207,13 @@ public async Task UpdatePromptResponseAsync(string promptId, PublishingPromptInp await activityReporter.CompleteInteractionAsync(promptId, answers, updateResponse: true, cancellationToken).ConfigureAwait(false); } - public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + public async Task GetPipelineStepsAsync(GetPipelineStepsRequest? request = null, CancellationToken cancellationToken = default) { + _ = request; // Reserved for future filtering options + logger.LogDebug("Resolving pipeline steps for list-steps request."); +#pragma warning disable ASPIREPIPELINES001 var pipeline = serviceProvider.GetRequiredService() as DistributedApplicationPipeline ?? throw new InvalidOperationException("Pipeline is not a DistributedApplicationPipeline."); @@ -223,14 +224,18 @@ public async Task GetPipelineStepsAsync(CancellationToken ca var resolvedSteps = await pipeline.ResolveStepsAsync(pipelineContext).ConfigureAwait(false); var orderedSteps = DistributedApplicationPipeline.GetTopologicalOrder(resolvedSteps); +#pragma warning restore ASPIREPIPELINES001 - return orderedSteps.Select(step => new PipelineStepInfo + return new GetPipelineStepsResponse { - Name = step.Name, - Description = step.Description, - DependsOn = [.. step.DependsOnSteps], - Tags = [.. step.Tags], - ResourceName = step.Resource?.Name - }).ToArray(); + Steps = orderedSteps.Select(step => new PipelineStepInfo + { + Name = step.Name, + Description = step.Description, + DependsOn = [.. step.DependsOnSteps], + Tags = [.. step.Tags], + ResourceName = step.Resource?.Name + }).ToArray() + }; } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 8733795202d..ddf999c20e7 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -719,6 +719,22 @@ internal sealed class PipelineStepInfo public string? ResourceName { get; init; } } +/// +/// Request for getting pipeline step metadata. +/// +internal sealed class GetPipelineStepsRequest { } + +/// +/// Response containing pipeline step metadata. +/// +internal sealed class GetPipelineStepsResponse +{ + /// + /// Gets the pipeline steps in topological (execution) order. + /// + public required PipelineStepInfo[] Steps { get; init; } +} + /// /// Represents the connection information for the Dashboard MCP server. /// diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 3b2e77c1635..633ec4b612a 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -395,8 +395,9 @@ public async Task ExecuteAsync(PipelineContext context) /// /// Resolves all pipeline steps (from built-in steps and resource annotations), - /// normalizes dependencies, validates, and returns the steps in topological order - /// without executing them. + /// normalizes RequiredBy relationships to DependsOn, and validates the steps + /// without executing them. The returned list is in collection order; use + /// to obtain execution order. /// internal async Task> ResolveStepsAsync(PipelineContext context) { diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index c26f0851f13..9998887d667 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -466,12 +466,14 @@ public async Task DoCommandListStepsDisplaysCustomSteps() var backchannel = new TestAppHostBackchannel { RequestStopAsyncCalled = requestStopCalled, - GetPipelineStepsAsyncCallback = (ct) => Task.FromResult(new PipelineStepInfo[] + GetPipelineStepsAsyncCallback = (ct) => Task.FromResult(new GetPipelineStepsResponse { - new() { Name = "parameter-prompt" }, - new() { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] }, - new() { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] }, - new() { Name = "deploy-webapi", DependsOn = ["provision-redis-infra", "build-webapi"], Tags = ["deploy-compute"] } + Steps = [ + new() { Name = "parameter-prompt" }, + new() { Name = "provision-redis-infra", DependsOn = ["parameter-prompt"], Tags = ["provision-infra"] }, + new() { Name = "build-webapi", DependsOn = ["parameter-prompt"], Tags = ["build-compute"] }, + new() { Name = "deploy-webapi", DependsOn = ["provision-redis-infra", "build-webapi"], Tags = ["deploy-compute"] } + ] }) }; backchannelCompletionSource?.SetResult(backchannel); diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 6a3ab91c680..b0c19882c40 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -838,8 +838,8 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] yield break; } - public Task GetPipelineStepsAsync(CancellationToken cancellationToken) => - Task.FromResult(Array.Empty()); + public Task GetPipelineStepsAsync(CancellationToken cancellationToken) => + Task.FromResult(new GetPipelineStepsResponse { Steps = [] }); } // Data structures for tracking prompts diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index 9e316691dd5..f52bce1d2ac 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -30,7 +30,7 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public Func>? GetCapabilitiesAsyncCallback { get; set; } public TaskCompletionSource? GetPipelineStepsAsyncCalled { get; set; } - public Func>? GetPipelineStepsAsyncCallback { get; set; } + public Func>? GetPipelineStepsAsyncCallback { get; set; } public Task RequestStopAsync(CancellationToken cancellationToken) { @@ -222,7 +222,7 @@ public async Task GetCapabilitiesAsync(CancellationToken cancellationT } else { - return ["baseline.v2"]; + return ["baseline.v2", "pipeline-steps.v1"]; } } @@ -254,7 +254,7 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] yield return new CommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; } - public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) { GetPipelineStepsAsyncCalled?.SetResult(); if (GetPipelineStepsAsyncCallback is not null) @@ -262,10 +262,13 @@ public async Task GetPipelineStepsAsync(CancellationToken ca return await GetPipelineStepsAsyncCallback(cancellationToken).ConfigureAwait(false); } - return [ - new PipelineStepInfo { Name = "process-parameters", Description = "Prompts for parameter values" }, - new PipelineStepInfo { Name = "build-webapi", DependsOn = ["process-parameters"], Tags = ["build-compute"] }, - new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["build-webapi"], Tags = ["deploy-compute"] } - ]; + return new GetPipelineStepsResponse + { + Steps = [ + new PipelineStepInfo { Name = "process-parameters", Description = "Prompts for parameter values" }, + new PipelineStepInfo { Name = "build-webapi", DependsOn = ["process-parameters"], Tags = ["build-compute"] }, + new PipelineStepInfo { Name = "deploy-webapi", DependsOn = ["build-webapi"], Tags = ["deploy-compute"] } + ] + }; } } From f1f0b68f94ca9d2dbf62800856baf8e35a59f83f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 13 Apr 2026 09:26:20 +1000 Subject: [PATCH 7/8] 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> --- .../Backchannel/AppHostCliBackchannel.cs | 6 +++--- src/Aspire.Cli/Commands/DeployCommand.cs | 2 ++ src/Aspire.Cli/Commands/DoCommand.cs | 2 ++ .../Commands/PipelineCommandBase.cs | 9 ++++++++- src/Aspire.Cli/Commands/PublishCommand.cs | 2 ++ .../Backchannel/AppHostRpcTarget.cs | 19 +++++++++++++++++-- .../Backchannel/BackchannelDataTypes.cs | 9 ++++++++- .../DistributedApplicationPipeline.cs | 2 +- .../Commands/DoCommandTests.cs | 2 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../TestServices/TestAppHostCliBackchannel.cs | 6 +++--- 11 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index 38a9b5d26a1..1fa62b33c99 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -24,7 +24,7 @@ internal interface IAppHostCliBackchannel Task CompletePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); Task UpdatePromptResponseAsync(string promptId, PublishingPromptInputAnswer[] answers, CancellationToken cancellationToken); IAsyncEnumerable ExecAsync(CancellationToken cancellationToken); - Task GetPipelineStepsAsync(CancellationToken cancellationToken); + Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken); } internal sealed class AppHostCliBackchannel(ILogger logger, AspireCliTelemetry telemetry) : IAppHostCliBackchannel @@ -481,7 +481,7 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] } } - public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + public async Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); var rpc = await GetRpcTaskAsync().WaitAsync(cancellationToken).ConfigureAwait(false); @@ -490,7 +490,7 @@ public async Task GetPipelineStepsAsync(CancellationTo var response = await rpc.InvokeWithCancellationAsync( "GetPipelineStepsAsync", - [new GetPipelineStepsRequest()], + [new GetPipelineStepsRequest { Step = step }], cancellationToken).ConfigureAwait(false); logger.LogDebug("Received {StepCount} pipeline steps.", response.Steps.Length); diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 6b5a47543b6..9f483bd0eb4 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -77,6 +77,8 @@ protected override Task 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"; diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index 09b0d84f01a..54f6675b784 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -97,6 +97,8 @@ protected override async Task 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)) diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 9eadb3c1f83..e85726fc0a7 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -122,6 +122,12 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner protected abstract string GetCanceledMessage(); protected abstract string GetProgressMessage(ParseResult parseResult); + /// + /// Gets the target pipeline step name for this command, used for --list-steps filtering. + /// Returns null to show all steps. + /// + protected virtual string? GetTargetStepName(ParseResult parseResult) => null; + /// /// Gets command-specific arguments to forward when starting a debug session from the extension context. /// Subclasses should override to include their specific positional arguments. @@ -272,7 +278,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell "pipeline-steps.v1"); } - var response = await backchannel.GetPipelineStepsAsync(cancellationToken); + var targetStep = GetTargetStepName(parseResult); + var response = await backchannel.GetPipelineStepsAsync(targetStep, cancellationToken); PrintPipelineSteps(response.Steps); await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 93269112176..42b8416fd8b 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -85,5 +85,7 @@ protected override Task 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"; } diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 824c9094516..2f86c69d064 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -209,8 +209,6 @@ public async Task UpdatePromptResponseAsync(string promptId, PublishingPromptInp public async Task GetPipelineStepsAsync(GetPipelineStepsRequest? request = null, CancellationToken cancellationToken = default) { - _ = request; // Reserved for future filtering options - logger.LogDebug("Resolving pipeline steps for list-steps request."); #pragma warning disable ASPIREPIPELINES001 @@ -223,6 +221,23 @@ public async Task GetPipelineStepsAsync(GetPipelineSte 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 diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index ddf999c20e7..1f6d4f1e7d2 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -722,7 +722,14 @@ internal sealed class PipelineStepInfo /// /// Request for getting pipeline step metadata. /// -internal sealed class GetPipelineStepsRequest { } +internal sealed class GetPipelineStepsRequest +{ + /// + /// Gets or sets the target step name to filter to (including transitive dependencies). + /// When null, all steps are returned. + /// + public string? Step { get; init; } +} /// /// Response containing pipeline step metadata. diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 633ec4b612a..9b86605f805 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -482,7 +482,7 @@ private static (List StepsToExecute, Dictionary ComputeTransitiveDependencies( + internal static List ComputeTransitiveDependencies( PipelineStep step, Dictionary stepsByName) { diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index 9998887d667..b008a5b029e 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -466,7 +466,7 @@ public async Task DoCommandListStepsDisplaysCustomSteps() var backchannel = new TestAppHostBackchannel { RequestStopAsyncCalled = requestStopCalled, - GetPipelineStepsAsyncCallback = (ct) => Task.FromResult(new GetPipelineStepsResponse + GetPipelineStepsAsyncCallback = (step, ct) => Task.FromResult(new GetPipelineStepsResponse { Steps = [ new() { Name = "parameter-prompt" }, diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index b0c19882c40..2a6c6f1708f 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -838,7 +838,7 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] yield break; } - public Task GetPipelineStepsAsync(CancellationToken cancellationToken) => + public Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) => Task.FromResult(new GetPipelineStepsResponse { Steps = [] }); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index f52bce1d2ac..fdd7d422417 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -30,7 +30,7 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public Func>? GetCapabilitiesAsyncCallback { get; set; } public TaskCompletionSource? GetPipelineStepsAsyncCalled { get; set; } - public Func>? GetPipelineStepsAsyncCallback { get; set; } + public Func>? GetPipelineStepsAsyncCallback { get; set; } public Task RequestStopAsync(CancellationToken cancellationToken) { @@ -254,12 +254,12 @@ public async IAsyncEnumerable ExecAsync([EnumeratorCancellation] yield return new CommandOutput { Text = "test", IsErrorMessage = false, LineNumber = 0 }; } - public async Task GetPipelineStepsAsync(CancellationToken cancellationToken) + public async Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) { GetPipelineStepsAsyncCalled?.SetResult(); if (GetPipelineStepsAsyncCallback is not null) { - return await GetPipelineStepsAsyncCallback(cancellationToken).ConfigureAwait(false); + return await GetPipelineStepsAsyncCallback(step, cancellationToken).ConfigureAwait(false); } return new GetPipelineStepsResponse From d8c2da877e341641a429b61e1414e5fde7bd0a81 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 14 Apr 2026 09:07:18 +1000 Subject: [PATCH 8/8] 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> --- src/Aspire.Cli/Commands/DoCommand.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index 54f6675b784..6b4bbacfe9f 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -30,6 +30,16 @@ public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService 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; @@ -55,12 +65,6 @@ protected override async Task GetRunArgumentsAsync(string? fullyQualif cancellationToken: cancellationToken); } - // Step is required when not using --list-steps - if (string.IsNullOrEmpty(step) && !parseResult.GetValue(s_listStepsOption)) - { - throw new InvalidOperationException("The 'step' argument is required when not using --list-steps."); - } - if (!string.IsNullOrEmpty(step)) { baseArgs.AddRange(["--step", step]);