diff --git a/.gitignore b/.gitignore
index a82ffd06875..60371d10272 100644
--- a/.gitignore
+++ b/.gitignore
@@ -186,6 +186,7 @@ tests/PolyglotAppHosts/**/Go/apphost.exe
#Aspire CLI
.aspire/
+.modules/
# Release notes automation output
tools/ReleaseNotes/analysis-output/
diff --git a/extension/gulpfile.js b/extension/gulpfile.js
index 77d2a45dc1c..4e504a85808 100644
--- a/extension/gulpfile.js
+++ b/extension/gulpfile.js
@@ -1,4 +1,4 @@
-const { execSync } = require('child_process');
+const { execFileSync } = require('child_process');
const gulp = require('gulp');
const path = require('path');
const fs = require('fs');
@@ -40,7 +40,8 @@ const exportL10n = (done) => {
// Step 1: Export strings from source files to bundle.l10n.json
console.log('Exporting l10n strings from source files...');
- execSync(`npx @vscode/l10n-dev export --outDir ${l10nDir} ./src`, {
+ const l10nDevCli = path.join(path.dirname(require.resolve('@vscode/l10n-dev/package.json')), 'dist', 'cli.js');
+ execFileSync(process.execPath, [l10nDevCli, 'export', '--outDir', l10nDir, './src'], {
cwd: rootDir,
stdio: 'inherit'
});
diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index 24c1e3d3d62..d8c04afb042 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -268,6 +268,12 @@
Get started with Aspire
+
+ Go
+
+
+ Go: {0}
+
Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.
@@ -541,6 +547,9 @@
This field is required.
+
+ Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000.
+
Unable to add folder to workspace: {0}
diff --git a/extension/package.json b/extension/package.json
index a3181cad200..e3106820e0d 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -784,6 +784,13 @@
"description": "%configuration.aspire.globalAppHostsPollingInterval%",
"scope": "window"
},
+ "aspire.appHostDiscoveryTimeoutMs": {
+ "type": "number",
+ "default": 30000,
+ "minimum": 1000,
+ "description": "%configuration.aspire.appHostDiscoveryTimeoutMs%",
+ "scope": "window"
+ },
"aspire.enableCodeLens": {
"type": "boolean",
"default": true,
diff --git a/extension/package.nls.json b/extension/package.nls.json
index b889ec3b2f4..32ae40fa38b 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -42,6 +42,7 @@
"configuration.aspire.enableDebugConfigEnvironmentLogging": "Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.",
"configuration.aspire.registerMcpServerInWorkspace": "Whether to register the Aspire MCP server when a workspace is open.",
"configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running AppHosts (used in global view). Minimum: 1000.",
+ "configuration.aspire.appHostDiscoveryTimeoutMs": "Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000.",
"configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in AppHost files.",
"configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in AppHost files.",
"configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.",
diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts
index 643db6ed958..24794d8e256 100644
--- a/extension/src/debugger/AspireDebugConfigurationProvider.ts
+++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts
@@ -1,22 +1,40 @@
import * as vscode from 'vscode';
import { defaultConfigurationName } from '../loc/strings';
+import { AppHostDiscoveryService, getDebugTargetForCandidate } from '../utils/appHostDiscovery';
+import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery';
import { checkCliAvailableOrRedirect } from '../utils/workspace';
+import { extensionLogOutputChannel } from '../utils/logging';
export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
+ constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) {
+ }
+
async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise {
if (folder === undefined) {
return [];
}
- const configurations: vscode.DebugConfiguration[] = [];
- configurations.push({
+ const activeEditor = vscode.window.activeTextEditor;
+ if (!activeEditor) {
+ return [this.createDefaultConfiguration(folder)];
+ }
+
+ const activeEditorFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri);
+ if (activeEditorFolder?.uri.toString() !== folder.uri.toString()) {
+ return [this.createDefaultConfiguration(folder)];
+ }
+
+ const candidate = await this.tryFindCandidateForEditorFile(activeEditor.document.uri.fsPath, folder);
+ if (!candidate) {
+ return [this.createDefaultConfiguration(folder)];
+ }
+
+ return [{
type: 'aspire',
request: 'launch',
name: defaultConfigurationName,
- program: '${workspaceFolder}'
- });
-
- return configurations;
+ program: getDebugTargetForCandidate(candidate)
+ }];
}
async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise {
@@ -44,4 +62,41 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
return config;
}
+
+ async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise {
+ if (typeof config.program === 'string') {
+ config.program = await this.resolveDebugTarget(config.program, folder);
+ }
+
+ return config;
+ }
+
+ private async tryFindCandidateForEditorFile(filePath: string, folder: vscode.WorkspaceFolder): Promise {
+ try {
+ return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath, folder);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Failed to discover AppHost for debug configuration file ${filePath}: ${error}`);
+ return undefined;
+ }
+ }
+
+ private async resolveDebugTarget(filePath: string, folder: vscode.WorkspaceFolder | undefined): Promise {
+ try {
+ return await this._appHostDiscoveryService.resolveDebugTarget(filePath, folder);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Failed to resolve AppHost debug target ${filePath}: ${error}`);
+ return filePath;
+ }
+ }
+
+ private createDefaultConfiguration(folder: vscode.WorkspaceFolder): vscode.DebugConfiguration {
+ return {
+ type: 'aspire',
+ request: 'launch',
+ name: defaultConfigurationName,
+ program: folder.uri.fsPath
+ };
+ }
}
diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts
index 1970d4f172d..5c041cdfade 100644
--- a/extension/src/editor/AspireEditorCommandProvider.ts
+++ b/extension/src/editor/AspireEditorCommandProvider.ts
@@ -3,37 +3,20 @@ import * as path from 'path';
import { noAppHostInWorkspace } from '../loc/strings';
import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions';
import { AspireCommandType } from '../dcp/types';
-import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes';
+import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery';
+import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery';
+import { extensionLogOutputChannel } from '../utils/logging';
export class AspireEditorCommandProvider implements vscode.Disposable {
- private _workspaceAppHostPath: string | null = null;
- private _workspaceSettingsJsonWatchers: Map = new Map();
private _disposables: vscode.Disposable[] = [];
- constructor() {
- // Watch for both aspire.config.json and .aspire/settings.json changes
- const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file('/'));
- if (workspaceFolder) {
- this._workspaceSettingsJsonWatchers.set(workspaceFolder, this.watchWorkspaceForAppHostPathChanges(workspaceFolder, this.onChangeAppHostPath.bind(this)));
- }
- else {
- vscode.workspace.workspaceFolders?.forEach(folder => {
- this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this)));
- });
- }
-
- // As additional workspace folders are added/removed, we need to watch/unwatch them too
+ constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) {
this._disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(event => {
- event.added.forEach(folder => {
- this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this)));
- });
- event.removed.forEach(folder => {
- const disposable = this._workspaceSettingsJsonWatchers.get(folder);
- if (disposable) {
- disposable.dispose();
- this._workspaceSettingsJsonWatchers.delete(folder);
- }
- });
+ void this.updateWorkspaceAppHostContext();
+ }));
+
+ this._disposables.push(this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => {
+ void this.processActiveDocumentForWorkspace(workspaceFolder);
}));
this._disposables.push(vscode.window.onDidChangeActiveTextEditor(async (editor) => {
@@ -54,160 +37,81 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
}
}
+ private async processActiveDocumentForWorkspace(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ const activeDocument = vscode.window.activeTextEditor?.document;
+ if (!activeDocument) {
+ await this.updateWorkspaceAppHostContext();
+ return;
+ }
+
+ const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeDocument.uri);
+ if (activeWorkspaceFolder?.uri.toString() === workspaceFolder.uri.toString()) {
+ await this.processDocument(activeDocument);
+ }
+ }
+
public async processDocument(document: vscode.TextDocument): Promise {
const fileExtension = path.extname(document.uri.fsPath).toLowerCase();
const isSupportedFile = getResourceDebuggerExtensions().some(extension => extension.getSupportedFileTypes().includes(fileExtension));
vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile);
-
- if (await this.isAppHostFile(document.uri.fsPath)) {
- vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', true);
- }
- else {
- vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', false);
- }
+ vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', await this.tryFindCandidateForEditorFile(document.uri.fsPath) !== undefined);
+ await this.updateWorkspaceAppHostContext();
}
- private async isAppHostFile(filePath: string): Promise {
- const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString());
- const lines = fileText.split(/\r?\n/);
-
- // C# apphost detection
- if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) {
- return true;
- }
-
- if (lines.some(line => line === 'var builder = DistributedApplication.CreateBuilder(args);')) {
- return true;
+ private async updateWorkspaceAppHostContext(): Promise {
+ const workspaceFolder = vscode.window.activeTextEditor
+ ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)
+ : vscode.workspace.workspaceFolders?.[0];
+ if (!workspaceFolder) {
+ vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', false);
+ return;
}
- // TypeScript/JavaScript apphost detection
- const ext = path.extname(filePath).toLowerCase();
- if (['.ts', '.js', '.mts', '.mjs'].includes(ext)) {
- // Match both the new `.aspire/modules/aspire` import path and the legacy
- // `.modules/aspire` path so legacy stable-channel TypeScript AppHosts that
- // still import from `./.modules/aspire.js` continue to expose Run/Debug
- // commands via the `aspire.fileIsAppHost` context.
- if (lines.some(line => /import\s+.*createBuilder.*from\s+['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) {
- return true;
- }
+ const appHostPath = await this.trySelectWorkspaceAppHostPath(workspaceFolder);
+ vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', appHostPath !== undefined);
+ }
- if (lines.some(line => /require\s*\(['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) {
- return true;
+ /**
+ * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available.
+ */
+ public async getAppHostPath(): Promise {
+ if (vscode.window.activeTextEditor) {
+ const candidate = await this.tryFindCandidateForEditorFile(vscode.window.activeTextEditor.document.uri.fsPath);
+ if (candidate) {
+ return getDebugTargetForCandidate(candidate);
}
}
- return false;
- }
+ const workspaceFolder = vscode.window.activeTextEditor
+ ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)
+ : vscode.workspace.workspaceFolders?.[0];
+ if (!workspaceFolder) {
+ return null;
+ }
- private onChangeAppHostPath(newPath: string | null) {
- vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', !!newPath);
- this._workspaceAppHostPath = newPath;
+ return await this.trySelectWorkspaceAppHostPath(workspaceFolder) ?? null;
}
- private watchWorkspaceForAppHostPathChanges(workspaceFolder: vscode.WorkspaceFolder, onChangeAppHostPath: (newPath: string | null) => void): vscode.Disposable {
- const disposables: vscode.Disposable[] = [];
-
- // Watch new format: aspire.config.json in workspace root
- const newFormatWatcher = vscode.workspace.createFileSystemWatcher(
- new vscode.RelativePattern(workspaceFolder, aspireConfigFileName)
- );
- newFormatWatcher.onDidCreate(async uri => readConfigFileAndInvokeCallback(uri));
- newFormatWatcher.onDidChange(uri => readConfigFileAndInvokeCallback(uri));
- newFormatWatcher.onDidDelete(() => {
- // When new format is deleted, try to fall back to legacy format
- const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json');
- vscode.workspace.fs.stat(legacyUri).then(
- () => readConfigFileAndInvokeCallback(legacyUri),
- () => onChangeAppHostPath(null)
- );
- });
- disposables.push(newFormatWatcher);
-
- // Watch legacy format: .aspire/settings.json
- const legacyWatcher = vscode.workspace.createFileSystemWatcher(
- new vscode.RelativePattern(workspaceFolder, '.aspire/settings.json')
- );
- legacyWatcher.onDidCreate(async uri => {
- // Only use legacy if new format doesn't exist
- const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
- try {
- await vscode.workspace.fs.stat(newFormatUri);
- // New format exists, ignore legacy change
- } catch {
- readConfigFileAndInvokeCallback(uri);
- }
- });
- legacyWatcher.onDidChange(async uri => {
- const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
- try {
- await vscode.workspace.fs.stat(newFormatUri);
- // New format exists, ignore legacy change
- } catch {
- readConfigFileAndInvokeCallback(uri);
- }
- });
- legacyWatcher.onDidDelete(() => {
- // Legacy deleted; check if new format exists
- const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
- vscode.workspace.fs.stat(newFormatUri).then(
- () => readConfigFileAndInvokeCallback(newFormatUri),
- () => onChangeAppHostPath(null)
- );
- });
- disposables.push(legacyWatcher);
-
- // Read the initial value, preferring new format over legacy
- const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
- const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json');
- vscode.workspace.fs.stat(newFormatUri).then(
- () => readConfigFileAndInvokeCallback(newFormatUri),
- () => {
- // New format doesn't exist, try legacy
- vscode.workspace.fs.stat(legacyUri).then(
- () => readConfigFileAndInvokeCallback(legacyUri),
- () => onChangeAppHostPath(null)
- );
- }
- );
-
- return {
- dispose() {
- disposables.forEach(d => d.dispose());
- }
- };
-
- async function readConfigFileAndInvokeCallback(uri: vscode.Uri) {
- try {
- const json = await readJsonFile(uri);
- const appHostRelativePath = getAppHostPathFromConfig(json);
- if (!appHostRelativePath) {
- onChangeAppHostPath(null);
- return;
- }
-
- // Resolve relative path based on the config file's directory
- const configDir = path.dirname(uri.fsPath);
- const appHostPath = path.isAbsolute(appHostRelativePath)
- ? appHostRelativePath
- : path.join(configDir, appHostRelativePath);
- onChangeAppHostPath(appHostPath);
- }
- catch {
- onChangeAppHostPath(null);
- }
+ private async tryFindCandidateForEditorFile(filePath: string): Promise {
+ try {
+ return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Failed to discover AppHost for editor file ${filePath}: ${error}`);
+ return undefined;
}
}
- /**
- * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available.
- */
- public async getAppHostPath(): Promise {
- if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
- return vscode.window.activeTextEditor.document.uri.fsPath;
+ private async trySelectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ try {
+ const appHosts = await this._appHostDiscoveryService.discover(workspaceFolder);
+ return await selectWorkspaceAppHostPath(workspaceFolder, appHosts);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Failed to discover AppHost candidates for workspace ${workspaceFolder.uri.fsPath}: ${error}`);
+ return undefined;
}
-
- return this._workspaceAppHostPath;
}
public async tryExecuteRunAppHost(noDebug: boolean): Promise {
@@ -251,6 +155,5 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
dispose() {
this._disposables.forEach(disposable => disposable.dispose());
- this._workspaceSettingsJsonWatchers.forEach(disposable => disposable.dispose());
}
}
diff --git a/extension/src/extension.ts b/extension/src/extension.ts
index b4620ee821f..eb7ab99ee8b 100644
--- a/extension/src/extension.ts
+++ b/extension/src/extension.ts
@@ -38,6 +38,7 @@ import { readGitCommitSha } from './utils/versionInfo';
import { collectResourceCommandArguments } from './views/ResourceCommandArguments';
import { createResourceCommandArgumentLoader } from './views/ResourceCommandArgumentsLoader';
import { ResourceCommandJson } from './views/AppHostDataRepository';
+import { AppHostDiscoveryService } from './utils/appHostDiscovery';
let aspireExtensionContext = new AspireExtensionContext();
@@ -61,7 +62,10 @@ export async function activate(context: vscode.ExtensionContext) {
terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo;
terminalProvider.closeAllOpenAspireTerminals();
- const editorCommandProvider = new AspireEditorCommandProvider();
+ const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider);
+ context.subscriptions.push(appHostDiscoveryService);
+
+ const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService);
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));
@@ -85,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) {
const verifyCliInstalledRegistration = vscode.commands.registerCommand('aspire-vscode.verifyCliInstalled', verifyCliInstalledCommand);
// Aspire panel - running app hosts tree view
- const dataRepository = new AppHostDataRepository(terminalProvider);
+ const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService);
const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, context.globalState);
const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', {
treeDataProvider: appHostTreeProvider,
@@ -195,7 +199,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration);
context.subscriptions.push(installCliStableRegistration, installCliDailyRegistration, verifyCliInstalledRegistration);
- const debugConfigProvider = new AspireDebugConfigurationProvider();
+ const debugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService);
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic)
);
@@ -219,7 +223,7 @@ export async function activate(context: vscode.ExtensionContext) {
const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true);
const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace);
const appHostDisposablePromise = checkForExistingAppHostPathInWorkspace(
- terminalProvider,
+ appHostDiscoveryService,
getEnableSettingsFileCreationPromptOnStartup,
setEnableSettingsFileCreationPromptOnStartup
);
diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts
index 7513438ea57..dd1b6031a6d 100644
--- a/extension/src/test/appHostDataRepository.test.ts
+++ b/extension/src/test/appHostDataRepository.test.ts
@@ -50,6 +50,7 @@ suite('AppHostDataRepository', () => {
let getCliPathStub: sinon.SinonStub;
let spawnStub: sinon.SinonStub;
let defaultWorkspaceFoldersStub: sinon.SinonStub;
+ let findFilesStub: sinon.SinonStub;
setup(() => {
subscriptions = [];
@@ -58,11 +59,13 @@ suite('AppHostDataRepository', () => {
spawnStub = sinon.stub(cliModule, 'spawnCliProcess');
spawnStub.callsFake(() => new TestChildProcess());
defaultWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined);
+ findFilesStub = sinon.stub(vscode.workspace, 'findFiles').resolves([]);
});
teardown(() => {
spawnStub.restore();
getCliPathStub.restore();
+ findFilesStub.restore();
if (defaultWorkspaceFoldersStub.restore) {
defaultWorkspaceFoldersStub.restore();
}
@@ -410,7 +413,7 @@ suite('AppHostDataRepository', () => {
'/workspace/samples/Store/AppHost.csproj',
],
}));
- await waitForMicrotasks();
+ await waitForAppHostDiscovery();
assert.strictEqual(repository.viewMode, 'workspace');
assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj');
@@ -439,6 +442,12 @@ suite('AppHostDataRepository', () => {
path: configuredAppHostPath,
},
}));
+ findFilesStub.callsFake(async (include: vscode.GlobPattern) => {
+ const pattern = typeof include === 'string' ? include : include.pattern;
+ return pattern.endsWith('aspire.config.json')
+ ? [vscode.Uri.file(path.join(workspaceRoot, 'aspire.config.json'))]
+ : [];
+ });
let getAppHostsLineCallback: ((line: string) => void) | undefined;
let psOptions: any;
@@ -677,7 +686,7 @@ suite('AppHostDataRepository', () => {
selected_project_file: '/workspace/apphost/apphost.cs',
all_project_file_candidates: ['/workspace/apphost/apphost.cs'],
}));
- await waitForMicrotasks();
+ await waitForAppHostDiscovery();
assert.ok(psOptions);
assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']);
@@ -751,7 +760,7 @@ suite('AppHostDataRepository', () => {
},
],
}));
- await waitForMicrotasks();
+ await waitForAppHostDiscovery();
assert.ok(psOptions);
psOptions.lineCallback(JSON.stringify([
@@ -822,7 +831,7 @@ suite('AppHostDataRepository', () => {
},
],
}));
- await waitForMicrotasks();
+ await waitForAppHostDiscovery();
assert.ok(psOptions);
psOptions.lineCallback(JSON.stringify([]));
@@ -877,7 +886,7 @@ suite('AppHostDataRepository', () => {
selected_project_file: '/workspace/labs/ops/apphost.cs',
all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'],
}));
- await waitForMicrotasks();
+ await waitForAppHostDiscovery();
assert.ok(describeOptions);
assert.ok(psOptions);
@@ -1310,7 +1319,7 @@ async function waitForAppHostDiscovery(): Promise {
}
async function waitForCondition(condition: () => boolean, message: string): Promise {
- for (let i = 0; i < 20; i++) {
+ for (let i = 0; i < 100; i++) {
if (condition()) {
return;
}
diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts
new file mode 100644
index 00000000000..dfeadd44878
--- /dev/null
+++ b/extension/src/test/appHostDiscovery.test.ts
@@ -0,0 +1,473 @@
+///
+
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as vscode from 'vscode';
+import * as cliModule from '../debugger/languages/cli';
+import { AppHostDiscoveryService, findCandidateForEditorFile, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery';
+import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
+
+suite('AppHost discovery', () => {
+ test('resolves SDK-style C# AppHost source file to discovered project candidate', () => {
+ const appHostProjectPath = buildPath('workspace', 'AppHost', 'AppHost.csproj');
+ const programPath = buildPath('workspace', 'AppHost', 'Program.cs');
+
+ const candidate = findCandidateForEditorFile(programPath, [{
+ path: appHostProjectPath,
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate?.path, appHostProjectPath);
+ assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostProjectPath);
+ });
+
+ test('keeps file-based C# AppHost candidate as source file', () => {
+ const appHostPath = buildPath('workspace', 'AppHost', 'apphost.cs');
+
+ const candidate = findCandidateForEditorFile(appHostPath, [{
+ path: appHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate?.path, appHostPath);
+ assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath);
+ });
+
+ test('keeps TypeScript AppHost candidate as source file', () => {
+ const appHostPath = buildPath('workspace', 'AppHost', 'apphost.ts');
+
+ const candidate = findCandidateForEditorFile(appHostPath, [{
+ path: appHostPath,
+ language: 'typescript/nodejs',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate?.path, appHostPath);
+ assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath);
+ });
+
+ test('returns undefined when no discovered candidate contains C# source file', () => {
+ const programPath = buildPath('workspace', 'Web', 'Program.cs');
+
+ const candidate = findCandidateForEditorFile(programPath, [{
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate, undefined);
+ });
+
+ test('does not map source file to non-C# project candidate', () => {
+ const programPath = buildPath('workspace', 'AppHost', 'Program.cs');
+
+ const candidate = findCandidateForEditorFile(programPath, [{
+ path: buildPath('workspace', 'AppHost', 'apphost.ts'),
+ language: 'typescript/nodejs',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate, undefined);
+ });
+
+ test('maps C# file in AppHost project directory to discovered project candidate', () => {
+ const helperPath = buildPath('workspace', 'AppHost', 'Helper.cs');
+
+ const candidate = findCandidateForEditorFile(helperPath, [{
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate?.path, buildPath('workspace', 'AppHost', 'AppHost.csproj'));
+ });
+
+ test('does not map C# file under bin directory to discovered project candidate', () => {
+ const generatedPath = buildPath('workspace', 'AppHost', 'bin', 'Debug', 'net10.0', 'Generated.cs');
+
+ const candidate = findCandidateForEditorFile(generatedPath, [{
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(candidate, undefined);
+ });
+
+ suite('service', () => {
+ let sandbox: sinon.SinonSandbox;
+ let findFilesStub: sinon.SinonStub;
+
+ setup(() => {
+ sandbox = sinon.createSandbox();
+ findFilesStub = sandbox.stub(vscode.workspace, 'findFiles').resolves([]);
+ });
+
+ teardown(() => {
+ sandbox.restore();
+ });
+
+ test('does not force refresh discovery after cached negative editor lookup', async () => {
+ stubFileSystemWatchers(sandbox);
+ const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ options?.stdoutCallback?.(JSON.stringify([{
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ }]));
+ options?.exitCallback?.(0);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+
+ try {
+ const workspaceFolder = makeWorkspaceFolder(buildPath('workspace'));
+ const firstResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder);
+ const secondResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder);
+
+ assert.strictEqual(firstResult, undefined);
+ assert.strictEqual(secondResult, undefined);
+ assert.strictEqual(spawnStub.callCount, 1);
+ }
+ finally {
+ service.dispose();
+ }
+ });
+
+ test('fires change event and invalidates cache when watched files change', async () => {
+ const watcherCallbacks = stubFileSystemWatchers(sandbox);
+ const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ options?.stdoutCallback?.('[]');
+ options?.exitCallback?.(0);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+ const workspaceFolder = makeWorkspaceFolder(buildPath('workspace'));
+ let changedWorkspaceFolder: vscode.WorkspaceFolder | undefined;
+ const subscription = service.onDidChangeCandidates(folder => {
+ changedWorkspaceFolder = folder;
+ });
+
+ try {
+ await service.discover(workspaceFolder);
+ assert.strictEqual(spawnStub.callCount, 1);
+
+ watcherCallbacks[0]();
+ assert.strictEqual(changedWorkspaceFolder, workspaceFolder);
+
+ await service.discover(workspaceFolder);
+ assert.strictEqual(spawnStub.callCount, 2);
+ }
+ finally {
+ subscription.dispose();
+ service.dispose();
+ }
+ });
+
+ test('ignores watched files in excluded directories', async () => {
+ const watcherCallbacks = stubFileSystemWatchers(sandbox);
+ const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ options?.stdoutCallback?.('[]');
+ options?.exitCallback?.(0);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+ const workspaceFolder = makeWorkspaceFolder(buildPath('workspace'));
+ let changeCount = 0;
+ const subscription = service.onDidChangeCandidates(() => {
+ changeCount++;
+ });
+
+ try {
+ await service.discover(workspaceFolder);
+ assert.strictEqual(spawnStub.callCount, 1);
+
+ watcherCallbacks[0](vscode.Uri.file(buildPath('workspace', 'AppHost', 'bin', 'Debug', 'Generated.csproj')));
+ assert.strictEqual(changeCount, 0);
+
+ await service.discover(workspaceFolder);
+ assert.strictEqual(spawnStub.callCount, 1);
+ }
+ finally {
+ subscription.dispose();
+ service.dispose();
+ }
+ });
+
+ test('kills in-flight CLI process when disposed', async () => {
+ stubFileSystemWatchers(sandbox);
+ const childProcess = {
+ killed: false,
+ kill: sandbox.stub().callsFake(() => {
+ childProcess.killed = true;
+ return true;
+ }),
+ };
+ const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').returns(childProcess as any);
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+ const workspaceFolder = makeWorkspaceFolder(buildPath('workspace'));
+
+ const discovery = service.discover(workspaceFolder);
+ await waitForMicrotasks();
+
+ service.dispose();
+
+ await assert.rejects(discovery, /disposed/);
+ assert.strictEqual(spawnStub.callCount, 1);
+ assert.strictEqual(childProcess.kill.callCount, 1);
+ assert.strictEqual(childProcess.killed, true);
+ });
+
+ test('times out hung CLI process and allows retry', async () => {
+ stubFileSystemWatchers(sandbox);
+ sandbox.stub(vscode.workspace, 'getConfiguration').returns({
+ get: (key: string, defaultValue: T) => key === 'appHostDiscoveryTimeoutMs' ? 5000 as T : defaultValue,
+ } as vscode.WorkspaceConfiguration);
+ const clock = sandbox.useFakeTimers();
+ const killedArgs: string[][] = [];
+ let hangCli = true;
+ const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => {
+ const childProcess = {
+ killed: false,
+ kill: sandbox.stub().callsFake(() => {
+ childProcess.killed = true;
+ killedArgs.push(args);
+ return true;
+ }),
+ };
+ if (!hangCli) {
+ options?.stdoutCallback?.('[]');
+ options?.exitCallback?.(0);
+ }
+ return childProcess as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+ const workspaceFolder = makeWorkspaceFolder(buildPath('workspace'));
+
+ try {
+ const discovery = service.discover(workspaceFolder);
+ await waitForMicrotasks();
+
+ await clock.tickAsync(5_000);
+ await waitForMicrotasks();
+ await clock.tickAsync(5_000);
+
+ await assert.rejects(discovery, /timed out after 5 seconds/);
+ assert.deepStrictEqual(killedArgs, [
+ ['ls', '--format', 'json'],
+ ['extension', 'get-apphosts'],
+ ]);
+
+ hangCli = false;
+ const retryResult = await service.discover(workspaceFolder);
+ assert.deepStrictEqual(retryResult, []);
+ assert.strictEqual(spawnStub.callCount, 3);
+ }
+ finally {
+ service.dispose();
+ clock.restore();
+ }
+ });
+
+ test('keeps valid aspire ls candidates when future entries have unexpected shape', async () => {
+ stubFileSystemWatchers(sandbox);
+ sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ options?.stdoutCallback?.(JSON.stringify([
+ {
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ },
+ {
+ path: buildPath('workspace', 'Future', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 42,
+ extraMetadata: true,
+ },
+ ]));
+ options?.exitCallback?.(0);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+
+ try {
+ const result = await service.discover(makeWorkspaceFolder(buildPath('workspace')));
+
+ assert.deepStrictEqual(result, [{
+ path: buildPath('workspace', 'AppHost', 'AppHost.csproj'),
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+ }
+ finally {
+ service.dispose();
+ }
+ });
+
+ test('reports both aspire ls and legacy fallback errors when discovery fails', async () => {
+ stubFileSystemWatchers(sandbox);
+ sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => {
+ options?.stderrCallback?.(`${args.join(' ')} failed`);
+ options?.exitCallback?.(1);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+
+ try {
+ await assert.rejects(
+ service.discover(makeWorkspaceFolder(buildPath('workspace'))),
+ /aspire ls discovery failed: ls --format json failed\naspire extension get-apphosts fallback failed: extension get-apphosts failed/);
+ }
+ finally {
+ service.dispose();
+ }
+ });
+
+ test('selects configured path from recursive config during service discovery', async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-'));
+ try {
+ stubFileSystemWatchers(sandbox);
+ const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json');
+ const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json');
+ const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj');
+ const otherAppHostPath = path.join(tempDir, 'Other', 'AppHost', 'AppHost.csproj');
+
+ fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true });
+ fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true });
+ fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } }));
+ fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } }));
+ findFilesStub.callsFake(async (include: vscode.GlobPattern) => {
+ const pattern = typeof include === 'string' ? include : include.pattern;
+ return pattern.endsWith('aspire.config.json')
+ ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)]
+ : [];
+ });
+ sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ options?.stdoutCallback?.(JSON.stringify([
+ {
+ path: otherAppHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ },
+ {
+ path: matchingAppHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ },
+ ]));
+ options?.exitCallback?.(0);
+ return { kill: () => { } } as any;
+ });
+ const service = new AppHostDiscoveryService(makeTerminalProvider());
+
+ try {
+ const result = await service.discover(makeWorkspaceFolder(tempDir));
+
+ assert.deepStrictEqual(result, [
+ {
+ path: otherAppHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ selected: false,
+ },
+ {
+ path: matchingAppHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ selected: true,
+ },
+ ]);
+ }
+ finally {
+ service.dispose();
+ }
+ }
+ finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ test('selects configured path that matches a later discovered candidate', async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-'));
+ try {
+ const workspaceFolder = makeWorkspaceFolder(tempDir);
+ const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json');
+ const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json');
+ const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj');
+
+ fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true });
+ fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true });
+ fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } }));
+ fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } }));
+
+ findFilesStub.callsFake(async (include: vscode.GlobPattern) => {
+ const pattern = typeof include === 'string' ? include : include.pattern;
+ return pattern.endsWith('aspire.config.json')
+ ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)]
+ : [];
+ });
+
+ const selectedPath = await selectWorkspaceAppHostPath(workspaceFolder, [{
+ path: matchingAppHostPath,
+ language: 'csharp',
+ status: 'buildable',
+ }]);
+
+ assert.strictEqual(selectedPath, matchingAppHostPath);
+ }
+ finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+ });
+});
+
+function buildPath(...segments: string[]): string {
+ return path.join(path.sep, ...segments);
+}
+
+function makeWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder {
+ return {
+ uri: vscode.Uri.file(folderPath),
+ name: path.basename(folderPath),
+ index: 0,
+ };
+}
+
+function makeTerminalProvider(): AspireTerminalProvider {
+ return {
+ getAspireCliExecutablePath: async () => 'aspire',
+ createEnvironment: () => ({}),
+ } as unknown as AspireTerminalProvider;
+}
+
+async function waitForMicrotasks(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+function stubFileSystemWatchers(sandbox: sinon.SinonSandbox): Array<(uri?: vscode.Uri) => void> {
+ const callbacks: Array<(uri?: vscode.Uri) => void> = [];
+ sandbox.stub(vscode.workspace, 'createFileSystemWatcher').callsFake(() => ({
+ onDidCreate: callback => {
+ callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj'))));
+ return { dispose: () => { } };
+ },
+ onDidChange: callback => {
+ callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj'))));
+ return { dispose: () => { } };
+ },
+ onDidDelete: callback => {
+ callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj'))));
+ return { dispose: () => { } };
+ },
+ dispose: () => { },
+ } as vscode.FileSystemWatcher));
+
+ return callbacks;
+}
diff --git a/extension/src/test/aspireDebugConfigurationProvider.test.ts b/extension/src/test/aspireDebugConfigurationProvider.test.ts
new file mode 100644
index 00000000000..899ec4b9475
--- /dev/null
+++ b/extension/src/test/aspireDebugConfigurationProvider.test.ts
@@ -0,0 +1,195 @@
+///
+
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as vscode from 'vscode';
+import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider';
+import { AppHostDiscoveryService } from '../utils/appHostDiscovery';
+
+suite('AspireDebugConfigurationProvider', () => {
+ let tempDir: string;
+ let sandbox: sinon.SinonSandbox;
+
+ setup(() => {
+ sandbox = sinon.createSandbox();
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-'));
+ });
+
+ teardown(() => {
+ sandbox.restore();
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => {
+ const appHostDirectory = path.join(tempDir, 'AppHost');
+ fs.mkdirSync(appHostDirectory);
+
+ const programPath = path.join(appHostDirectory, 'Program.cs');
+ const projectPath = path.join(appHostDirectory, 'AppHost.csproj');
+ fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();');
+ fs.writeFileSync(projectPath, '');
+
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath));
+ const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
+ name: 'Debug AppHost',
+ type: 'aspire',
+ request: 'launch',
+ program: programPath
+ });
+
+ assert.strictEqual(config?.program, projectPath);
+ });
+
+ test('leaves launch config single-file apphost.cs unchanged', async () => {
+ const appHostPath = path.join(tempDir, 'apphost.cs');
+ fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);');
+
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath));
+ const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
+ name: 'Debug AppHost',
+ type: 'aspire',
+ request: 'launch',
+ program: appHostPath
+ });
+
+ assert.strictEqual(config?.program, appHostPath);
+ });
+
+ test('leaves launch config TypeScript apphost.ts unchanged', async () => {
+ const appHostPath = path.join(tempDir, 'apphost.ts');
+ fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";');
+
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs'));
+ const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
+ name: 'Debug AppHost',
+ type: 'aspire',
+ request: 'launch',
+ program: appHostPath
+ });
+
+ assert.strictEqual(config?.program, appHostPath);
+ });
+
+ test('leaves launch config non-AppHost C# source file unchanged', async () => {
+ const appDirectory = path.join(tempDir, 'App');
+ fs.mkdirSync(appDirectory);
+
+ const programPath = path.join(appDirectory, 'Program.cs');
+ fs.writeFileSync(programPath, 'Console.WriteLine("Hello");');
+ fs.writeFileSync(path.join(appDirectory, 'App.csproj'), '');
+
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath));
+ const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
+ name: 'Debug AppHost',
+ type: 'aspire',
+ request: 'launch',
+ program: programPath
+ });
+
+ assert.strictEqual(config?.program, programPath);
+ });
+
+ test('provides dynamic launch config when active file resolves to AppHost candidate', async () => {
+ const folder = createWorkspaceFolder(tempDir);
+ const programPath = path.join(tempDir, 'AppHost', 'Program.cs');
+ const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj');
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath));
+ setActiveEditor(programPath, folder);
+
+ const configs = await provider.provideDebugConfigurations(folder);
+
+ assert.strictEqual(configs.length, 1);
+ assert.strictEqual(configs[0].program, projectPath);
+ });
+
+ test('provides default dynamic launch config when active file is not an AppHost candidate', async () => {
+ const folder = createWorkspaceFolder(tempDir);
+ const programPath = path.join(tempDir, 'Web', 'Program.cs');
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath, null));
+ setActiveEditor(programPath, folder);
+
+ const configs = await provider.provideDebugConfigurations(folder);
+
+ assert.strictEqual(configs.length, 1);
+ assert.strictEqual(configs[0].program, folder.uri.fsPath);
+ });
+
+ test('provides default dynamic launch config when discovery fails', async () => {
+ const folder = createWorkspaceFolder(tempDir);
+ const programPath = path.join(tempDir, 'AppHost', 'Program.cs');
+ const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService());
+ setActiveEditor(programPath, folder);
+
+ const configs = await provider.provideDebugConfigurations(folder);
+
+ assert.strictEqual(configs.length, 1);
+ assert.strictEqual(configs[0].program, folder.uri.fsPath);
+ });
+
+ test('provides default dynamic launch config when there is no active editor', async () => {
+ const folder = createWorkspaceFolder(tempDir);
+ const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(folder.uri.fsPath, null));
+ sandbox.stub(vscode.window, 'activeTextEditor').value(undefined);
+
+ const configs = await provider.provideDebugConfigurations(folder);
+
+ assert.strictEqual(configs.length, 1);
+ assert.strictEqual(configs[0].program, folder.uri.fsPath);
+ });
+
+ test('leaves launch config program unchanged when debug target resolution fails', async () => {
+ const programPath = path.join(tempDir, 'AppHost', 'Program.cs');
+ const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService());
+
+ const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
+ name: 'Debug AppHost',
+ type: 'aspire',
+ request: 'launch',
+ program: programPath
+ });
+
+ assert.strictEqual(config?.program, programPath);
+ });
+
+ function setActiveEditor(filePath: string, folder: vscode.WorkspaceFolder): void {
+ sandbox.stub(vscode.window, 'activeTextEditor').value({
+ document: {
+ uri: vscode.Uri.file(filePath),
+ },
+ });
+ sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder);
+ }
+});
+
+function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder {
+ return {
+ uri: vscode.Uri.file(folderPath),
+ name: 'workspace',
+ index: 0,
+ };
+}
+
+function createAppHostDiscoveryService(resolvedPath: string, candidatePath: string | null = resolvedPath, language = 'csharp'): AppHostDiscoveryService {
+ return {
+ resolveDebugTarget: async () => resolvedPath,
+ tryFindCandidateForEditorFile: async () => candidatePath ? {
+ path: candidatePath,
+ language: language,
+ status: 'buildable',
+ } : undefined,
+ } as unknown as AppHostDiscoveryService;
+}
+
+function createFailingAppHostDiscoveryService(): AppHostDiscoveryService {
+ return {
+ resolveDebugTarget: async () => {
+ throw new Error('discovery failed');
+ },
+ tryFindCandidateForEditorFile: async () => {
+ throw new Error('discovery failed');
+ },
+ } as unknown as AppHostDiscoveryService;
+}
diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts
new file mode 100644
index 00000000000..bede4044c7b
--- /dev/null
+++ b/extension/src/test/aspireEditorCommandProvider.test.ts
@@ -0,0 +1,165 @@
+///
+
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as vscode from 'vscode';
+import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider';
+import { AppHostDiscoveryService } from '../utils/appHostDiscovery';
+
+function createEditor(filePath: string): vscode.TextEditor {
+ return {
+ document: {
+ uri: vscode.Uri.file(filePath),
+ fileName: filePath,
+ languageId: filePath.endsWith('.ts') ? 'typescript' : 'csharp'
+ } as vscode.TextDocument
+ } as vscode.TextEditor;
+}
+
+suite('AspireEditorCommandProvider', () => {
+ let tempDir: string;
+ let activeEditor: vscode.TextEditor | undefined;
+ let activeEditorStub: sinon.SinonStub;
+ let workspaceFoldersStub: sinon.SinonStub;
+ let getWorkspaceFolderStub: sinon.SinonStub;
+ let onDidChangeWorkspaceFoldersStub: sinon.SinonStub;
+ let onDidChangeActiveTextEditorStub: sinon.SinonStub;
+ let executeCommandStub: sinon.SinonStub;
+
+ setup(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-'));
+ activeEditor = undefined;
+
+ activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor);
+ workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined);
+ getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => {
+ if (uri.fsPath.startsWith(tempDir)) {
+ return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 };
+ }
+
+ return undefined;
+ });
+ onDidChangeWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } } as vscode.Disposable);
+ onDidChangeActiveTextEditorStub = sinon.stub(vscode.window, 'onDidChangeActiveTextEditor').returns({ dispose: () => { } } as vscode.Disposable);
+ executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined);
+ });
+
+ teardown(() => {
+ executeCommandStub.restore();
+ onDidChangeActiveTextEditorStub.restore();
+ onDidChangeWorkspaceFoldersStub.restore();
+ getWorkspaceFolderStub.restore();
+ workspaceFoldersStub.restore();
+ activeEditorStub.restore();
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => {
+ const appHostDirectory = path.join(tempDir, 'AppHost');
+ fs.mkdirSync(appHostDirectory);
+
+ const programPath = path.join(appHostDirectory, 'Program.cs');
+ const projectPath = path.join(appHostDirectory, 'AppHost.csproj');
+ fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();');
+ fs.writeFileSync(projectPath, '');
+ activeEditor = createEditor(programPath);
+
+ const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath));
+ try {
+ assert.strictEqual(await provider.getAppHostPath(), projectPath);
+ }
+ finally {
+ provider.dispose();
+ }
+ });
+
+ test('returns source file when active editor is single-file apphost.cs', async () => {
+ const appHostPath = path.join(tempDir, 'apphost.cs');
+ fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);');
+ activeEditor = createEditor(appHostPath);
+
+ const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath));
+ try {
+ assert.strictEqual(await provider.getAppHostPath(), appHostPath);
+ }
+ finally {
+ provider.dispose();
+ }
+ });
+
+ test('returns source file when active editor is TypeScript apphost.ts', async () => {
+ const appHostPath = path.join(tempDir, 'apphost.ts');
+ fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";');
+ activeEditor = createEditor(appHostPath);
+
+ const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs'));
+ try {
+ assert.strictEqual(await provider.getAppHostPath(), appHostPath);
+ }
+ finally {
+ provider.dispose();
+ }
+ });
+
+ test('clears AppHost contexts when discovery fails while processing document', async () => {
+ const programPath = path.join(tempDir, 'Program.cs');
+ fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);');
+ activeEditor = createEditor(programPath);
+
+ const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService());
+ try {
+ await provider.processDocument(activeEditor.document);
+
+ assert.ok(executeCommandStub.calledWith('setContext', 'aspire.fileIsAppHost', false));
+ assert.ok(executeCommandStub.calledWith('setContext', 'aspire.workspaceHasAppHost', false));
+ }
+ finally {
+ provider.dispose();
+ }
+ });
+
+ test('returns null when discovery fails while resolving AppHost path', async () => {
+ const programPath = path.join(tempDir, 'Program.cs');
+ fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);');
+ activeEditor = createEditor(programPath);
+
+ const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService());
+ try {
+ assert.strictEqual(await provider.getAppHostPath(), null);
+ }
+ finally {
+ provider.dispose();
+ }
+ });
+});
+
+function createAppHostDiscoveryService(resolvedPath: string, language = 'csharp'): AppHostDiscoveryService {
+ return {
+ onDidChangeCandidates: () => ({ dispose: () => { } }),
+ tryFindCandidateForEditorFile: async () => ({
+ path: resolvedPath,
+ language: language,
+ status: 'buildable',
+ }),
+ discover: async () => [{
+ path: resolvedPath,
+ language: language,
+ status: 'buildable',
+ }],
+ } as unknown as AppHostDiscoveryService;
+}
+
+function createFailingAppHostDiscoveryService(): AppHostDiscoveryService {
+ return {
+ onDidChangeCandidates: () => ({ dispose: () => { } }),
+ tryFindCandidateForEditorFile: async () => {
+ throw new Error('discovery failed');
+ },
+ discover: async () => {
+ throw new Error('discovery failed');
+ },
+ } as unknown as AppHostDiscoveryService;
+}
diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts
index 691e13239c6..3c4bdbeb821 100644
--- a/extension/src/test/workspace.test.ts
+++ b/extension/src/test/workspace.test.ts
@@ -1,15 +1,12 @@
import * as assert from 'assert';
-import type { ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
-import * as cliModule from '../debugger/languages/cli';
-import type { SpawnProcessOptions } from '../debugger/languages/cli';
-import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { yesLabel } from '../loc/strings';
-import { checkForExistingAppHostPathInWorkspace, findAppHostsWithAspireLs, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace';
+import { checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace';
+import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery';
suite('utils/workspace tests', () => {
let sandbox: sinon.SinonSandbox;
@@ -79,11 +76,6 @@ suite('utils/workspace tests', () => {
});
test('AppHost selection quick pick shows aspire ls language and status metadata', async () => {
- const terminalProvider = {
- getAspireCliExecutablePath: async () => 'aspire',
- createEnvironment: () => ({}),
- } as unknown as AspireTerminalProvider;
- let spawnOptions: SpawnProcessOptions | undefined;
sandbox.stub(vscode.workspace, 'workspaceFolders').value([{
uri: vscode.Uri.file('/workspace'),
name: 'workspace',
@@ -92,30 +84,20 @@ suite('utils/workspace tests', () => {
sandbox.stub(vscode.workspace, 'findFiles').resolves([]);
sandbox.stub(vscode.window, 'showInformationMessage').resolves(yesLabel as never);
const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick').resolves(undefined);
- sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
- spawnOptions = options;
- return { kill: () => true } as ChildProcessWithoutNullStreams;
- });
-
- const disposable = await checkForExistingAppHostPathInWorkspace(terminalProvider, () => true, async () => { });
- assert.ok(spawnOptions);
- assert.ok(spawnOptions.stdoutCallback);
- assert.ok(spawnOptions.exitCallback);
- spawnOptions.stdoutCallback(JSON.stringify([
+ const appHostDiscoveryService = createAppHostDiscoveryService([
{
- relativePath: 'apps/Store/AppHost.csproj',
path: '/workspace/apps/Store/AppHost.csproj',
language: 'csharp',
status: 'buildable',
},
{
- relativePath: 'samples/Store/AppHost.csproj',
path: '/workspace/samples/Store/AppHost.csproj',
language: 'typescript/nodejs',
status: 'possibly-unbuildable',
},
- ]));
- spawnOptions.exitCallback(0);
+ ]);
+
+ const disposable = await checkForExistingAppHostPathInWorkspace(appHostDiscoveryService, () => true, async () => { });
await waitForStubCall(showQuickPickStub);
const items = showQuickPickStub.getCall(0).args[0] as readonly vscode.QuickPickItem[];
@@ -125,12 +107,12 @@ suite('utils/workspace tests', () => {
detail: item.detail,
})), [
{
- label: 'apps/Store/AppHost.csproj',
+ label: path.join('apps', 'Store', 'AppHost.csproj'),
description: 'C# · buildable',
detail: '/workspace/apps/Store/AppHost.csproj',
},
{
- label: 'samples/Store/AppHost.csproj',
+ label: path.join('samples', 'Store', 'AppHost.csproj'),
description: 'TypeScript · possibly-unbuildable',
detail: '/workspace/samples/Store/AppHost.csproj',
},
@@ -146,20 +128,15 @@ suite('utils/workspace tests', () => {
const secondDiscoveredAppHostPath = path.join(workspaceRoot, 'samples', 'Store', 'AppHost.csproj');
try {
- fs.writeFileSync(path.join(workspaceRoot, 'aspire.config.json'), JSON.stringify({
+ const configPath = path.join(workspaceRoot, 'aspire.config.json');
+ fs.writeFileSync(configPath, JSON.stringify({
appHost: {
path: configuredAppHostPath,
},
}));
-
- const terminalProvider = {
- getAspireCliExecutablePath: async () => 'aspire',
- createEnvironment: () => ({}),
- } as unknown as AspireTerminalProvider;
- let spawnOptions: SpawnProcessOptions | undefined;
- sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
- spawnOptions = options;
- return { kill: () => true } as ChildProcessWithoutNullStreams;
+ sandbox.stub(vscode.workspace, 'findFiles').callsFake(async (include) => {
+ const pattern = typeof include === 'string' ? include : include.pattern;
+ return pattern.includes('aspire.config.json') ? [vscode.Uri.file(configPath)] : [];
});
const rootFolder = {
@@ -167,28 +144,24 @@ suite('utils/workspace tests', () => {
name: 'workspace',
index: 0,
};
- const discovery = findAppHostsWithAspireLs(terminalProvider, 'aspire', rootFolder);
-
- assert.ok(spawnOptions);
- assert.ok(spawnOptions.stdoutCallback);
- assert.ok(spawnOptions.exitCallback);
- spawnOptions.stdoutCallback(JSON.stringify([
+ const result = await getWorkspaceAppHostProjectSearchResult(rootFolder, [
{
- relativePath: 'apps/Store/AppHost.csproj',
path: discoveredAppHostPath,
language: 'csharp',
status: 'buildable',
},
{
- relativePath: 'samples/Store/AppHost.csproj',
path: secondDiscoveredAppHostPath,
language: 'csharp',
status: 'buildable',
},
- ]));
- spawnOptions.exitCallback(0);
-
- const result = await discovery.result;
+ {
+ path: configuredAppHostPath,
+ language: null,
+ status: 'buildable',
+ selected: true,
+ },
+ ]);
assert.strictEqual(result.selected_project_file, configuredAppHostPath);
assert.deepStrictEqual(result.all_project_file_candidates, [
@@ -230,3 +203,10 @@ async function waitForStubCall(stub: sinon.SinonStub): Promise {
assert.ok(stub.called);
}
+
+function createAppHostDiscoveryService(candidates: Awaited>): AppHostDiscoveryService {
+ return {
+ onDidChangeCandidates: () => ({ dispose: () => { } }),
+ discover: async () => candidates,
+ } as unknown as AppHostDiscoveryService;
+}
diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts
new file mode 100644
index 00000000000..750d006d2d8
--- /dev/null
+++ b/extension/src/utils/appHostDiscovery.ts
@@ -0,0 +1,626 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+import type { ChildProcessWithoutNullStreams } from 'child_process';
+import { spawnCliProcess } from '../debugger/languages/cli';
+import { AspireTerminalProvider } from './AspireTerminalProvider';
+import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes';
+import { EnvironmentVariables } from './environment';
+import { extensionLogOutputChannel } from './logging';
+import { getAppHostDiscoveryTimeoutMs } from './settings';
+
+// Mirrors the `aspire ls --format json` candidate shape documented in
+// docs/specs/cli-output-formats.md. Older CLI fallback results are adapted into
+// this shape so extension code can keep using the modern discovery contract.
+export interface CandidateAppHostDisplayInfo {
+ path: string;
+ language: string | null;
+ status: string | null;
+ selected?: boolean;
+}
+
+export interface AppHostCandidate {
+ relativePath: string;
+ path: string;
+ language: string;
+ status: string;
+}
+
+export interface AppHostProjectSearchResult {
+ selected_project_file: string | null;
+ all_project_file_candidates: string[];
+ app_host_candidates: AppHostCandidate[];
+}
+
+interface LegacyAppHostProjectSearchResult {
+ selected_project_file: string | null;
+ all_project_file_candidates: string[];
+}
+
+const discoveryExcludePattern = '{**/artifacts/**,**/[Bb]in/**,**/[Oo]bj/**,**/node_modules/**,**/.git/**,**/.vs/**,**/.vscode-test/**,**/.idea/**,**/.aspire/modules/**}';
+
+export class AppHostDiscoveryService implements vscode.Disposable {
+ private readonly _onDidChangeCandidates = new vscode.EventEmitter();
+ private readonly _cache = new Map>();
+ private readonly _watchers = new Map();
+ private readonly _activeCliProcesses = new Set();
+ private readonly _cancelActiveCliProcesses = new Set<(error: Error) => void>();
+ private _disposed = false;
+ readonly onDidChangeCandidates = this._onDidChangeCandidates.event;
+
+ constructor(private readonly _terminalProvider: AspireTerminalProvider) {
+ }
+
+ async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false): Promise {
+ this._throwIfDisposed();
+
+ const key = path.resolve(workspaceFolder.uri.fsPath);
+ if (forceRefresh) {
+ this._cache.delete(key);
+ }
+
+ this._ensureWatchers(workspaceFolder, key);
+
+ let resultPromise = this._cache.get(key);
+ if (!resultPromise) {
+ resultPromise = this._discoverCore(workspaceFolder)
+ .then(candidates => this._includeConfiguredAppHostCandidate(workspaceFolder, candidates))
+ .catch(error => {
+ this._cache.delete(key);
+ throw error;
+ });
+ this._cache.set(key, resultPromise);
+ }
+
+ return resultPromise;
+ }
+
+ async resolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise {
+ return await this.tryResolveDebugTarget(filePath, workspaceFolder) ?? filePath;
+ }
+
+ async tryResolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise {
+ const candidate = await this.tryFindCandidateForEditorFile(filePath, workspaceFolder);
+ return candidate ? getDebugTargetForCandidate(candidate) : undefined;
+ }
+
+ async tryFindCandidateForEditorFile(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise {
+ const folder = workspaceFolder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath));
+ if (!folder) {
+ return undefined;
+ }
+
+ const result = await this.discover(folder);
+ return findCandidateForEditorFile(filePath, result);
+ }
+
+ dispose(): void {
+ if (this._disposed) {
+ return;
+ }
+
+ this._disposed = true;
+ for (const disposables of this._watchers.values()) {
+ disposables.forEach(disposable => disposable.dispose());
+ }
+ this._watchers.clear();
+ this._cache.clear();
+ for (const cancel of [...this._cancelActiveCliProcesses]) {
+ cancel(new Error('AppHost discovery service was disposed.'));
+ }
+ this._cancelActiveCliProcesses.clear();
+ this._activeCliProcesses.clear();
+ this._onDidChangeCandidates.dispose();
+ }
+
+ private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ try {
+ const appHosts = await this._discoverWithLs(workspaceFolder);
+ extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire ls`);
+ return appHosts;
+ }
+ catch (error) {
+ this._throwIfDisposed();
+ extensionLogOutputChannel.warn(`aspire ls discovery failed, falling back to aspire extension get-apphosts: ${formatErrorMessage(error)}`);
+ try {
+ const appHosts = await this._discoverWithLegacyGetAppHosts(workspaceFolder);
+ extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire extension get-apphosts`);
+ return appHosts;
+ }
+ catch (fallbackError) {
+ this._throwIfDisposed();
+ throw new Error(`aspire ls discovery failed: ${formatErrorMessage(error)}\naspire extension get-apphosts fallback failed: ${formatErrorMessage(fallbackError)}`);
+ }
+ }
+ }
+
+ private async _discoverWithLs(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ this._throwIfDisposed();
+
+ const cliPath = await this._terminalProvider.getAspireCliExecutablePath();
+ const args = ['ls', '--format', 'json'];
+ if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') {
+ args.push('--cli-wait-for-debugger');
+ }
+
+ const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath);
+ return parseCandidateOutput(output, 'aspire ls');
+ }
+
+ private async _discoverWithLegacyGetAppHosts(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ this._throwIfDisposed();
+
+ const cliPath = await this._terminalProvider.getAspireCliExecutablePath();
+ const args = ['extension', 'get-apphosts'];
+ if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') {
+ args.push('--cli-wait-for-debugger');
+ }
+
+ const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath);
+ const parsed = parseLegacyGetAppHostsOutput(output);
+ return toCandidatesFromLegacySearchResult(parsed);
+ }
+
+ private _ensureWatchers(workspaceFolder: vscode.WorkspaceFolder, key: string): void {
+ if (this._watchers.has(key)) {
+ return;
+ }
+
+ const invalidate = (uri: vscode.Uri) => {
+ if (isExcludedDiscoveryUri(workspaceFolder, uri)) {
+ return;
+ }
+
+ this._cache.delete(key);
+ this._onDidChangeCandidates.fire(workspaceFolder);
+ };
+ const patterns = [
+ '**/*.csproj',
+ '**/*.fsproj',
+ '**/*.vbproj',
+ '**/apphost.cs',
+ '**/apphost.ts',
+ '**/apphost.js',
+ '**/apphost.mts',
+ '**/apphost.mjs',
+ `**/${aspireConfigFileName}`,
+ '**/.aspire/settings.json',
+ ];
+
+ const watchers = patterns.map(pattern => {
+ const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(workspaceFolder, pattern));
+ watcher.onDidCreate(uri => invalidate(uri));
+ watcher.onDidChange(uri => invalidate(uri));
+ watcher.onDidDelete(uri => invalidate(uri));
+ return watcher;
+ });
+ this._watchers.set(key, watchers);
+ }
+
+ private _throwIfDisposed(): void {
+ if (this._disposed) {
+ throw new Error('AppHost discovery service has been disposed.');
+ }
+ }
+
+ private async _includeConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidates: CandidateAppHostDisplayInfo[]): Promise {
+ if (candidates.some(candidate => candidate.selected)) {
+ return candidates;
+ }
+
+ const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder);
+ const configuredPath = configuredPaths.find(configuredPath => candidates.some(candidate => isSamePath(candidate.path, configuredPath)))
+ ?? configuredPaths[0];
+ if (!configuredPath) {
+ return candidates;
+ }
+
+ const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath));
+ if (matchingCandidate) {
+ return candidates.map(candidate => ({
+ ...candidate,
+ selected: isSamePath(candidate.path, configuredPath),
+ }));
+ }
+
+ return [
+ ...candidates,
+ {
+ path: configuredPath,
+ language: null,
+ status: 'buildable',
+ selected: true,
+ },
+ ];
+ }
+
+ private _runCliForStdout(cliPath: string, args: string[], workingDirectory: string): Promise {
+ return new Promise((resolve, reject) => {
+ this._throwIfDisposed();
+
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ let childProcess: ChildProcessWithoutNullStreams | undefined;
+ let timeout: ReturnType | undefined;
+ const cancel = (error: Error) => {
+ if (childProcess && !childProcess.killed) {
+ try {
+ if (!childProcess.kill()) {
+ extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: aspire ${args.join(' ')}`);
+ }
+ }
+ catch (killError) {
+ extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: ${killError}`);
+ }
+ }
+
+ settle(() => reject(error));
+ };
+ const cleanup = () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = undefined;
+ }
+ if (childProcess) {
+ this._activeCliProcesses.delete(childProcess);
+ }
+ this._cancelActiveCliProcesses.delete(cancel);
+ };
+ const settle = (complete: () => void) => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ cleanup();
+ complete();
+ };
+
+ this._cancelActiveCliProcesses.add(cancel);
+ try {
+ childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, {
+ noExtensionVariables: true,
+ workingDirectory,
+ stdoutCallback: data => { stdout += data; },
+ stderrCallback: data => { stderr += data; },
+ exitCallback: code => {
+ settle(() => {
+ if (code === 0) {
+ resolve(stdout);
+ }
+ else {
+ reject(new Error(stderr || `exit code ${code ?? 1}`));
+ }
+ });
+ },
+ errorCallback: error => {
+ settle(() => reject(error));
+ },
+ });
+ }
+ catch (error) {
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
+ return;
+ }
+
+ if (settled) {
+ return;
+ }
+
+ this._activeCliProcesses.add(childProcess);
+ const timeoutMs = getAppHostDiscoveryTimeoutMs();
+ timeout = setTimeout(() => {
+ cancel(new Error(`aspire ${args.join(' ')} timed out after ${timeoutMs / 1000} seconds.`));
+ }, timeoutMs);
+ });
+ }
+}
+
+export function findCandidateForEditorFile(filePath: string, candidates: readonly CandidateAppHostDisplayInfo[]): CandidateAppHostDisplayInfo | undefined {
+ const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, filePath));
+ if (matchingCandidate) {
+ return matchingCandidate;
+ }
+
+ if (path.extname(filePath).toLowerCase() !== '.cs') {
+ return undefined;
+ }
+
+ // IMPORTANT: `aspire ls` is still the source of truth for what is a valid AppHost.
+ // This block does not discover AppHosts by reading C# source files or by deciding
+ // that a project "looks like" an AppHost. It only handles the editor affordance gap
+ // in the current CLI shape:
+ //
+ // aspire ls --format json
+ // [
+ // { "path": "/repo/AppHost/AppHost.csproj", "language": "csharp", "status": "buildable" }
+ // ]
+ //
+ // For SDK-style .NET AppHosts the launch target is the `.csproj`, but users usually
+ // have `Program.cs` or another C# source file open when they invoke Run/Debug from
+ // the editor or debug picker. Until the CLI returns source identity/project membership
+ // in the candidate payload, treat C# files under a candidate `.csproj` directory as
+ // editor aliases for that candidate. Pick the deepest candidate directory so nested
+ // AppHost candidates prefer their own project over an outer candidate. Keep this
+ // heuristic bounded to C# project candidates from `aspire ls` and remove it when the
+ // CLI can report the canonical source file or owning project for each candidate.
+ const projectCandidate = candidates
+ .filter(candidate => isCSharpProjectCandidate(candidate) && isCSharpSourceFileForProjectCandidate(filePath, candidate.path))
+ .sort((left, right) => path.dirname(right.path).length - path.dirname(left.path).length)[0];
+ return projectCandidate;
+}
+
+export function getDebugTargetForCandidate(candidate: CandidateAppHostDisplayInfo): string {
+ return candidate.path;
+}
+
+export function getWorkspaceAppHostProjectSearchResult(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): AppHostProjectSearchResult {
+ const appHostCandidates = candidates.map(candidate => toAppHostCandidate(workspaceFolder, candidate));
+ const selectedAppHostPath = candidates.find(candidate => candidate.selected)?.path
+ ?? (candidates.length === 1 ? candidates[0].path : null);
+ const effectiveAppHostCandidates = selectedAppHostPath && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath))
+ ? [...appHostCandidates, toConfiguredAppHostCandidate(workspaceFolder, selectedAppHostPath)]
+ : appHostCandidates;
+ const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate);
+
+ return {
+ selected_project_file: selectedAppHostPath && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath))
+ ? selectedAppHostPath
+ : null,
+ all_project_file_candidates: buildableCandidates.map(candidate => candidate.path),
+ app_host_candidates: effectiveAppHostCandidates,
+ };
+}
+
+export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolean {
+ return candidate.status === 'buildable';
+}
+
+export function formatAppHostLanguage(language: string): string | undefined {
+ if (!language) {
+ return undefined;
+ }
+
+ switch (language.toLowerCase()) {
+ case 'csharp':
+ return 'C#';
+ case 'typescript':
+ case 'typescript/nodejs':
+ return 'TypeScript';
+ default:
+ return language.charAt(0).toUpperCase() + language.slice(1);
+ }
+}
+
+export async function selectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): Promise {
+ const selectedCandidate = candidates.find(candidate => candidate.selected);
+ if (selectedCandidate) {
+ return selectedCandidate.path;
+ }
+
+ const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder);
+ for (const configuredPath of configuredPaths) {
+ const candidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath));
+ if (candidate) {
+ return candidate.path;
+ }
+ }
+
+ return candidates.length === 1 ? candidates[0].path : undefined;
+}
+
+export async function findConfiguredAppHostPaths(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ let newConfigFiles: vscode.Uri[];
+ let legacySettingsFiles: vscode.Uri[];
+ try {
+ [newConfigFiles, legacySettingsFiles] = await Promise.all([
+ vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, `**/${aspireConfigFileName}`), discoveryExcludePattern),
+ vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, '**/.aspire/settings.json'), discoveryExcludePattern),
+ ]);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Failed to find AppHost configuration files: ${formatErrorMessage(error)}`);
+ return [];
+ }
+
+ const newConfigDirs = new Set(newConfigFiles.map(uri => path.dirname(uri.fsPath)));
+ const filteredLegacyFiles = legacySettingsFiles.filter(legacyUri => {
+ const projectRoot = path.dirname(path.dirname(legacyUri.fsPath));
+ return !newConfigDirs.has(projectRoot);
+ });
+
+ const configuredPaths: string[] = [];
+ for (const uri of [...newConfigFiles, ...filteredLegacyFiles]) {
+ try {
+ const json = await readJsonFile(uri);
+ const appHostPath = getAppHostPathFromConfig(json);
+ if (appHostPath) {
+ configuredPaths.push(path.isAbsolute(appHostPath) ? appHostPath : path.join(path.dirname(uri.fsPath), appHostPath));
+ }
+ }
+ catch {
+ }
+ }
+
+ return configuredPaths;
+}
+
+function isExcludedDiscoveryUri(workspaceFolder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean {
+ const relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath);
+ if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
+ return true;
+ }
+
+ const segments = relativePath.split(/[\\/]+/);
+ return segments.some((segment, index) => {
+ const lowerSegment = segment.toLowerCase();
+ return lowerSegment === 'artifacts'
+ || lowerSegment === 'bin'
+ || lowerSegment === 'obj'
+ || lowerSegment === 'node_modules'
+ || lowerSegment === '.git'
+ || lowerSegment === '.vs'
+ || lowerSegment === '.vscode-test'
+ || lowerSegment === '.idea'
+ || (lowerSegment === '.aspire' && segments[index + 1]?.toLowerCase() === 'modules');
+ });
+}
+
+function toAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidate: CandidateAppHostDisplayInfo): AppHostCandidate {
+ return {
+ relativePath: path.relative(workspaceFolder.uri.fsPath, candidate.path),
+ path: candidate.path,
+ language: candidate.language ?? '',
+ status: candidate.status ?? 'buildable',
+ };
+}
+
+function toConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, appHostPath: string): AppHostCandidate {
+ return {
+ relativePath: path.relative(workspaceFolder.uri.fsPath, appHostPath),
+ path: appHostPath,
+ language: '',
+ status: 'buildable',
+ };
+}
+
+function parseCandidateOutput(output: string, commandName: string): CandidateAppHostDisplayInfo[] {
+ const trimmed = output.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ const parsed = JSON.parse(trimmed);
+ if (Array.isArray(parsed)) {
+ const appHosts = parsed
+ .filter(isLsCandidate)
+ .map(candidate => ({
+ path: candidate.path,
+ language: candidate.language,
+ status: candidate.status,
+ }));
+
+ const unexpectedCandidateCount = parsed.length - appHosts.length;
+ if (unexpectedCandidateCount > 0) {
+ extensionLogOutputChannel.warn(`${commandName} returned ${unexpectedCandidateCount} candidate(s) with an unexpected shape; ignoring those entries.`);
+ }
+
+ return appHosts;
+ }
+
+ if (isAppHostProjectSearchResult(parsed)) {
+ return parsed.app_host_candidates.map(candidate => ({
+ path: candidate.path,
+ language: candidate.language,
+ status: candidate.status,
+ selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidate.path),
+ }));
+ }
+
+ if (isLegacyAppHostProjectSearchResult(parsed)) {
+ return toCandidatesFromLegacySearchResult(parsed);
+ }
+
+ throw new Error(`${commandName} returned an unexpected output shape.`);
+}
+
+function parseLegacyGetAppHostsOutput(output: string): LegacyAppHostProjectSearchResult {
+ // `aspire extension get-apphosts` prints a single JSON object:
+ // {"selected_project_file":"/repo/AppHost/AppHost.csproj","all_project_file_candidates":["/repo/AppHost/AppHost.csproj"]}
+ // Older builds can include log lines, so scan for the first line with the expected shape.
+ for (const line of output.split(/\r?\n/)) {
+ try {
+ const parsed = JSON.parse(line);
+ if (isLegacyAppHostProjectSearchResult(parsed)) {
+ return parsed;
+ }
+ }
+ catch {
+ }
+ }
+
+ const parsed = JSON.parse(output.trim());
+ if (isLegacyAppHostProjectSearchResult(parsed)) {
+ return parsed;
+ }
+
+ throw new Error('aspire extension get-apphosts returned an unexpected output shape.');
+}
+
+function isLsCandidate(obj: unknown): obj is CandidateAppHostDisplayInfo {
+ return !!obj
+ && typeof obj === 'object'
+ && typeof (obj as CandidateAppHostDisplayInfo).path === 'string'
+ && typeof (obj as CandidateAppHostDisplayInfo).language === 'string'
+ && typeof (obj as CandidateAppHostDisplayInfo).status === 'string';
+}
+
+function formatErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isLegacyAppHostProjectSearchResult(obj: unknown): obj is LegacyAppHostProjectSearchResult {
+ return !!obj
+ && typeof obj === 'object'
+ && (typeof (obj as LegacyAppHostProjectSearchResult).selected_project_file === 'string' || (obj as LegacyAppHostProjectSearchResult).selected_project_file === null)
+ && Array.isArray((obj as LegacyAppHostProjectSearchResult).all_project_file_candidates);
+}
+
+function isAppHostProjectSearchResult(obj: unknown): obj is AppHostProjectSearchResult {
+ return !!obj
+ && typeof obj === 'object'
+ && (typeof (obj as AppHostProjectSearchResult).selected_project_file === 'string' || (obj as AppHostProjectSearchResult).selected_project_file === null)
+ && Array.isArray((obj as AppHostProjectSearchResult).app_host_candidates)
+ && (obj as AppHostProjectSearchResult).app_host_candidates.every(candidate =>
+ candidate
+ && typeof candidate.relativePath === 'string'
+ && typeof candidate.path === 'string'
+ && typeof candidate.language === 'string'
+ && typeof candidate.status === 'string');
+}
+
+function toCandidatesFromLegacySearchResult(parsed: LegacyAppHostProjectSearchResult): CandidateAppHostDisplayInfo[] {
+ return parsed.all_project_file_candidates.filter(candidate => typeof candidate === 'string').map(candidatePath => ({
+ path: candidatePath,
+ language: null,
+ status: null,
+ selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidatePath),
+ }));
+}
+
+function isCSharpProjectCandidate(candidate: CandidateAppHostDisplayInfo): boolean {
+ // Only `.csproj` candidates can own nearby C# source files for the editor alias
+ // heuristic above. Modern `aspire ls` candidates include the CLI language id
+ // (`language: "csharp"`); legacy `aspire extension get-apphosts` fallback
+ // candidates do not have a language, so `null` is treated as C# here to
+ // preserve old CLI support while keeping the compatibility gap local to
+ // candidate adaptation/matching.
+ return path.extname(candidate.path).toLowerCase() === '.csproj'
+ && (candidate.language === null || candidate.language.toLowerCase() === 'csharp');
+}
+
+function isCSharpSourceFileForProjectCandidate(filePath: string, projectPath: string): boolean {
+ const projectDirectory = path.dirname(path.resolve(projectPath));
+ const sourcePath = path.resolve(filePath);
+ const comparison = process.platform === 'win32' || process.platform === 'darwin'
+ ? 'case-insensitive'
+ : 'case-sensitive';
+ const normalizedProjectDirectory = comparison === 'case-insensitive' ? projectDirectory.toLowerCase() : projectDirectory;
+ const normalizedSourcePath = comparison === 'case-insensitive' ? sourcePath.toLowerCase() : sourcePath;
+ const relativePath = path.relative(normalizedProjectDirectory, normalizedSourcePath);
+ return relativePath !== ''
+ && !relativePath.startsWith('..')
+ && !path.isAbsolute(relativePath)
+ && !relativePath.split(path.sep).some(segment => segment.toLowerCase() === 'bin' || segment.toLowerCase() === 'obj');
+}
+
+function isSamePath(left: string, right: string): boolean {
+ const comparison = process.platform === 'win32' || process.platform === 'darwin'
+ ? 'case-insensitive'
+ : 'case-sensitive';
+ const resolvedLeft = path.resolve(left);
+ const resolvedRight = path.resolve(right);
+ return comparison === 'case-insensitive'
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
+ : resolvedLeft === resolvedRight;
+}
diff --git a/extension/src/utils/cliTypes.ts b/extension/src/utils/cliTypes.ts
index 861a788cb47..ff2185b91e3 100644
--- a/extension/src/utils/cliTypes.ts
+++ b/extension/src/utils/cliTypes.ts
@@ -7,7 +7,7 @@ import { stripComments } from 'jsonc-parser';
*/
export async function readJsonFile(uri: vscode.Uri): Promise {
const buffer = await vscode.workspace.fs.readFile(uri);
- const raw = buffer.toString();
+ const raw = Buffer.from(buffer).toString('utf8');
return JSON.parse(stripComments(raw));
}
diff --git a/extension/src/utils/settings.ts b/extension/src/utils/settings.ts
index 93008fad1e9..7582e58e998 100644
--- a/extension/src/utils/settings.ts
+++ b/extension/src/utils/settings.ts
@@ -19,3 +19,8 @@ export function getRegisterMcpServerInWorkspace(): boolean {
export function getEnableAutoRestore(): boolean {
return getAspireConfig().get('enableAutoRestore', false);
}
+
+export function getAppHostDiscoveryTimeoutMs(): number {
+ const timeoutMs = getAspireConfig().get('appHostDiscoveryTimeoutMs', 30000);
+ return Number.isFinite(timeoutMs) ? Math.max(timeoutMs, 1000) : 30000;
+}
diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts
index 87e7edc6216..771006d7cf4 100644
--- a/extension/src/utils/workspace.ts
+++ b/extension/src/utils/workspace.ts
@@ -1,13 +1,11 @@
import * as vscode from 'vscode';
import { appHostCandidateDescription, cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings';
import path from 'path';
-import { spawnCliProcess } from '../debugger/languages/cli';
-import { AspireTerminalProvider } from './AspireTerminalProvider';
-import { ChildProcessWithoutNullStreams } from 'child_process';
import { AspireConfigFile, aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes';
import { extensionLogOutputChannel } from './logging';
-import { EnvironmentVariables } from './environment';
import { resolveCliPath } from './cliPath';
+import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery';
+import type { AppHostCandidate } from './appHostDiscovery';
/**
* Common file patterns to exclude from workspace file searches.
@@ -110,19 +108,6 @@ export function getRelativePathToWorkspace(filePath: string): string {
return filePath;
}
-export interface AppHostCandidate {
- relativePath: string;
- path: string;
- language: string;
- status: string;
-}
-
-export interface AppHostProjectSearchResult {
- selected_project_file: string | null;
- all_project_file_candidates: string[];
- app_host_candidates: AppHostCandidate[];
-}
-
interface AppHostQuickPickItem extends vscode.QuickPickItem {
appHostPath: string;
}
@@ -131,132 +116,6 @@ export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolea
return candidate.status === 'buildable';
}
-function isAppHostCandidate(obj: any): obj is AppHostCandidate {
- return obj
- && typeof obj.relativePath === 'string'
- && typeof obj.path === 'string'
- && typeof obj.language === 'string'
- && typeof obj.status === 'string';
-}
-
-interface ParsedAppHostCandidates {
- candidates: AppHostCandidate[];
- selectedProjectFile: string | null;
- isAspireLsOutput: boolean;
-}
-
-function parseAppHostCandidates(stdout: string): ParsedAppHostCandidates {
- const parsed = JSON.parse(stdout);
- if (Array.isArray(parsed)) {
- return {
- candidates: parsed.filter(isAppHostCandidate),
- selectedProjectFile: null,
- isAspireLsOutput: true,
- };
- }
-
- if (parsed
- && (typeof parsed.selected_project_file === 'string' || parsed.selected_project_file === null)
- && Array.isArray(parsed.all_project_file_candidates)) {
- const candidates = parsed.all_project_file_candidates
- .filter((appHostPath: unknown): appHostPath is string => typeof appHostPath === 'string')
- .map((appHostPath: string) => ({
- relativePath: path.basename(appHostPath),
- path: appHostPath,
- language: '',
- status: 'buildable',
- }));
-
- return {
- candidates,
- selectedProjectFile: parsed.selected_project_file,
- isAspireLsOutput: false,
- };
- }
-
- return {
- candidates: [],
- selectedProjectFile: null,
- isAspireLsOutput: true,
- };
-}
-
-async function getConfiguredAppHostPathFromWorkspaceRoot(rootFolder: vscode.WorkspaceFolder): Promise {
- const configUris = [
- vscode.Uri.joinPath(rootFolder.uri, aspireConfigFileName),
- vscode.Uri.joinPath(rootFolder.uri, '.aspire', 'settings.json'),
- ];
-
- for (const uri of configUris) {
- try {
- const json = await readJsonFile(uri);
- const appHostPath = getAppHostPathFromConfig(json);
- if (!appHostPath) {
- continue;
- }
-
- const configDir = path.dirname(uri.fsPath);
- return path.isAbsolute(appHostPath)
- ? appHostPath
- : path.join(configDir, appHostPath);
- } catch {
- // Missing or invalid settings files do not block AppHost discovery.
- }
- }
-
- return null;
-}
-
-function createAppHostProjectSearchResult(appHostCandidates: AppHostCandidate[], selectedProjectFile: string | null, rootFolder: vscode.WorkspaceFolder): AppHostProjectSearchResult {
- const effectiveAppHostCandidates = selectedProjectFile && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile))
- ? [...appHostCandidates, createConfiguredAppHostCandidate(selectedProjectFile, rootFolder)]
- : appHostCandidates;
- const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate);
- const allProjectFileCandidates = buildableCandidates.map(candidate => candidate.path);
- const selectedCandidate = selectedProjectFile && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile))
- ? selectedProjectFile
- : null;
-
- return {
- selected_project_file: selectedCandidate,
- all_project_file_candidates: allProjectFileCandidates,
- app_host_candidates: effectiveAppHostCandidates,
- };
-}
-
-function createConfiguredAppHostCandidate(appHostPath: string, rootFolder: vscode.WorkspaceFolder): AppHostCandidate {
- return {
- relativePath: path.relative(rootFolder.uri.fsPath, appHostPath),
- path: appHostPath,
- language: '',
- status: 'buildable',
- };
-}
-
-function isSamePath(left: string, right: string): boolean {
- const normalizedLeft = path.normalize(left);
- const normalizedRight = path.normalize(right);
- return process.platform === 'win32'
- ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
- : normalizedLeft === normalizedRight;
-}
-
-export function formatAppHostLanguage(language: string): string | undefined {
- if (!language) {
- return undefined;
- }
-
- switch (language.toLowerCase()) {
- case 'csharp':
- return 'C#';
- case 'typescript':
- case 'typescript/nodejs':
- return 'TypeScript';
- default:
- return language.charAt(0).toUpperCase() + language.slice(1);
- }
-}
-
function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFolder: vscode.WorkspaceFolder): AppHostQuickPickItem[] {
const candidates = result.app_host_candidates.length > 0
? result.app_host_candidates
@@ -279,66 +138,7 @@ function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFol
});
}
-export function findAppHostsWithAspireLs(terminalProvider: AspireTerminalProvider, cliPath: string, rootFolder: vscode.WorkspaceFolder): { process: ChildProcessWithoutNullStreams; result: Promise } {
- let stdout = '';
- let stderr = '';
- const configuredAppHostPathPromise = getConfiguredAppHostPathFromWorkspaceRoot(rootFolder);
-
- const args = ['ls', '--format', 'json'];
- if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') {
- args.push('--cli-wait-for-debugger');
- }
-
- let proc: ChildProcessWithoutNullStreams;
- const result = new Promise((resolve, reject) => {
- let settled = false;
- proc = spawnCliProcess(terminalProvider, cliPath, args, {
- errorCallback: error => {
- settled = true;
- extensionLogOutputChannel.error(`Error executing aspire ls command: ${error}`);
- reject(error);
- },
- exitCallback: async code => {
- if (settled) {
- return;
- }
-
- if (code !== 0) {
- settled = true;
- extensionLogOutputChannel.warn(`aspire ls command exited with code: ${code}`);
- reject(new Error(stderr || `aspire ls exited with code ${code}`));
- return;
- }
-
- try {
- const parsed = parseAppHostCandidates(stdout);
- const selectedProjectFile = parsed.isAspireLsOutput
- ? await configuredAppHostPathPromise
- : null;
- const effectiveSelectedProjectFile = selectedProjectFile ?? parsed.selectedProjectFile;
- extensionLogOutputChannel.info(`Found ${parsed.candidates.length} AppHost candidates with aspire ls`);
- settled = true;
- resolve(createAppHostProjectSearchResult(parsed.candidates, effectiveSelectedProjectFile, rootFolder));
- } catch (error) {
- settled = true;
- reject(error);
- }
- },
- stdoutCallback: data => {
- stdout += data;
- },
- stderrCallback: data => {
- stderr += data;
- },
- noExtensionVariables: true,
- workingDirectory: rootFolder.uri.fsPath
- });
- });
-
- return { process: proc!, result };
-}
-
-export async function checkForExistingAppHostPathInWorkspace(terminalProvider: AspireTerminalProvider, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise {
+export async function checkForExistingAppHostPathInWorkspace(appHostDiscoveryService: AppHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise {
extensionLogOutputChannel.info('Checking for existing AppHost path in workspace');
const enabled = getEnableSettingsFileCreationPromptOnStartup();
@@ -392,24 +192,16 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A
}
const settingsFile = settingsFiles[0];
- extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire ls');
+ extensionLogOutputChannel.info('Searching for AppHost projects using shared AppHost discovery');
- let proc: ChildProcessWithoutNullStreams | undefined;
- const cliPath = await terminalProvider.getAspireCliExecutablePath();
- const discovery = findAppHostsWithAspireLs(terminalProvider, cliPath, rootFolder);
- proc = discovery.process;
- discovery.result
+ appHostDiscoveryService.discover(rootFolder, true)
+ .then(appHosts => getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts))
.then(result => promptToAddAppHostPathToSettingsFile(result, settingsFileExists, settingsFile, rootFolder, setEnableSettingsFileCreationPromptOnStartup))
.catch(error => {
extensionLogOutputChannel.error(`Failed to retrieve AppHost projects: ${error}`);
- })
- .finally(() => proc = undefined);
+ });
- return {
- dispose() {
- proc?.kill();
- }
- };
+ return null;
}
async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearchResult, settingsFileExists: boolean, settingsFileLocation: vscode.Uri, rootFolder: vscode.WorkspaceFolder, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise {
diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts
index 16c2592433b..42cd419fe54 100644
--- a/extension/src/views/AppHostDataRepository.ts
+++ b/extension/src/views/AppHostDataRepository.ts
@@ -5,7 +5,7 @@ import { spawnCliProcess } from '../debugger/languages/cli';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { extensionLogOutputChannel } from '../utils/logging';
import { appHostDescribeMayNotBeSupported, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings';
-import { AppHostCandidate, findAppHostsWithAspireLs, formatAppHostLanguage, isBuildableAppHostCandidate } from '../utils/workspace';
+import { AppHostCandidate, AppHostDiscoveryService, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery';
export interface ResourceUrlJson {
name: string | null;
@@ -141,7 +141,9 @@ export class AppHostDataRepository {
private _workspaceAppHostDescription: string | undefined;
private _workspaceAppHostDiscoveryComplete = false;
private _workspaceAppHostDiscoveryUsesWorkspaceRoot = false;
- private _getAppHostsProcess: ChildProcessWithoutNullStreams | undefined;
+ private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable;
+ private readonly _appHostDiscoveryService: AppHostDiscoveryService;
+ private readonly _ownsAppHostDiscoveryService: boolean;
// ── Error state ──
private _describeErrorMessage: string | undefined;
@@ -155,7 +157,15 @@ export class AppHostDataRepository {
private readonly _configChangeDisposable: vscode.Disposable;
private _disposed = false;
- constructor(private readonly _terminalProvider: AspireTerminalProvider) {
+ constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService) {
+ this._appHostDiscoveryService = appHostDiscoveryService ?? new AppHostDiscoveryService(_terminalProvider);
+ this._ownsAppHostDiscoveryService = appHostDiscoveryService === undefined;
+ this._appHostDiscoveryChangeDisposable = this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => {
+ const rootFolder = vscode.workspace.workspaceFolders?.[0];
+ if (rootFolder?.uri.toString() === workspaceFolder.uri.toString()) {
+ this._fetchWorkspaceAppHost();
+ }
+ });
this._fetchWorkspaceAppHost();
this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('aspire.globalAppHostsPollingInterval') && this._shouldPoll) {
@@ -266,13 +276,12 @@ export class AppHostDataRepository {
this._disposed = true;
this._stopPolling();
this._stopDescribeWatch();
- if (this._getAppHostsProcess) {
- const getAppHostsProcess = this._getAppHostsProcess;
- this._getAppHostsProcess = undefined;
- this._terminateProcess(getAppHostsProcess, 'aspire ls');
- }
this._configChangeDisposable.dispose();
+ this._appHostDiscoveryChangeDisposable.dispose();
this._onDidChangeData.dispose();
+ if (this._ownsAppHostDiscoveryService) {
+ this._appHostDiscoveryService.dispose();
+ }
}
// ── PS polling lifecycle ──
@@ -333,29 +342,16 @@ export class AppHostDataRepository {
const rootFolder = workspaceFolders[0];
this._workspaceAppHostDiscoveryUsesWorkspaceRoot = true;
- extensionLogOutputChannel.info('Fetching workspace apphost via: aspire ls');
+ extensionLogOutputChannel.info('Fetching workspace apphost via shared AppHost discovery');
- this._terminalProvider.getAspireCliExecutablePath().then(cliPath => {
+ this._appHostDiscoveryService.discover(rootFolder).then(appHosts => {
if (this._disposed) {
return;
}
- const discovery = findAppHostsWithAspireLs(this._terminalProvider, cliPath, rootFolder);
- this._getAppHostsProcess = discovery.process;
- discovery.result.then(result => {
- if (this._disposed) {
- return;
- }
-
- this._getAppHostsProcess = undefined;
- this._workspaceAppHostDiscoveryComplete = true;
- this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file);
- }).catch(error => {
- this._getAppHostsProcess = undefined;
- this._workspaceAppHostDiscoveryComplete = true;
- extensionLogOutputChannel.warn(`aspire ls error: ${error}`);
- this._syncPolling();
- });
+ const result = getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts);
+ this._workspaceAppHostDiscoveryComplete = true;
+ this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file);
}).catch(error => {
this._workspaceAppHostDiscoveryComplete = true;
extensionLogOutputChannel.warn(`Failed to fetch workspace apphost: ${error}`);
diff --git a/playground/TypeScriptAppHost/.gitignore b/playground/TypeScriptAppHost/.gitignore
index 9d70b9f5312..3f818ff5441 100644
--- a/playground/TypeScriptAppHost/.gitignore
+++ b/playground/TypeScriptAppHost/.gitignore
@@ -1,3 +1,4 @@
!.aspire/
.aspire/dcp/
.aspire/modules/
+.modules/
diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs
index 0bcfdb3864f..326b876cf77 100644
--- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs
+++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs
@@ -18,6 +18,7 @@ internal interface IAppHostCliBackchannel
Task GetDashboardUrlsAsync(CancellationToken cancellationToken);
IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken);
IAsyncEnumerable GetResourceStatesAsync(CancellationToken cancellationToken);
+ Task WaitForDisconnectAsync(CancellationToken cancellationToken);
Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken);
Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken);
IAsyncEnumerable GetPublishingActivitiesAsync(CancellationToken cancellationToken);
@@ -34,6 +35,7 @@ internal sealed class AppHostCliBackchannel(
{
private const string BaselineCapability = "baseline.v2";
private TaskCompletionSource _rpcTaskCompletionSource = new();
+ private TaskCompletionSource _disconnectTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
private string? _socketPath;
private bool _autoReconnect;
private CancellationToken _cancellationToken;
@@ -51,6 +53,17 @@ private Task GetRpcTaskAsync()
}
}
+ public async Task WaitForDisconnectAsync(CancellationToken cancellationToken)
+ {
+ Task disconnectTask;
+ lock (_lock)
+ {
+ disconnectTask = _disconnectTaskCompletionSource.Task;
+ }
+
+ await disconnectTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
public async Task RequestStopAsync(CancellationToken cancellationToken)
{
// This RPC call is required to allow the CLI to trigger a clean shutdown
@@ -301,6 +314,10 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
_socketPath = socketPath;
_autoReconnect = autoReconnect;
_cancellationToken = cancellationToken;
+ lock (_lock)
+ {
+ _disconnectTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
var connectingLogLevel = retryCount % 10 == 0 ? LogLevel.Debug : LogLevel.Trace;
logger.Log(connectingLogLevel, "Connecting to AppHost backchannel at {SocketPath} (autoReconnect={AutoReconnect}, retryCount={RetryCount})", socketPath, autoReconnect, retryCount);
@@ -340,6 +357,8 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
);
}
+ rpc.Disconnected += OnRpcDisconnected;
+
// Set up auto-reconnect if enabled
if (autoReconnect)
{
@@ -401,6 +420,15 @@ private void OnDisconnected(object? sender, JsonRpcDisconnectedEventArgs args)
});
}
+ private void OnRpcDisconnected(object? sender, JsonRpcDisconnectedEventArgs args)
+ {
+ logger.LogDebug("Backchannel disconnected: {Reason}", args.Reason);
+ lock (_lock)
+ {
+ _disconnectTaskCompletionSource.TrySetResult();
+ }
+ }
+
private void ResetForReconnection()
{
lock (_lock)
diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
index 475728cdab0..56ffdb74fcb 100644
--- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
+++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs
@@ -160,7 +160,14 @@ await extensionInteractionService.LaunchAppHostAsync(
execution.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(),
options.StartDebugSession);
- _ = StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken);
+ await StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken).ConfigureAwait(false);
+ var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false);
+
+ // The extension launched the AppHost process, so there is no local Process to await.
+ // Keep this CLI process alive because it owns the AppHost backchannel; the extension
+ // will stop the CLI through the RPC endpoint when the managed debug session ends,
+ // or the AppHost backchannel will disconnect if the AppHost exits first.
+ await backchannel.WaitForDisconnectAsync(cancellationToken).ConfigureAwait(false);
return CliExitCodes.Success;
}
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 8d6cc53c443..3a78e399b7b 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -219,8 +219,6 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv
var isMcpStartCommand = args?.Length >= 2 &&
((args[0] == "mcp" && args[1] == "start") || (args[0] == "agent" && args[1] == "mcp"));
- var extensionEndpoint = Environment.GetEnvironmentVariable(KnownConfigNames.ExtensionEndpoint);
-
// Create file logger provider from pre-computed path info
var fileLoggerProvider = new FileLoggerProvider(loggingOptions.LogFilePath, errorWriter);
@@ -253,8 +251,12 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv
builder.AddFilter("Aspire.Cli.Certificates.NativeCertificateToolRunner", LogLevel.Information);
}
- // Configure console logging based on --verbosity or --debug
- if (consoleLogLevel is not null && !isMcpStartCommand && extensionEndpoint is null)
+ // Configure console logging based on --verbosity or --debug.
+ // When the CLI is hosted by the VS Code extension, stderr is captured line-by-line
+ // and surfaced in the Debug Console, so debug logs still need to flow to stderr there;
+ // suppressing them only because an extension endpoint is present meant that
+ // `--debug` produced no visible output under F5, even though it works in a terminal.
+ if (consoleLogLevel is not null && !isMcpStartCommand)
{
// Use custom Spectre Console logger for clean debug output to stderr
builder.AddProvider(new SpectreConsoleLoggerProvider(Console.Error, logBufferContext));
diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs
index b026298b55b..095a1f6363d 100644
--- a/src/Aspire.Cli/Projects/ProjectLocator.cs
+++ b/src/Aspire.Cli/Projects/ProjectLocator.cs
@@ -434,11 +434,80 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand
logger.LogDebug("Stopping AppHost discovery early after finding multiple valid AppHost projects.");
}
+ await AddSettingsAppHostCandidateAsync().ConfigureAwait(false);
+
// This sort is done here to make results deterministic since we get all the app
// host information in parallel and the order may vary.
appHostProjects.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal));
return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects);
+
+ async Task AddSettingsAppHostCandidateAsync()
+ {
+ var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: true, cancellationToken).ConfigureAwait(false);
+ if (settingsAppHost is null)
+ {
+ return;
+ }
+
+ var pathComparison = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))
+ || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)))
+ {
+ return;
+ }
+
+ var handler = projectFactory.TryGetProject(settingsAppHost);
+ if (handler is null)
+ {
+ var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName);
+ if (displayProgress)
+ {
+ interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath));
+ }
+
+ logger.LogDebug("Skipping configured AppHost project {SettingsAppHost} because no project handler was found.", settingsAppHost.FullName);
+ hasUnsupportedProjects = true;
+ return;
+ }
+
+ var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken).ConfigureAwait(false);
+ var settingsAppHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName);
+ if (validationResult.IsValid)
+ {
+ if (displayProgress)
+ {
+ interactionService.DisplaySubtleMessage(settingsAppHostRelativePath);
+ }
+
+ var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId);
+ appHostProjects.Add(appHostProject);
+ await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false);
+ }
+ else if (validationResult.IsPossiblyUnbuildable)
+ {
+ if (displayProgress)
+ {
+ interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, settingsAppHostRelativePath));
+ }
+
+ var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable);
+ unbuildableSuspectedAppHostProjects.Add(appHostProject);
+ await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false);
+ }
+ else if (validationResult.IsUnsupported)
+ {
+ if (displayProgress)
+ {
+ interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, settingsAppHostRelativePath));
+ }
+
+ logger.LogDebug("Skipping unsupported configured AppHost project {SettingsAppHost}", settingsAppHost.FullName);
+ hasUnsupportedProjects = true;
+ }
+ }
}
if (displayProgress)
diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
index d0324a42d14..855df1ccf12 100644
--- a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
+++ b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore
@@ -1,2 +1,3 @@
.java-build/
.aspire/
+.modules/
diff --git a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
index 615cf938939..050768f6060 100644
--- a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
+++ b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore
@@ -2,3 +2,4 @@ node_modules/
dist/
.venv/
.aspire/
+.modules/
diff --git a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore
index b9485af2bd7..c420eff27d7 100644
--- a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore
+++ b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore
@@ -1,3 +1,4 @@
node_modules/
dist/
.aspire/
+.modules/
diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs
index 26a0394f69c..59a2655e32b 100644
--- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs
@@ -164,6 +164,46 @@ public async Task LsCommand_JsonFormat_WhenNoCandidates_ReturnsEmptyArray()
Assert.Equal("", stderrText);
}
+ [Fact]
+ public async Task LsCommand_JsonFormat_IncludesConfiguredAppHostOutsideWorkingDirectory()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var textWriter = new TestOutputTextWriter(outputHelper);
+ var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("WorkingDir");
+ var configuredAppHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "ConfiguredAppHost.csproj"));
+ await File.WriteAllTextAsync(configuredAppHost.FullName, "Not a real apphost");
+ await File.WriteAllTextAsync(Path.Combine(workingDirectory.FullName, "aspire.config.json"), JsonSerializer.Serialize(new
+ {
+ appHost = new
+ {
+ path = "../ConfiguredAppHost.csproj"
+ }
+ }));
+
+ var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
+ {
+ options.WorkingDirectory = workingDirectory;
+ options.OutputTextWriter = textWriter;
+ options.AppHostProjectFactory = _ => new TestAppHostProjectFactory();
+ });
+ using var provider = services.BuildServiceProvider();
+
+ var command = provider.GetRequiredService();
+ var result = command.Parse("ls --format json");
+
+ var exitCode = await result.InvokeAsync().DefaultTimeout();
+
+ Assert.Equal(CliExitCodes.Success, exitCode);
+
+ var jsonOutput = string.Join(string.Empty, textWriter.Logs);
+ var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo);
+ Assert.NotNull(candidateAppHosts);
+ var candidate = Assert.Single(candidateAppHosts);
+ Assert.Equal(configuredAppHost.FullName, candidate.Path);
+ Assert.Equal(KnownLanguageId.CSharp, candidate.Language);
+ Assert.Equal("buildable", candidate.Status);
+ }
+
[Fact]
public async Task LsCommand_JsonFormat_OnlyJsonOnStdout_StatusMessagesOnStderr()
{
diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs
index d226eac99fc..3e554a7f97b 100644
--- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs
@@ -831,6 +831,7 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat
}
public Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task WaitForDisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task GetCapabilitiesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { "baseline.v2" });
public Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) =>
diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs
index b047cd2537f..6a1a7a8266c 100644
--- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs
+++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs
@@ -800,13 +800,17 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue()
}
[Fact]
- public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected()
+ public async Task RunAsyncKeepsExtensionLaunchedAppHostAliveUntilBackchannelDisconnects()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
- var launchAppHostCalledTcs = new TaskCompletionSource();
+ var launchAppHostCalledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var backchannel = new TestAppHostBackchannel
+ {
+ ConnectAsyncCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
+ };
TestExtensionInteractionService? testExtensionInteractionService = null;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
@@ -826,25 +830,36 @@ public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected()
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "devkit" or "project"),
};
- options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel();
+ options.AppHostBackchannelFactory = _ => backchannel;
});
using var provider = services.BuildServiceProvider();
var runner = provider.GetRequiredService();
+ var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var exitCode = await runner.RunAsync(
+ var runTask = runner.RunAsync(
projectFile: projectFile,
watch: false,
noBuild: false,
noRestore: false,
args: [],
- env: null,
- backchannelCompletionSource: new TaskCompletionSource(),
+ env: new Dictionary
+ {
+ [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
+ },
+ backchannelCompletionSource,
options: new ProcessInvocationOptions(),
- cancellationToken: CancellationToken.None).DefaultTimeout();
+ cancellationToken: CancellationToken.None);
await launchAppHostCalledTcs.Task.DefaultTimeout();
- Assert.Equal(CliExitCodes.Success, exitCode);
+ await backchannel.ConnectAsyncCalled.Task.DefaultTimeout();
+ Assert.Same(backchannel, await backchannelCompletionSource.Task.DefaultTimeout());
+
+ var completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
+ Assert.NotSame(runTask, completedTask);
+
+ backchannel.DisconnectCompletionSource.SetResult();
+ Assert.Equal(CliExitCodes.Success, await runTask.DefaultTimeout());
}
[Fact]
@@ -876,23 +891,20 @@ public async Task RunAsyncFailsBackchannelWhenExtensionLaunchedAppHostDoesNotCon
var runner = provider.GetRequiredService();
var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var exitCode = await runner.RunAsync(
- projectFile: projectFile,
- watch: false,
- noBuild: false,
- noRestore: false,
- args: [],
- env: new Dictionary
- {
- [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
- },
- backchannelCompletionSource,
- new ProcessInvocationOptions(),
- CancellationToken.None).DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
var exception = await Assert.ThrowsAsync(
- () => backchannelCompletionSource.Task.WaitAsync(TimeSpan.FromSeconds(3)));
+ () => runner.RunAsync(
+ projectFile: projectFile,
+ watch: false,
+ noBuild: false,
+ noRestore: false,
+ args: [],
+ env: new Dictionary
+ {
+ [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
+ },
+ backchannelCompletionSource,
+ new ProcessInvocationOptions(),
+ CancellationToken.None).DefaultTimeout());
Assert.Contains("Timed out waiting for AppHost backchannel", exception.Message);
}
diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs
index fadc6f70402..8fc9e2bdb46 100644
--- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs
+++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs
@@ -442,7 +442,12 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new
}));
var executionContext = CreateExecutionContext(workingDirectory);
- var projectLocator = CreateProjectLocator(executionContext);
+ var displayedSubtleMessages = new List();
+ var interactionService = new TestInteractionService
+ {
+ DisplaySubtleMessageCallback = displayedSubtleMessages.Add
+ };
+ var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService);
var result = await projectLocator.UseOrFindAppHostProjectFileAsync(
projectFile: null,
@@ -452,6 +457,41 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new
Assert.Equal(settingsAppHostFile.FullName, result.SelectedProjectFile?.FullName);
Assert.Contains(result.AllProjectFileCandidates, file => file.FullName == settingsAppHostFile.FullName);
+ Assert.Contains(Path.Join("..", "SettingsAppHost.csproj"), displayedSubtleMessages);
+ }
+
+ [Fact]
+ public async Task UseOrFindAppHostProjectFileTreatsSettingsAppHostWithoutProjectHandlerAsUnsupported()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+
+ var settingsAppHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.custom"));
+ await File.WriteAllTextAsync(settingsAppHostFile.FullName, "Not a supported apphost type");
+
+ var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
+ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new
+ {
+ appHost = new
+ {
+ path = "apphost.custom"
+ }
+ }));
+
+ var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
+ var interactionService = new TestInteractionService();
+ var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ projectLocator.UseOrFindAppHostProjectFileAsync(
+ projectFile: null,
+ multipleAppHostProjectsFoundBehavior: MultipleAppHostProjectsFoundBehavior.None,
+ createSettingsFile: false,
+ CancellationToken.None)).DefaultTimeout();
+
+ Assert.Equal(ProjectLocatorFailureReason.UnsupportedProjects, exception.FailureReason);
+ var warning = Assert.Single(interactionService.DisplayedMessages);
+ Assert.Equal(KnownEmojis.Warning, warning.Emoji);
+ Assert.Contains("apphost.custom", warning.Message);
}
[Fact]
diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs
index 565c289dea8..f80db942b48 100644
--- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs
+++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs
@@ -23,6 +23,7 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel
public TaskCompletionSource? ConnectAsyncCalled { get; set; }
public Func? ConnectAsyncCallback { get; set; }
+ public TaskCompletionSource DisconnectCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource? GetPublishingActivitiesAsyncCalled { get; set; }
public Func>? GetPublishingActivitiesAsyncCallback { get; set; }
@@ -115,6 +116,11 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC
}
}
+ public async Task WaitForDisconnectAsync(CancellationToken cancellationToken)
+ {
+ await DisconnectCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
public async IAsyncEnumerable GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
{
GetPublishingActivitiesAsyncCalled?.SetResult();