From 11a219e7c252e4b464aed9a15a9b313e40b8c7f9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:11:27 -0500 Subject: [PATCH 01/11] Add DebugSessionOptions type and thread through RPC layer --- .../BackchannelJsonSerializerContext.cs | 1 + .../Backchannel/ExtensionBackchannel.cs | 10 +++++----- .../ExtensionBackchannelDataTypes.cs | 18 ++++++++++++++++++ src/Aspire.Cli/Commands/RunCommand.cs | 2 +- .../Interaction/ExtensionInteractionService.cs | 6 +++--- .../TestServices/TestExtensionBackchannel.cs | 6 +++--- .../TestExtensionInteractionService.cs | 2 +- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 815878ca712..c21c9d65abd 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -37,6 +37,7 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(EnvVar))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DebugSessionOptions))] [JsonSerializable(typeof(List>))] [JsonSerializable(typeof(bool?))] [JsonSerializable(typeof(AppHostProjectSearchResultPoco))] diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index d98fad345d7..aaff055ca94 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -43,7 +43,7 @@ internal interface IExtensionBackchannel Task HasCapabilityAsync(string capability, CancellationToken cancellationToken); Task LaunchAppHostAsync(string projectFile, List arguments, List environment, bool debug, CancellationToken cancellationToken); Task NotifyAppHostStartupCompletedAsync(CancellationToken cancellationToken); - Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, CancellationToken cancellationToken); + Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options, CancellationToken cancellationToken); Task DisplayPlainTextAsync(string text, CancellationToken cancellationToken); Task WriteDebugSessionMessageAsync(string message, bool stdout, string? textStyle, CancellationToken cancellationToken); } @@ -686,7 +686,7 @@ await rpc.InvokeWithCancellationAsync( } public async Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, - CancellationToken cancellationToken) + DebugSessionOptions? options, CancellationToken cancellationToken) { await ConnectAsync(cancellationToken); @@ -694,12 +694,12 @@ public async Task StartDebugSessionAsync(string workingDirectory, string? projec var rpc = await _rpcTaskCompletionSource.Task; - _logger.LogDebug("Starting extension debugging session in directory {WorkingDirectory} for project file {ProjectFile} with debug={Debug}", - workingDirectory, projectFile ?? "", debug); + _logger.LogDebug("Starting extension debugging session in directory {WorkingDirectory} for project file {ProjectFile} with command={Command} debug={Debug}", + workingDirectory, projectFile ?? "", options?.Command ?? "", debug); await rpc.InvokeWithCancellationAsync( "startDebugSession", - [_token, workingDirectory, projectFile, debug], + [_token, workingDirectory, projectFile, debug, options], cancellationToken); } diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs index f66fa34f3d7..a4421758cb0 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs @@ -58,3 +58,21 @@ internal sealed class EnvVar [JsonPropertyName("value")] public string? Value { get; set; } } + +/// +/// Options passed when starting a debug session from the CLI to the extension. +/// +internal sealed class DebugSessionOptions +{ + /// + /// Gets or sets the command type for the debug session (e.g., "run", "deploy", "publish", "do"). + /// + [JsonPropertyName("command")] + public string? Command { get; set; } + + /// + /// Gets or sets additional arguments to pass to the command (e.g., step name for "do", unmatched tokens). + /// + [JsonPropertyName("args")] + public string[]? Args { get; set; } +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index e13252936f2..76e9e3c2e8c 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -172,7 +172,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) { extensionInteractionService.DisplayConsolePlainText(RunCommandStrings.StartingDebugSessionInExtension); - await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, startDebugSession); + await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, startDebugSession, new DebugSessionOptions { Command = "run" }); return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index fcdaa1f706f..893622c0fd3 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -20,7 +20,7 @@ internal interface IExtensionInteractionService : IInteractionService void DisplayDashboardUrls(DashboardUrlsState dashboardUrls); void NotifyAppHostStartupCompleted(); void DisplayConsolePlainText(string message); - Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug); + Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options = null); void WriteDebugSessionMessage(string message, bool stdout, string? textStyle); void ConsoleDisplaySubtleMessage(string message, bool allowMarkup = false); } @@ -433,9 +433,9 @@ public void DisplayConsolePlainText(string message) _consoleInteractionService.DisplayPlainText(message); } - public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug) + public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options = null) { - return Backchannel.StartDebugSessionAsync(workingDirectory, projectFile, debug, _cancellationToken); + return Backchannel.StartDebugSessionAsync(workingDirectory, projectFile, debug, options, _cancellationToken); } public void WriteDebugSessionMessage(string message, bool stdout, string? textStyle) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs index caf5010a7c9..03fd941e236 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs @@ -77,7 +77,7 @@ internal sealed class TestExtensionBackchannel : IExtensionBackchannel public TaskCompletionSource? NotifyAppHostStartupCompletedAsyncCalled { get; set; } public TaskCompletionSource? StartDebugSessionAsyncCalled { get; set; } - public Func? StartDebugSessionAsyncCallback { get; set; } + public Func? StartDebugSessionAsyncCallback { get; set; } public TaskCompletionSource? DisplayPlainTextAsyncCalled { get; set; } public Func? DisplayPlainTextAsyncCallback { get; set; } @@ -260,11 +260,11 @@ public Task NotifyAppHostStartupCompletedAsync(CancellationToken cancellationTok } public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, - CancellationToken cancellationToken) + DebugSessionOptions? options, CancellationToken cancellationToken) { StartDebugSessionAsyncCalled?.SetResult(); return StartDebugSessionAsyncCallback != null - ? StartDebugSessionAsyncCallback.Invoke(workingDirectory, projectFile, debug) + ? StartDebugSessionAsyncCallback.Invoke(workingDirectory, projectFile, debug, options) : Task.CompletedTask; } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 07e82a5609f..08ec5586ee2 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -97,7 +97,7 @@ public void DisplayConsolePlainText(string message) DisplayConsoleWriteLineMessage?.Invoke(message); } - public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug) + public Task StartDebugSessionAsync(string workingDirectory, string? projectFile, bool debug, DebugSessionOptions? options = null) { StartDebugSessionCallback?.Invoke(workingDirectory, projectFile, debug); return Task.CompletedTask; From fdebc4a3cb54747f1223b876fbc8fc6c9ea0f4b6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:11:38 -0500 Subject: [PATCH 02/11] Advertise CLI capabilities including pipelines in config info --- extension/src/types/configInfo.ts | 1 + src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs | 2 +- src/Aspire.Cli/Commands/ConfigCommand.cs | 2 +- src/Aspire.Cli/Commands/ConfigInfo.cs | 8 +++++--- src/Aspire.Cli/Utils/ExtensionHelper.cs | 6 ++++++ 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/extension/src/types/configInfo.ts b/extension/src/types/configInfo.ts index 1f539f4f7c9..d2815edf62c 100644 --- a/extension/src/types/configInfo.ts +++ b/extension/src/types/configInfo.ts @@ -25,4 +25,5 @@ export interface ConfigInfo { GlobalSettingsPath: string; AvailableFeatures: FeatureInfo[]; SettingsSchema: SettingsSchema; + Capabilities?: string[]; } diff --git a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs index 995e15770d8..90d47b97397 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs @@ -56,6 +56,6 @@ public Task StopCliAsync() public Task GetCliCapabilitiesAsync() { - return Task.FromResult(new[] { KnownCapabilities.BuildDotnetUsingCli }); + return Task.FromResult(KnownCapabilities.GetAdvertisedCapabilities()); } } diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index 3c4d0d50bdd..78b05aedadb 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -446,7 +446,7 @@ private Task ExecuteAsync(bool useJson) if (useJson) { - var info = new ConfigInfo(localPath, globalPath, availableFeatures, localSchema, globalSchema); + var info = new ConfigInfo(localPath, globalPath, availableFeatures, localSchema, globalSchema, KnownCapabilities.GetAdvertisedCapabilities()); var json = System.Text.Json.JsonSerializer.Serialize(info, JsonSourceGenerationContext.Default.ConfigInfo); // Use DisplayRawText to avoid Spectre.Console word wrapping which breaks JSON strings if (InteractionService is ConsoleInteractionService consoleService) diff --git a/src/Aspire.Cli/Commands/ConfigInfo.cs b/src/Aspire.Cli/Commands/ConfigInfo.cs index 1e9a99ea96c..d0366361e87 100644 --- a/src/Aspire.Cli/Commands/ConfigInfo.cs +++ b/src/Aspire.Cli/Commands/ConfigInfo.cs @@ -11,12 +11,14 @@ namespace Aspire.Cli.Commands; /// List of all available feature metadata. /// Schema for the local settings.json file structure (includes all properties). /// Schema for the global settings.json file structure (excludes local-only properties). +/// List of CLI capabilities advertised to extensions. internal sealed record ConfigInfo( - string LocalSettingsPath, - string GlobalSettingsPath, + string LocalSettingsPath, + string GlobalSettingsPath, List AvailableFeatures, SettingsSchema LocalSettingsSchema, - SettingsSchema GlobalSettingsSchema); + SettingsSchema GlobalSettingsSchema, + string[] Capabilities); /// /// Information about a single feature flag. diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index 0ac0170359f..a399529301e 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -35,4 +35,10 @@ internal static class KnownCapabilities public const string Baseline = "baseline.v1"; public const string SecretPrompts = "secret-prompts.v1"; public const string FilePickers = "file-pickers.v1"; + public const string Pipelines = "pipelines"; + + /// + /// Gets the set of capabilities this CLI advertises to extensions. + /// + public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines]; } From c344e3490ad5d4125fa33327581989abc8250eba Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:11:49 -0500 Subject: [PATCH 03/11] Add extension context interception and --start-debug-session to pipeline commands --- src/Aspire.Cli/Commands/DeployCommand.cs | 5 +- src/Aspire.Cli/Commands/DoCommand.cs | 11 ++- .../Commands/PipelineCommandBase.cs | 75 ++++++++++++++++--- src/Aspire.Cli/Commands/PublishCommand.cs | 5 +- .../Projects/DotNetAppHostProject.cs | 7 +- src/Aspire.Cli/Projects/IAppHostProject.cs | 5 ++ 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 765925e7047..917e90fbfd7 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -20,8 +21,8 @@ internal sealed class DeployCommand : PipelineCommandBase private readonly Option _clearCacheOption; - public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + public DeployCommand(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("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole) { _clearCacheOption = new Option("--clear-cache") { diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index f4d3bee3486..cecaf7d2775 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -20,8 +21,8 @@ internal sealed class DoCommand : PipelineCommandBase private readonly Argument _stepArgument; - public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("do", DoCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + 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) { _stepArgument = new Argument("step") { @@ -34,6 +35,12 @@ public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService protected override string OperationFailedPrefix => DoCommandStrings.OperationFailedPrefix; protected override string GetOutputPathDescription() => DoCommandStrings.OutputPathArgumentDescription; + protected override string[] GetCommandArgs(ParseResult parseResult) + { + var step = parseResult.GetValue(_stepArgument); + return !string.IsNullOrEmpty(step) ? [step] : []; + } + protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult) { var baseArgs = new List { "--operation", "publish" }; diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 2d19ccb94d3..fd83f0732df 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -28,6 +29,7 @@ internal abstract class PipelineCommandBase : BaseCommand protected readonly IProjectLocator _projectLocator; protected readonly IAppHostProjectFactory _projectFactory; + private readonly IConfiguration _configuration; private readonly IFeatures _features; private readonly ICliHostEnvironment _hostEnvironment; private readonly ILogger _logger; @@ -36,6 +38,7 @@ internal abstract class PipelineCommandBase : BaseCommand protected static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", PublishCommandStrings.ProjectArgumentDescription); private readonly Option _outputPathOption; + private readonly Option? _startDebugSessionOption; protected static readonly Option s_logLevelOption = new("--log-level") { @@ -69,12 +72,13 @@ private static bool IsCompletionStateError(string completionState) => private static bool IsCompletionStateWarning(string completionState) => completionState == CompletionStates.CompletedWithWarning; - protected PipelineCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) + protected PipelineCommandBase(string name, string description, 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(name, description, features, updateNotifier, executionContext, interactionService, telemetry) { _runner = runner; _projectLocator = projectLocator; _hostEnvironment = hostEnvironment; + _configuration = configuration; _features = features; _projectFactory = projectFactory; _logger = logger; @@ -92,6 +96,15 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner Options.Add(s_includeExceptionDetailsOption); Options.Add(s_noBuildOption); + if (ExtensionHelper.IsExtensionHost(interactionService, out _, out _)) + { + _startDebugSessionOption = new Option("--start-debug-session") + { + Description = RunCommandStrings.StartDebugSessionArgumentDescription + }; + Options.Add(_startDebugSessionOption); + } + // In the publish and deploy commands we forward all unrecognized tokens // through to the underlying tooling when we launch the app host. TreatUnmatchedTokensAsErrors = false; @@ -102,11 +115,45 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner protected abstract string GetCanceledMessage(); protected abstract string GetProgressMessage(ParseResult parseResult); + /// + /// Gets command-specific arguments to forward when starting a debug session from the extension context. + /// Subclasses should override to include their specific positional arguments. + /// Unmatched tokens are always included automatically. + /// + protected virtual string[] GetCommandArgs(ParseResult parseResult) => []; + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + // If running in the extension context (Aspire terminal) without a debug session, + // intercept and tell VS Code to start a proper debug session for this command. + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); + if (ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _) + && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) + { + // Resolve the apphost project interactively before starting the debug session, + // so the user is prompted if needed and we can pass it along. + if (passedAppHostProjectFile is null) + { + var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken); + passedAppHostProjectFile = searchResult.SelectedProjectFile; + + if (passedAppHostProjectFile is null) + { + return ExitCodeConstants.FailedToFindProject; + } + } + + var commandArgs = GetCommandArgs(parseResult).Concat(parseResult.UnmatchedTokens).ToArray(); + + extensionInteractionService.DisplayConsolePlainText($"Detected aspire {Name} inside the Aspire extension, starting a debug session in VS Code..."); + await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, debug: true, new DebugSessionOptions { Command = Name, Args = commandArgs.Length > 0 ? commandArgs : null }); + return ExitCodeConstants.Success; + } + var debugMode = parseResult.GetValue(RootCommand.DebugOption); var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); var noBuild = parseResult.GetValue(s_noBuildOption); + var startDebugSession = _startDebugSessionOption is not null && parseResult.GetValue(_startDebugSessionOption); Task? pendingRun = null; PublishContext? publishContext = null; @@ -118,7 +165,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = Telemetry.StartDiagnosticActivity(this.Name); - var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken); var effectiveAppHostFile = searchResult.SelectedProjectFile; @@ -161,6 +207,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell BackchannelCompletionSource = backchannelCompletionSource, WorkingDirectory = ExecutionContext.WorkingDirectory, Debug = debugMode, + StartDebugSession = startDebugSession, NoBuild = noBuild }; @@ -187,6 +234,16 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell throw sdkException; } + // When running in extension context, the extension takes over apphost management. + // DotNetCliRunner returns Success immediately after delegating to LaunchAppHostAsync, + // so pendingRun completes before the backchannel is established. In this case, + // continue waiting for the backchannel rather than throwing. + if (!completedTask.IsFaulted && await pendingRun == ExitCodeConstants.Success + && ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) + { + return await backchannelCompletionSource.Task; + } + // Throw an error if the run completed without returning a backchannel. // Include possible error if the run task faulted. var innerException = completedTask.IsFaulted ? completedTask.Exception : null; @@ -358,7 +415,7 @@ public async Task ProcessPublishingActivitiesDebugAsync(IAsyncEnumerable

