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/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 diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 935b07ee76f..4b741264196 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}. @@ -76,6 +94,9 @@ Configure launch.json file + + Create a new project + DCP server not initialized - cannot forward debug output. @@ -127,6 +148,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 @@ -136,9 +163,15 @@ Error: {0} + + Execute pipeline step (aspire do) + Execute resource command + + Explore the dashboard + Extension context is not initialized. @@ -172,12 +205,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}. @@ -211,15 +256,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? @@ -292,30 +346,54 @@ 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. @@ -325,6 +403,9 @@ 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. @@ -334,6 +415,8 @@ Update integrations + + Verify Aspire CLI installation Use the system default browser (cannot auto-close). @@ -343,17 +426,23 @@ 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 8a1b9392351..fcec1af019c 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 55197b32c1f..e16757faed7 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", @@ -105,6 +108,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/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 5a0f340bbac..b38dd770932 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -78,12 +78,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'); } @@ -96,17 +112,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') { @@ -140,7 +158,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; @@ -165,7 +183,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 ?? '?')); @@ -192,7 +210,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\\$/)); } } @@ -209,12 +228,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); @@ -228,7 +252,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)); 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 78045a3d6fc..c0e15ca5e23 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -86,6 +86,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'); diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index 6f8f2abf9c3..66ff29428a6 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -8,8 +8,8 @@ import { ProgressNotifier } from './progressNotifier'; import { applyTextStyle, formatText } from '../utils/strings'; import { extensionLogOutputChannel } from '../utils/logging'; import { AspireExtendedDebugConfiguration, EnvVar } from '../dcp/types'; +import { AnsiColors, AspireTerminal } from '../utils/AspireTerminalProvider'; import { AspireDebugSession, DashboardBrowserType } from '../debugger/AspireDebugSession'; -import { AnsiColors } from '../utils/AspireTerminalProvider'; import { isDirectory } from '../utils/io'; export interface IInteractionService { @@ -36,7 +36,7 @@ export interface IInteractionService { stopDebugging: () => void; closeDashboard: () => 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; } @@ -93,14 +93,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); } @@ -374,12 +381,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() { @@ -466,14 +479,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, }; @@ -536,6 +553,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(); }); }); 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/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/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" + } + } +} 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/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/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 765925e7047..e342a25c583 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") { @@ -34,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" }; @@ -71,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 f4d3bee3486..c7b49606e3d 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,12 +21,14 @@ 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) { + 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); } @@ -34,11 +37,25 @@ public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService protected override string OperationFailedPrefix => DoCommandStrings.OperationFailedPrefix; protected override string GetOutputPathDescription() => DoCommandStrings.OutputPathArgumentDescription; - protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult) + protected override string[] GetCommandArgs(ParseResult parseResult) + { + var step = parseResult.GetValue(_stepArgument); + return !string.IsNullOrEmpty(step) ? [step] : []; + } + + 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 2d19ccb94d3..169637ef6c6 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,21 +96,64 @@ 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; } 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); + /// + /// 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; @@ -157,10 +203,11 @@ 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, + 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..93269112176 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; } @@ -48,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" }; @@ -79,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; 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/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 1f485315b30..af3b9f6a92f 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,10 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, NoLaunchProfile = true, - NoExtensionLaunch = 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, }; if (isSingleFileAppHost) @@ -497,7 +500,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. /// 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 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]; } 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;