Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
56e811c
Fix VS Code AppHost launch path resolution
davidfowl May 23, 2026
8d5d73a
Use aspire ls for extension AppHost discovery
davidfowl May 23, 2026
dd5ef0b
Refresh AppHost discovery consumers on changes
davidfowl May 23, 2026
a16414d
Use CLI language ids for AppHost discovery
davidfowl May 23, 2026
2cd4877
Handle AppHost discovery failures in editor commands
davidfowl May 23, 2026
7991102
Add TypeScript AppHost launch discovery coverage
davidfowl May 23, 2026
c3c8505
Harden AppHost discovery process handling
davidfowl May 24, 2026
63b84c5
Honor configured AppHosts in discovery
davidfowl May 25, 2026
befdbf2
Merge origin/main into PR branch
davidfowl May 25, 2026
192ac6a
Consolidate extension AppHost discovery
davidfowl May 25, 2026
db92159
Fix workspace test path separators
davidfowl May 25, 2026
6e8ce79
Merge branch 'main' into codex/vscode-apphost-launch-path
adamint May 27, 2026
257c815
Fix AppHost configured path selection
adamint May 27, 2026
6f0b51e
Keep extension-launched AppHost CLI alive
adamint May 27, 2026
72eca70
Update extension build and CLI debug logging
adamint May 27, 2026
ec5d5a0
Address AppHost launch review feedback
adamint May 27, 2026
0b79866
Address extension discovery review feedback
adamint May 27, 2026
dcb07bd
Stabilize pipeline unit tests without Docker
adamint May 27, 2026
424c0d5
Use ordinal comparison in pipeline test provider
adamint May 27, 2026
3c027ec
Revert pipeline test isolation changes
adamint May 27, 2026
134d0ca
Merge upstream main into PR branch
adamint May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions extension/src/debugger/AspireDebugConfigurationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { defaultConfigurationName } from '../loc/strings';
import { resolveAppHostLaunchPath } from '../utils/appHostLaunchPath';
import { checkCliAvailableOrRedirect } from '../utils/workspace';

export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
Expand Down Expand Up @@ -44,4 +45,12 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati

return config;
}

async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
if (typeof config.program === 'string') {
config.program = await resolveAppHostLaunchPath(config.program);
}

return config;
}
}
3 changes: 2 additions & 1 deletion extension/src/editor/AspireEditorCommandProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { noAppHostInWorkspace } from '../loc/strings';
import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions';
import { AspireCommandType } from '../dcp/types';
import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes';
import { resolveAppHostLaunchPath } from '../utils/appHostLaunchPath';

export class AspireEditorCommandProvider implements vscode.Disposable {
private _workspaceAppHostPath: string | null = null;
Expand Down Expand Up @@ -204,7 +205,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
*/
public async getAppHostPath(): Promise<string | null> {
if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
return vscode.window.activeTextEditor.document.uri.fsPath;
return await resolveAppHostLaunchPath(vscode.window.activeTextEditor.document.uri.fsPath);
}

return this._workspaceAppHostPath;
Expand Down
73 changes: 73 additions & 0 deletions extension/src/test/aspireDebugConfigurationProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/// <reference types="mocha" />

import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider';

suite('AspireDebugConfigurationProvider', () => {
let tempDir: string;

setup(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-'));
});

teardown(() => {
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, '<Project Sdk="Microsoft.NET.Sdk" />');

const provider = new AspireDebugConfigurationProvider();
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();
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'), '<Project Sdk="Microsoft.NET.Sdk" />');

const provider = new AspireDebugConfigurationProvider();
const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, {
name: 'Debug AppHost',
type: 'aspire',
request: 'launch',
program: programPath
});

assert.strictEqual(config?.program, programPath);
});
});
91 changes: 91 additions & 0 deletions extension/src/test/aspireEditorCommandProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/// <reference types="mocha" />

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';

function createEditor(filePath: string): vscode.TextEditor {
return {
document: {
uri: vscode.Uri.file(filePath),
fileName: filePath,
languageId: '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, '<Project Sdk="Microsoft.NET.Sdk" />');
activeEditor = createEditor(programPath);

const provider = new AspireEditorCommandProvider();
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();
try {
assert.strictEqual(await provider.getAppHostPath(), appHostPath);
}
finally {
provider.dispose();
}
});
});
88 changes: 88 additions & 0 deletions extension/src/utils/appHostLaunchPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Dirent } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as vscode from 'vscode';

export async function resolveAppHostLaunchPath(filePath: string): Promise<string> {
if (path.extname(filePath).toLowerCase() !== '.cs') {
return filePath;
}

let fileText: string;
try {
fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString());
}
catch {
return filePath;
}

const lines = fileText.split(/\r?\n/);

// Single-file C# AppHosts are launched directly from source and start with:
// #:sdk Aspire.AppHost.Sdk
// The CLI accepts this source file shape, so do not rewrite it to a project path.
if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) {
return filePath;
}

if (!lines.some(line => line.includes('DistributedApplication.CreateBuilder'))) {
return filePath;
}
Comment thread
davidfowl marked this conversation as resolved.
Outdated

// SDK-style C# AppHosts usually launch from Program.cs:
// var builder = DistributedApplication.CreateBuilder(args);
// The CLI needs the containing .csproj instead of Program.cs so the AppHost SDK
// and project references load.
return await tryFindContainingProjectFile(filePath) ?? filePath;
}

async function tryFindContainingProjectFile(filePath: string): Promise<string | null> {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath));
const workspaceRoot = workspaceFolder?.uri.fsPath;
let directory = path.dirname(filePath);

while (true) {
const projectFile = await tryGetProjectFileInDirectory(directory);
if (projectFile !== undefined) {
return projectFile;
}

if (workspaceRoot && path.resolve(directory) === path.resolve(workspaceRoot)) {
return null;
}

const parent = path.dirname(directory);
if (parent === directory) {
return null;
}

directory = parent;
}
}

async function tryGetProjectFileInDirectory(directory: string): Promise<string | null | undefined> {
let entries: Dirent[];
try {
entries = await fs.readdir(directory, { withFileTypes: true });
}
catch {
return undefined;
}

const projectFiles = entries
.filter(entry => entry.isFile() && /\.(csproj|fsproj|vbproj)$/i.test(entry.name))
.map(entry => entry.name);

if (projectFiles.length === 0) {
return undefined;
}

if (projectFiles.length === 1) {
return path.join(directory, projectFiles[0]);
}

const directoryName = path.basename(directory);
const matchingProjectFile = projectFiles.find(projectFile =>
path.basename(projectFile, path.extname(projectFile)).toLowerCase() === directoryName.toLowerCase());
return matchingProjectFile ? path.join(directory, matchingProjectFile) : null;
}
Loading