ProcessPublishingActivitiesDebugAsync(IAsyncEnumerable

"CRT", _ => "INF" }; - + // Make debug and trace logs more subtle var formattedMessage = logLevel.ToUpperInvariant() switch { @@ -378,7 +435,7 @@ public async Task ProcessPublishingActivitiesDebugAsync(IAsyncEnumerable

$"[[{timestamp}]] [dim][[{logPrefix}]] {message}[/]", _ => $"[[{timestamp}]] [[{logPrefix}]] {message}" }; - + InteractionService.DisplaySubtleMessage(formattedMessage, allowMarkup: true); } else @@ -491,21 +548,21 @@ public async Task ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumera { var logLevel = activity.Data.LogLevel ?? "Information"; var message = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data); - + // Add 3-letter prefix to message for consistency var logPrefix = logLevel.ToUpperInvariant() switch { "DEBUG" => "DBG", - "TRACE" => "TRC", + "TRACE" => "TRC", "INFORMATION" => "INF", "WARNING" => "WRN", "ERROR" => "ERR", "CRITICAL" => "CRT", _ => "INF" }; - + var prefixedMessage = $"[[{logPrefix}]] {message}"; - + // Map log levels to appropriate console logger methods switch (logLevel.ToUpperInvariant()) { diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 99016f379c8..81d54e8a07c 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -38,8 +39,8 @@ internal sealed class PublishCommand : PipelineCommandBase private readonly IPublishCommandPrompter _prompter; - public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfiguration configuration, ILogger logger, IAnsiConsole ansiConsole) + : base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole) { _prompter = prompter; } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 1f485315b30..303adac09fc 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -370,7 +370,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca } var effectiveAppHostFile = context.AppHostFile; - var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj"; + var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj" && IsValidSingleFileAppHost(effectiveAppHostFile); var env = new Dictionary(context.EnvironmentVariables); // Check compatibility for project-based apphosts @@ -436,7 +436,8 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, NoLaunchProfile = true, - NoExtensionLaunch = true + StartDebugSession = context.StartDebugSession, + NoExtensionLaunch = !context.StartDebugSession, }; if (isSingleFileAppHost) @@ -497,7 +498,7 @@ public async Task FindAndStopRunningInstanceAsync(FileInf } // Stop all running instances - var stopTasks = matchingSockets.Select(socketPath => + var stopTasks = matchingSockets.Select(socketPath => _runningInstanceManager.StopRunningInstanceAsync(socketPath, cancellationToken)); var results = await Task.WhenAll(stopTasks); return results.All(r => r) ? RunningInstanceResult.InstanceStopped : RunningInstanceResult.StopFailed; diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index a7f2b225e91..f713824ba8b 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -121,6 +121,11 @@ internal sealed class PublishContext ///

public bool Debug { get; init; } + /// + /// Gets whether to start a debug session in the extension for the AppHost. + /// + public bool StartDebugSession { get; init; } + /// /// Gets whether to skip building before running. /// From d90da68841614aeb706bad31a767d9afbbe45356 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:11:57 -0500 Subject: [PATCH 04/11] Support deploy/publish/do commands in AspireDebugSession --- extension/src/dcp/types.ts | 5 ++ extension/src/debugger/AspireDebugSession.ts | 48 +++++++++++++++----- extension/src/debugger/languages/cli.ts | 2 - 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 6061114135e..47c9f804a31 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -123,9 +123,14 @@ export interface AspireResourceExtendedDebugConfiguration extends vscode.DebugCo projectFile?: string; } +export type AspireCommandType = 'run' | 'deploy' | 'publish' | 'do'; + export interface AspireExtendedDebugConfiguration extends vscode.DebugConfiguration { program: string; debuggers?: AspireDebuggersConfiguration; + command?: AspireCommandType; + args?: string[]; + step?: string; } interface AspireDebuggersConfiguration { diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 434eb0cf793..99db3821642 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -74,12 +74,28 @@ export class AspireDebugSession implements vscode.DebugAdapter { }); const appHostPath = this._session.configuration.program as string; - const noDebug = !!message.arguments?.noDebug; + const command = this.configuration.command ?? 'run'; + const noDebug = !!message.arguments?.noDebug && command === 'run'; - const args = ['run']; + const args: string[] = [command]; + + // Append any additional command args forwarded from the CLI (e.g., step name for 'do', unmatched tokens) + const commandArgs = this.configuration.args; + if (commandArgs && commandArgs.length > 0) { + args.push(...commandArgs); + } + + // For 'do' with an explicit step (old CLI fallback), pass it as a positional argument + const step = this.configuration.step; + if (command === 'do' && step && !commandArgs?.length) { + args.push(step); + } + + // --start-debug-session tells the CLI to launch the AppHost via the extension with debugger attached if (!noDebug) { args.push('--start-debug-session'); } + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { args.push('--cli-wait-for-debugger'); } @@ -92,17 +108,19 @@ export class AspireDebugSession implements vscode.DebugAdapter { args.push('--debug'); } + const commandLabel = `aspire ${command}`; + if (isDirectory(appHostPath)) { this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - void this.spawnRunCommand(args, appHostPath, noDebug); + void this.spawnAspireCommand(args, appHostPath, noDebug, commandLabel); } else { this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); const workspaceFolder = path.dirname(appHostPath); - args.push('--project', appHostPath); - void this.spawnRunCommand(args, workspaceFolder, noDebug); + args.push('--apphost', appHostPath); + void this.spawnAspireCommand(args, workspaceFolder, noDebug, commandLabel); } } else if (message.command === 'disconnect' || message.command === 'terminate') { @@ -136,7 +154,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { } } - async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { + async spawnAspireCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean, commandLabel: string = 'aspire run') { const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => { if (client.debugSessionId === this.debugSessionId) { this._rpcClient = client; @@ -161,7 +179,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { }, errorCallback: (error) => { extensionLogOutputChannel.error(`Error spawning aspire process: ${error}`); - vscode.window.showErrorMessage(processExceptionOccurred(error.message, 'aspire run')); + vscode.window.showErrorMessage(processExceptionOccurred(error.message, commandLabel)); }, exitCallback: (code) => { this.sendMessageWithEmoji("🔚", processExitedWithCode(code ?? '?')); @@ -188,7 +206,8 @@ export class AspireDebugSession implements vscode.DebugAdapter { .replace('\r\n', '\n') .split('\n') .map(line => line.trim()) - .filter(line => line.length > 0); + // Filter empty lines and terminal progress bar escape sequences + .filter(line => line.length > 0 && !line.match(/^\u001b\]9;4;\d+\u001b\\$/)); } } @@ -205,12 +224,17 @@ export class AspireDebugSession implements vscode.DebugAdapter { try { this.createDebugAdapterTrackerCore(projectDebuggerExtension.debugAdapter); - extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${args.join(' ')}`); + // The CLI sends the full dotnet CLI args (e.g., ["run", "--no-build", "--project", "...", "--", ...appHostArgs]). + // Since we launch the apphost directly via the debugger (not via dotnet run), extract only the args after "--". + const separatorIndex = args.indexOf('--'); + const appHostArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : args; + + extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${appHostArgs.join(' ')}`); const appHostDebugSessionConfiguration = await createDebugSessionConfiguration( this.configuration, { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration, - args, + appHostArgs, environment, { debug, forceBuild: options.forceBuild, runId: '', debugSessionId: this.debugSessionId, isApphost: true, debugSession: this }, projectDebuggerExtension); @@ -224,7 +248,9 @@ export class AspireDebugSession implements vscode.DebugAdapter { const disposable = vscode.debug.onDidTerminateDebugSession(async session => { if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) { - const shouldRestart = !this._userInitiatedStop; + const command = this.configuration.command ?? 'run'; + // Only restart for 'run' — pipeline commands (do/deploy/publish) exit normally after completing. + const shouldRestart = !this._userInitiatedStop && command === 'run'; const config = this.configuration; // Always dispose the current Aspire debug session when the AppHost stops. this.dispose(); diff --git a/extension/src/debugger/languages/cli.ts b/extension/src/debugger/languages/cli.ts index 78827d3f6b0..1e112a953fe 100644 --- a/extension/src/debugger/languages/cli.ts +++ b/extension/src/debugger/languages/cli.ts @@ -20,8 +20,6 @@ export interface SpawnProcessOptions { export function spawnCliProcess(terminalProvider: AspireTerminalProvider, command: string, args?: string[], options?: SpawnProcessOptions): ChildProcessWithoutNullStreams { const workingDirectory = options?.workingDirectory ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - extensionLogOutputChannel.info(`Spawning CLI process: ${command} ${args?.join(" ")} (working directory: ${workingDirectory})`); - const env = {}; Object.assign(env, terminalProvider.createEnvironment(options?.debugSessionId, options?.noDebug, options?.noExtensionVariables)); From 07d9725af9d7448f955256267b07d9989b729342 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:12:02 -0500 Subject: [PATCH 05/11] Add deploy, publish, and do commands to VS Code extension --- extension/loc/xlf/aspire-vscode.xlf | 98 ++++++++++++++++++- extension/package.json | 16 +++ extension/package.nls.json | 4 + extension/src/commands/deploy.ts | 7 +- extension/src/commands/do.ts | 34 +++++++ extension/src/commands/publish.ts | 7 +- .../src/editor/AspireEditorCommandProvider.ts | 33 ++++++- extension/src/extension.ts | 8 +- extension/src/loc/strings.ts | 1 + 9 files changed, 186 insertions(+), 22 deletions(-) create mode 100644 extension/src/commands/do.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index ea53f379a7f..2a0cfd6469d 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -28,15 +28,33 @@ Aspire VSCode + + Aspire helps you build observable, production-ready distributed apps — orchestrating front ends, APIs, containers, and databases from code. It works with **any language**: C#, Python, JavaScript, Go, Java, and more. + Aspire launch configuration already exists in launch.json. Aspire terminal + + Aspire: Error + Aspire: Launch default apphost + + Aspire: Stopped + + + Aspire: {0} apphost + + + Aspire: {0} apphosts + + + Aspire: {0}/{1} running + Attempted to start unsupported resource type: {0}. @@ -73,6 +91,9 @@ Configure launch.json file + + Create a new project + DCP server not initialized - cannot forward debug output. @@ -124,6 +145,12 @@ Encountered an exception ({0}) while running the following command: {1}. + + Enter the pipeline step to execute + + + Error fetching Aspire apphost status. Click to open the Aspire panel. + Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update @@ -133,9 +160,15 @@ Error: {0} + + Execute pipeline step (aspire do) + Execute resource command + + Explore the dashboard + Extension context is not initialized. @@ -169,12 +202,24 @@ Failed to start project: {0}. + + Get started with Aspire + Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs. Initialize Aspire + + Install Aspire CLI (daily) + + + Install Aspire CLI (stable) + + + Install the Aspire CLI + Invalid launch configuration for {0}. @@ -199,15 +244,24 @@ Launching Aspire debug session using directory {0}: attempting to determine effective apphost... + + Learn how to create, run, and monitor distributed applications with Aspire. + New Aspire project + + Next steps + No No + + No Aspire apphosts running. Click to open the Aspire panel. + No C# Dev Kit build task found, defaulting to dotnet CLI. Maybe the workspace hasn't finished loading? @@ -280,36 +334,63 @@ Run Aspire apphost + + Run your app + Run {0} Running apphosts + + Scaffold a new Aspire project from a starter template. The template includes an apphost orchestrator, a sample API, and a web frontend. [Create new project](command:aspire-vscode.new) + See CLI installation instructions + + Select directory + + + Select file + Select the default apphost to launch when starting an Aspire debug session Start + + Start your Aspire app to launch all services and open the real-time dashboard. [Run apphost](command:aspire-vscode.runAppHost) [Debug apphost](command:aspire-vscode.debugAppHost) + Stop Stop + + The Aspire CLI command to execute (run, deploy, publish, or do) + + + The Aspire CLI creates, runs, and manages your applications. Install it using the commands in the panel, then verify your installation. [Verify installation](command:aspire-vscode.verifyCliInstalled) + The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI to get started. [Update Aspire CLI](command:aspire-vscode.updateSelf) [Refresh](command:aspire-vscode.refreshRunningAppHosts) + + The Aspire Dashboard shows your resources, endpoints, logs, traces, and metrics — all in one place. [Open dashboard](command:aspire-vscode.openDashboard) + The apphost is not compatible. Consider upgrading the apphost or Aspire CLI. The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH. + + The pipeline step name to execute when command is 'do' + This field is required. @@ -319,23 +400,32 @@ Update integrations + + Verify Aspire CLI installation + View logs Watch {0} ({1}) + + Welcome to Aspire + Yes Yes - - Select directory + + You're all set! Add integrations for databases, messaging, and cloud services, or deploy your app to production. [Add an integration](command:aspire-vscode.add) [Open Aspire docs](https://aspire.dev/docs/) - - Select file + + {0} Aspire apphost running. Click to open the Aspire panel. + + + {0} Aspire apphosts running. Click to open the Aspire panel. \ No newline at end of file diff --git a/extension/package.json b/extension/package.json index dd346f0386b..e76a2f068f2 100644 --- a/extension/package.json +++ b/extension/package.json @@ -85,6 +85,16 @@ "type": "object", "description": "%extension.debug.debuggers%", "default": {} + }, + "command": { + "type": "string", + "description": "%extension.debug.command%", + "enum": ["run", "deploy", "publish", "do"], + "default": "run" + }, + "step": { + "type": "string", + "description": "%extension.debug.step%" } } } @@ -156,6 +166,12 @@ "title": "%command.deploy%", "category": "Aspire" }, + { + "command": "aspire-vscode.do", + "enablement": "workspaceFolderCount > 0", + "title": "%command.do%", + "category": "Aspire" + }, { "command": "aspire-vscode.configureLaunchJson", "enablement": "workspaceFolderCount > 0", diff --git a/extension/package.nls.json b/extension/package.nls.json index f843a850fc6..6c438f2ef48 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -3,6 +3,8 @@ "extension.description": "Official Aspire extension for Visual Studio Code", "extension.debug.program": "Path to the apphost to run", "extension.debug.debuggers": "Configuration to apply when launching resources of specific types", + "extension.debug.command": "The Aspire CLI command to execute (run, deploy, publish, or do)", + "extension.debug.step": "The pipeline step name to execute when command is 'do'", "extension.debug.defaultConfiguration.name": "Aspire: Launch default apphost", "extension.debug.defaultConfiguration.description": "Launch the effective Aspire apphost in your workspace", "command.add": "Add an integration", @@ -13,6 +15,7 @@ "command.updateSelf": "Update Aspire CLI", "command.openTerminal": "Open Aspire terminal", "command.deploy": "Deploy app", + "command.do": "Execute pipeline step (aspire do)", "command.configureLaunchJson": "Configure launch.json file", "command.settings": "Extension settings", "command.openLocalSettings": "Open local Aspire settings", @@ -99,6 +102,7 @@ "aspire-vscode.strings.dismissLabel": "Dismiss", "aspire-vscode.strings.selectDirectoryTitle": "Select directory", "aspire-vscode.strings.selectFileTitle": "Select file", + "aspire-vscode.strings.enterPipelineStep": "Enter the pipeline step to execute", "aspire-vscode.strings.statusBarStopped": "Aspire: Stopped", "aspire-vscode.strings.statusBarError": "Aspire: Error", "aspire-vscode.strings.statusBarRunning": "Aspire: {0}/{1} running", diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index 1cad80f24cc..41116b75fe7 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,8 +1,5 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; -import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; -import { getAppHostArgs } from '../utils/appHostArgs'; -export async function deployCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { - const appHostArgs = await getAppHostArgs(editorCommandProvider); - await terminalProvider.sendAspireCommandToAspireTerminal('deploy', true, appHostArgs); +export async function deployCommand(editorCommandProvider: AspireEditorCommandProvider) { + await editorCommandProvider.tryExecuteDeployAppHost(false); } diff --git a/extension/src/commands/do.ts b/extension/src/commands/do.ts new file mode 100644 index 00000000000..4efc00bc1e1 --- /dev/null +++ b/extension/src/commands/do.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getConfigInfo } from '../utils/configInfoProvider'; +import { enterPipelineStep } from '../loc/strings'; + +export async function doCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { + const step = await resolveStep(terminalProvider); + if (step === undefined) { + return; + } + await editorCommandProvider.tryExecuteDoAppHost(false, step ?? undefined); +} + +/** + * Checks CLI capabilities to determine whether the CLI supports interactive pipeline prompting. + * Returns null if the CLI will handle prompting (new CLI with pipelines capability). + * Returns the user-provided step name if the CLI doesn't support interactive prompting (old CLI). + * Returns undefined if the user cancels. + */ +async function resolveStep(terminalProvider: AspireTerminalProvider): Promise { + const configInfo = await getConfigInfo(terminalProvider); + if (configInfo?.Capabilities?.includes('pipelines')) { + // New CLI: it will prompt for the step via interaction service + return null; + } + + // Old CLI or capabilities unavailable: prompt the user for a step + const step = await vscode.window.showInputBox({ + prompt: enterPipelineStep, + placeHolder: 'deploy', + }); + return step; +} diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 3df7b1594f6..40eace33ed1 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,8 +1,5 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; -import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; -import { getAppHostArgs } from '../utils/appHostArgs'; -export async function publishCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { - const appHostArgs = await getAppHostArgs(editorCommandProvider); - await terminalProvider.sendAspireCommandToAspireTerminal('publish', true, appHostArgs); +export async function publishCommand(editorCommandProvider: AspireEditorCommandProvider) { + await editorCommandProvider.tryExecutePublishAppHost(false); } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 7b51b862300..3cf0c5c4d09 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { noAppHostInWorkspace } from '../loc/strings'; import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; +import { AspireCommandType } from '../dcp/types'; export class AspireEditorCommandProvider implements vscode.Disposable { private _workspaceAppHostPath: string | null = null; @@ -74,8 +75,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable { return true; } - const firstNonEmptyLine = lines.find(line => line.trim().length > 0)?.trim(); - return firstNonEmptyLine === 'var builder = DistributedApplication.CreateBuilder(args);'; + return lines.some(line => line === 'var builder = DistributedApplication.CreateBuilder(args);'); } private onChangeAppHostPath(newPath: string | null) { @@ -130,19 +130,42 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } public async tryExecuteRunAppHost(noDebug: boolean): Promise { + await this.launchAspireDebugSession('run', noDebug); + } + + public async tryExecuteDeployAppHost(noDebug: boolean): Promise { + await this.launchAspireDebugSession('deploy', noDebug); + } + + public async tryExecutePublishAppHost(noDebug: boolean): Promise { + await this.launchAspireDebugSession('publish', noDebug); + } + + public async tryExecuteDoAppHost(noDebug: boolean, doStep?: string): Promise { + await this.launchAspireDebugSession('do', noDebug, doStep); + } + + private async launchAspireDebugSession(aspireCommand: AspireCommandType, noDebug: boolean, doStep?: string): Promise { const appHostToRun = await this.getAppHostPath(); if (!appHostToRun) { vscode.window.showErrorMessage(noAppHostInWorkspace); return; } - await vscode.debug.startDebugging(undefined, { + const config: vscode.DebugConfiguration = { type: 'aspire', - name: `Aspire: ${vscode.workspace.asRelativePath(appHostToRun)}`, + name: `Aspire ${aspireCommand}: ${vscode.workspace.asRelativePath(appHostToRun)}`, request: 'launch', program: appHostToRun, + command: aspireCommand, noDebug: noDebug - }); + }; + + if (doStep) { + config.step = doStep; + } + + await vscode.debug.startDebugging(undefined, config); } dispose() { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 085d27b6516..2bc9d56cf5c 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -7,6 +7,7 @@ import { newCommand } from './commands/new'; import { initCommand } from './commands/init'; import { deployCommand } from './commands/deploy'; import { publishCommand } from './commands/publish'; +import { doCommand } from './commands/do'; import { errorMessage } from './loc/strings'; import { extensionLogOutputChannel } from './utils/logging'; import { initializeTelemetry, sendTelemetryEvent } from './utils/telemetry'; @@ -57,8 +58,9 @@ export async function activate(context: vscode.ExtensionContext) { const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); const cliInitCommandRegistration = vscode.commands.registerCommand('aspire-vscode.init', () => tryExecuteCommand('aspire-vscode.init', terminalProvider, initCommand)); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, (tp) => deployCommand(tp, editorCommandProvider))); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, (tp) => publishCommand(tp, editorCommandProvider))); + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, () => deployCommand(editorCommandProvider))); + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider))); + const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (tp) => doCommand(tp, editorCommandProvider))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp) => updateCommand(tp, editorCommandProvider))); const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', () => tryExecuteCommand('aspire-vscode.updateSelf', terminalProvider, updateSelfCommand)); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, openTerminalCommand)); @@ -101,7 +103,7 @@ export async function activate(context: vscode.ExtensionContext) { const statusBarProvider = new AspireStatusBarProvider(appHostTreeProvider); context.subscriptions.push(statusBarProvider); - context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); + context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, cliDoCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); context.subscriptions.push(installCliStableRegistration, installCliDailyRegistration, verifyCliInstalledRegistration); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 44d743af0d9..0c3ae326d25 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -85,6 +85,7 @@ export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PAT export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); export const selectDirectoryTitle = vscode.l10n.t('Select directory'); export const selectFileTitle = vscode.l10n.t('Select file'); +export const enterPipelineStep = vscode.l10n.t('Enter the pipeline step to execute'); // Status bar strings export const statusBarStopped = vscode.l10n.t('Aspire: Stopped'); From a27ffbe0dcf514faab40f5f5224c77e3514fba6c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:12:08 -0500 Subject: [PATCH 06/11] Add DebugSessionOptions support and displayLines fallback in InteractionService --- extension/src/server/interactionService.ts | 41 ++++++++++++------ extension/src/server/rpcClient.ts | 2 +- .../test/rpc/interactionServiceTests.test.ts | 43 +++++++++++++++++-- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index 57379e71087..02044921aa0 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -9,7 +9,7 @@ import { applyTextStyle, formatText } from '../utils/strings'; import { extensionLogOutputChannel } from '../utils/logging'; import { AspireExtendedDebugConfiguration, EnvVar } from '../dcp/types'; import { AspireDebugSession } from '../debugger/AspireDebugSession'; -import { AnsiColors } from '../utils/AspireTerminalProvider'; +import { AnsiColors, AspireTerminal } from '../utils/AspireTerminalProvider'; import { isDirectory } from '../utils/io'; export interface IInteractionService { @@ -35,7 +35,7 @@ export interface IInteractionService { launchAppHost(projectFile: string, args: string[], environment: EnvVar[], debug: boolean): Promise; stopDebugging: () => void; notifyAppHostStartupCompleted: () => void; - startDebugSession: (workingDirectory: string, projectFile: string | null, debug: boolean) => Promise; + startDebugSession: (workingDirectory: string, projectFile: string | null, debug: boolean, options?: DebugSessionOptions) => Promise; writeDebugSessionMessage: (message: string, stdout: boolean, textStyle?: string) => void; } @@ -92,14 +92,21 @@ function getConsoleLineText(line: ConsoleLine): string { return line.line ?? line.Line ?? ''; } +type DebugSessionOptions = { + command?: string; + args?: string[]; +}; + export class InteractionService implements IInteractionService { private _getAspireDebugSession: () => AspireDebugSession | null; + private _getAspireTerminal?: () => AspireTerminal; private _rpcClient?: ICliRpcClient; private _progressNotifier: ProgressNotifier; - constructor(getAspireDebugSession: () => AspireDebugSession | null, rpcClient: ICliRpcClient) { + constructor(getAspireDebugSession: () => AspireDebugSession | null, rpcClient: ICliRpcClient, getAspireTerminal?: () => AspireTerminal) { this._getAspireDebugSession = getAspireDebugSession; + this._getAspireTerminal = getAspireTerminal; this._rpcClient = rpcClient; this._progressNotifier = new ProgressNotifier(this._rpcClient); } @@ -368,12 +375,18 @@ export class InteractionService implements IInteractionService { } async displayLines(lines: ConsoleLine[]) { - const displayText = lines.map(line => getConsoleLineText(line)).join('\n'); - lines.forEach(line => extensionLogOutputChannel.info(formatText(getConsoleLineText(line)))); - - // Open a new temp file with the displayText - const doc = await vscode.workspace.openTextDocument({ content: displayText, language: 'plaintext' }); - await vscode.window.showTextDocument(doc, { preview: false }); + const debugSession = this._getAspireDebugSession(); + const aspireTerminal = !debugSession ? this._getAspireTerminal?.() : undefined; + for (const line of lines) { + const text = getConsoleLineText(line); + const stream = line.stream ?? line.Stream; + extensionLogOutputChannel.info(formatText(text)); + if (debugSession) { + debugSession.sendMessage(text, true, stream !== 'stderr' ? 'stdout' : 'stderr'); + } else if (aspireTerminal) { + aspireTerminal.terminal.sendText(text, true); + } + } } displayCancellationMessage() { @@ -460,14 +473,18 @@ export class InteractionService implements IInteractionService { debugSession.notifyAppHostStartupCompleted(); } - async startDebugSession(workingDirectory: string, projectFile: string | null, debug: boolean): Promise { + async startDebugSession(workingDirectory: string, projectFile: string | null, debug: boolean, options?: DebugSessionOptions): Promise { this.clearProgressNotification(); + const command = options?.command ?? 'run'; + const debugConfiguration: AspireExtendedDebugConfiguration = { type: 'aspire', - name: `Aspire: ${getRelativePathToWorkspace(projectFile ?? workingDirectory)}`, + name: `Aspire ${command}: ${getRelativePathToWorkspace(projectFile ?? workingDirectory)}`, request: 'launch', program: projectFile ?? workingDirectory, + command: command as AspireExtendedDebugConfiguration['command'], + args: options?.args, noDebug: !debug, }; @@ -523,6 +540,6 @@ export function addInteractionServiceEndpoints(connection: MessageConnection, in connection.onRequest("launchAppHost", middleware('launchAppHost', async (projectFile: string, args: string[], environment: EnvVar[], debug: boolean) => interactionService.launchAppHost(projectFile, args, environment, debug))); connection.onRequest("stopDebugging", middleware('stopDebugging', interactionService.stopDebugging.bind(interactionService))); connection.onRequest("notifyAppHostStartupCompleted", middleware('notifyAppHostStartupCompleted', interactionService.notifyAppHostStartupCompleted.bind(interactionService))); - connection.onRequest("startDebugSession", middleware('startDebugSession', async (workingDirectory: string, projectFile: string | null, debug: boolean) => interactionService.startDebugSession(workingDirectory, projectFile, debug))); + connection.onRequest("startDebugSession", middleware('startDebugSession', async (workingDirectory: string, projectFile: string | null, debug: boolean, options?: DebugSessionOptions) => interactionService.startDebugSession(workingDirectory, projectFile, debug, options))); connection.onRequest("writeDebugSessionMessage", middleware('writeDebugSessionMessage', interactionService.writeDebugSessionMessage.bind(interactionService))); } diff --git a/extension/src/server/rpcClient.ts b/extension/src/server/rpcClient.ts index b6fa61a32ea..26b2d6e0b0b 100644 --- a/extension/src/server/rpcClient.ts +++ b/extension/src/server/rpcClient.ts @@ -31,7 +31,7 @@ export class RpcClient implements ICliRpcClient { this._messageConnection = messageConnection; this._connectionClosed = false; this.debugSessionId = debugSessionId; - this.interactionService = new InteractionService(getAspireDebugSession, this); + this.interactionService = new InteractionService(getAspireDebugSession, this, () => terminalProvider.getAspireTerminal()); this._messageConnection.onClose(() => { this._connectionClosed = true; diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts index a4f8279d7bf..f2b073aa7c9 100644 --- a/extension/src/test/rpc/interactionServiceTests.test.ts +++ b/extension/src/test/rpc/interactionServiceTests.test.ts @@ -247,16 +247,51 @@ suite('InteractionService endpoints', () => { test("displayLines endpoint", async () => { const stub = sinon.stub(extensionLogOutputChannel, 'info'); - const testInfo = await createTestRpcServer(); - const openTextDocumentStub = sinon.stub(vscode.workspace, 'openTextDocument'); + const sentMessages: { message: string; category: string }[] = []; + const mockDebugSession = { + sendMessage: (message: string, addNewLine: boolean, category: 'stdout' | 'stderr') => { + sentMessages.push({ message, category }); + } + } as unknown as AspireDebugSession; + const testInfo = await createTestRpcServer(null, () => mockDebugSession); testInfo.interactionService.displayLines([ { Stream: 'stdout', Line: 'line1' }, { Stream: 'stderr', Line: 'line2' } ]); - assert.ok(openTextDocumentStub.calledOnce, 'openTextDocument should be called once'); - openTextDocumentStub.restore(); + assert.strictEqual(sentMessages.length, 2, 'Should send two messages to debug session'); + assert.strictEqual(sentMessages[0].message, 'line1'); + assert.strictEqual(sentMessages[0].category, 'stdout'); + assert.strictEqual(sentMessages[1].message, 'line2'); + assert.strictEqual(sentMessages[1].category, 'stderr'); + stub.restore(); + }); + + test("displayLines without debug session falls back to Aspire terminal", async () => { + const stub = sinon.stub(extensionLogOutputChannel, 'info'); + const sentTexts: string[] = []; + const mockTerminal = { + terminal: { + sendText: (text: string, addNewLine: boolean) => { + sentTexts.push(text); + } + }, + dispose: () => {} + }; + const testInfo = await createTestRpcServer(null, () => null); + // Inject a mock terminal provider via the InteractionService constructor + (testInfo.interactionService as any)._getAspireTerminal = () => mockTerminal; + + testInfo.interactionService.displayLines([ + { Stream: 'stdout', Line: 'line1' }, + { Stream: 'stderr', Line: 'line2' } + ]); + + assert.strictEqual(sentTexts.length, 2, 'Should send two lines to Aspire terminal'); + assert.strictEqual(sentTexts[0], 'line1'); + assert.strictEqual(sentTexts[1], 'line2'); + stub.restore(); }); }); From e60b6ac1f3022be33a7f46646905c58d3b36b2c6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:12:12 -0500 Subject: [PATCH 07/11] Add SimplePipelines playground project --- Aspire.slnx | 3 + .../SimplePipelines.AppHost/AppHost.cs | 61 +++++++++++++++++++ .../Properties/launchSettings.json | 14 +++++ .../SimplePipelines.AppHost.csproj | 15 +++++ .../appsettings.Development.json | 8 +++ .../SimplePipelines.AppHost/appsettings.json | 9 +++ 6 files changed, 110 insertions(+) create mode 100644 playground/SimplePipelines/SimplePipelines.AppHost/AppHost.cs create mode 100644 playground/SimplePipelines/SimplePipelines.AppHost/Properties/launchSettings.json create mode 100644 playground/SimplePipelines/SimplePipelines.AppHost/SimplePipelines.AppHost.csproj create mode 100644 playground/SimplePipelines/SimplePipelines.AppHost/appsettings.Development.json create mode 100644 playground/SimplePipelines/SimplePipelines.AppHost/appsettings.json diff --git a/Aspire.slnx b/Aspire.slnx index 15240a498ee..d18652e2976 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -275,6 +275,9 @@ + + + diff --git a/playground/SimplePipelines/SimplePipelines.AppHost/AppHost.cs b/playground/SimplePipelines/SimplePipelines.AppHost/AppHost.cs new file mode 100644 index 00000000000..42d6c573aca --- /dev/null +++ b/playground/SimplePipelines/SimplePipelines.AppHost/AppHost.cs @@ -0,0 +1,61 @@ +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines; + +var builder = DistributedApplication.CreateBuilder(args); + +// A standalone step not related to deploy or publish +builder.Pipeline.AddStep("hello-world", async (context) => +{ + var task = await context.ReportingStep + .CreateTaskAsync("Running hello-world step", context.CancellationToken) + .ConfigureAwait(false); + + await using (task.ConfigureAwait(false)) + { + await Task.Delay(500, context.CancellationToken).ConfigureAwait(false); + + await task.CompleteAsync( + "Hello world step completed", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } +}); + +// A custom prerequisite for the deploy pipeline +builder.Pipeline.AddStep("custom-deploy-prereq", async (context) => +{ + var task = await context.ReportingStep + .CreateTaskAsync("Running custom deploy prerequisite", context.CancellationToken) + .ConfigureAwait(false); + + await using (task.ConfigureAwait(false)) + { + await Task.Delay(500, context.CancellationToken).ConfigureAwait(false); + + await task.CompleteAsync( + "Custom deploy prerequisite completed", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } +}, requiredBy: WellKnownPipelineSteps.Deploy); + +// A custom prerequisite for the publish pipeline +builder.Pipeline.AddStep("custom-publish-prereq", async (context) => +{ + var task = await context.ReportingStep + .CreateTaskAsync("Running custom publish prerequisite", context.CancellationToken) + .ConfigureAwait(false); + + await using (task.ConfigureAwait(false)) + { + await Task.Delay(500, context.CancellationToken).ConfigureAwait(false); + + await task.CompleteAsync( + "Custom publish prerequisite completed", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } +}, requiredBy: WellKnownPipelineSteps.Publish); + +builder.Build().Run(); diff --git a/playground/SimplePipelines/SimplePipelines.AppHost/Properties/launchSettings.json b/playground/SimplePipelines/SimplePipelines.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..db2fc268823 --- /dev/null +++ b/playground/SimplePipelines/SimplePipelines.AppHost/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/SimplePipelines/SimplePipelines.AppHost/SimplePipelines.AppHost.csproj b/playground/SimplePipelines/SimplePipelines.AppHost/SimplePipelines.AppHost.csproj new file mode 100644 index 00000000000..f230e6b6cf9 --- /dev/null +++ b/playground/SimplePipelines/SimplePipelines.AppHost/SimplePipelines.AppHost.csproj @@ -0,0 +1,15 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + diff --git a/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.Development.json b/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.json b/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/SimplePipelines/SimplePipelines.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} From 0c6a3fd89b0f2c52bd56b08d980759683f120523 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:12:26 -0500 Subject: [PATCH 08/11] Reduce noisy logging and validate single-file apphosts in ProjectLocator --- extension/src/utils/cliPath.ts | 6 ------ src/Aspire.Cli/Projects/ProjectLocator.cs | 9 +++++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts index 6290ac6d945..083b49a0627 100644 --- a/extension/src/utils/cliPath.ts +++ b/extension/src/utils/cliPath.ts @@ -151,7 +151,6 @@ export async function resolveCliPath(deps: CliPathDependencies = defaultDependen if (configuredPath && !defaultPaths.includes(configuredPath)) { const isValid = await deps.tryExecute(configuredPath); if (isValid) { - extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`); return { cliPath: configuredPath, available: true, source: 'configured' }; } @@ -162,8 +161,6 @@ export async function resolveCliPath(deps: CliPathDependencies = defaultDependen // 2. Check if CLI is on PATH const onPath = await deps.isOnPath(); if (onPath) { - extensionLogOutputChannel.info('Aspire CLI found on system PATH'); - // If we previously auto-set the path to a default install location, clear it // since PATH is now working if (defaultPaths.includes(configuredPath)) { @@ -177,8 +174,6 @@ export async function resolveCliPath(deps: CliPathDependencies = defaultDependen // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) const foundPath = await deps.findAtDefaultPath(); if (foundPath) { - extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`); - // Update the setting so future invocations use this path if (configuredPath !== foundPath) { extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); @@ -189,6 +184,5 @@ export async function resolveCliPath(deps: CliPathDependencies = defaultDependen } // 4. CLI not found anywhere - extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations'); return { cliPath: 'aspire', available: false, source: 'not-found' }; } diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index aef5bad337a..82bc96ac748 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -230,8 +230,13 @@ public async Task UseOrFindAppHostProjectFileAsync(F var handler = projectFactory.TryGetProject(projectFile); if (handler is not null) { - logger.LogDebug("Using {Language} apphost {ProjectFile}", handler.DisplayName, projectFile.FullName); - return new AppHostProjectSearchResult(projectFile, [projectFile]); + // The handler still may have matched an invalid single file apphost, so validate it before accepting as the selected project file + var validationResult = await handler.ValidateAppHostAsync(projectFile, cancellationToken); + if (validationResult.IsValid) + { + logger.LogDebug("Using {Language} apphost {ProjectFile}", handler.DisplayName, projectFile.FullName); + return new AppHostProjectSearchResult(projectFile, [projectFile]); + } } // If no handler matched, for .cs files check if we should search the parent directory From c0961100f12e0218d6d50a1cc5bcb0efc5a5d83d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:15:59 -0500 Subject: [PATCH 09/11] Prompt for missing pipeline step in DoCommand via interaction service --- src/Aspire.Cli/Commands/DeployCommand.cs | 4 ++-- src/Aspire.Cli/Commands/DoCommand.cs | 14 ++++++++++++-- src/Aspire.Cli/Commands/PipelineCommandBase.cs | 4 ++-- src/Aspire.Cli/Commands/PublishCommand.cs | 4 ++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 917e90fbfd7..e342a25c583 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -35,7 +35,7 @@ public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionSer protected override string OperationFailedPrefix => DeployCommandStrings.OperationFailedPrefix; protected override string GetOutputPathDescription() => DeployCommandStrings.OutputPathArgumentDescription; - protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult) + protected override Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken) { var baseArgs = new List { "--operation", "publish", "--step", "deploy" }; @@ -72,7 +72,7 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st baseArgs.AddRange(unmatchedTokens); - return [.. baseArgs]; + return Task.FromResult([.. baseArgs]); } protected override string GetCanceledMessage() => DeployCommandStrings.DeploymentCanceled; diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index cecaf7d2775..c7b49606e3d 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -24,9 +24,11 @@ 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 + Description = DoCommandStrings.StepArgumentDescription, + Arity = isExtensionHost ? ArgumentArity.ZeroOrOne : ArgumentArity.ExactlyOne }; Arguments.Add(_stepArgument); } @@ -41,11 +43,19 @@ protected override string[] GetCommandArgs(ParseResult parseResult) return !string.IsNullOrEmpty(step) ? [step] : []; } - protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult) + protected override async Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken) { var baseArgs = new List { "--operation", "publish" }; var step = parseResult.GetValue(_stepArgument); + if (string.IsNullOrEmpty(step) && ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) + { + step = await InteractionService.PromptForStringAsync( + DoCommandStrings.StepArgumentDescription, + required: true, + cancellationToken: cancellationToken); + } + if (!string.IsNullOrEmpty(step)) { baseArgs.AddRange(["--step", step]); diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index fd83f0732df..169637ef6c6 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -111,7 +111,7 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner } protected abstract string GetOutputPathDescription(); - protected abstract string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult); + protected abstract Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken); protected abstract string GetCanceledMessage(); protected abstract string GetProgressMessage(ParseResult parseResult); @@ -203,7 +203,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell AppHostFile = effectiveAppHostFile, OutputPath = fullyQualifiedOutputPath, EnvironmentVariables = env, - Arguments = GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens, parseResult), + Arguments = await GetRunArgumentsAsync(fullyQualifiedOutputPath, unmatchedTokens, parseResult, cancellationToken), BackchannelCompletionSource = backchannelCompletionSource, WorkingDirectory = ExecutionContext.WorkingDirectory, Debug = debugMode, diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 81d54e8a07c..93269112176 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -49,7 +49,7 @@ public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionSe protected override string OperationFailedPrefix => PublishCommandStrings.OperationFailedPrefix; protected override string GetOutputPathDescription() => PublishCommandStrings.OutputPathArgumentDescription; - protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult) + protected override Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken) { var baseArgs = new List { "--operation", "publish", "--step", "publish" }; @@ -80,7 +80,7 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st baseArgs.AddRange(unmatchedTokens); - return [.. baseArgs]; + return Task.FromResult([.. baseArgs]); } protected override string GetCanceledMessage() => InteractionServiceStrings.OperationCancelled; From 5f6a9a2d627862599c19be97c709c513f93e066f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:27:47 -0500 Subject: [PATCH 10/11] Update extension README with deploy, publish, and do command documentation --- extension/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/extension/README.md b/extension/README.md index 7d8442ae1e8..0f2f490c3c3 100644 --- a/extension/README.md +++ b/extension/README.md @@ -15,6 +15,7 @@ The extension adds the following commands to VS Code: | Aspire: Update integrations | Update hosting integrations and Aspire SDK in the apphost. | | Aspire: Publish deployment artifacts | Generate deployment artifacts for an Aspire apphost. | | Aspire: Deploy app | Deploy the contents of an Aspire apphost to its defined deployment targets. | +| Aspire: Execute pipeline step (aspire do) | Execute a specific pipeline step and its dependencies. | | Aspire: Configure launch.json file | Add the default Aspire debugger launch configuration to your workspace's `launch.json`. | | Aspire: Extension settings | Open Aspire extension settings. | | Aspire: Open local Aspire settings | Open the local `.aspire/settings.json` file for the current workspace. | @@ -43,6 +44,31 @@ To run and debug your Aspire application, add an entry to the workspace `launch. } ``` +You can also use the `command` property to run deploy, publish, or pipeline step commands with the debugger attached: + +```json +{ + "type": "aspire", + "request": "launch", + "name": "Aspire: Deploy MyAppHost", + "program": "${workspaceFolder}/MyAppHost/MyAppHost.csproj", + "command": "deploy" +} +``` + +Supported values for `command` are `run` (default), `deploy`, `publish`, and `do`. When using `do`, you can optionally set the `step` property to specify the pipeline step to execute: + +```json +{ + "type": "aspire", + "request": "launch", + "name": "Aspire: Run pipeline step", + "program": "${workspaceFolder}/MyAppHost/MyAppHost.csproj", + "command": "do", + "step": "my-custom-step" +} +``` + ## Requirements ### Aspire CLI From 23c00580a52b9c4950b66ef59eadf44a13b45440 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 3 Mar 2026 16:41:52 -0500 Subject: [PATCH 11/11] Add clarifying comment on NoExtensionLaunch flag --- src/Aspire.Cli/Projects/DotNetAppHostProject.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 303adac09fc..af3b9f6a92f 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -437,6 +437,8 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca StandardErrorCallback = runOutputCollector.AppendError, NoLaunchProfile = true, StartDebugSession = context.StartDebugSession, + // When not starting a debug session, prevent DotNetCliRunner from delegating the + // apphost launch to the extension — pipeline commands should run the apphost directly. NoExtensionLaunch = !context.StartDebugSession, };