diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index 73088ed32fb..bbe98c2cc15 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -10,6 +10,9 @@
Aspire CLI Version: {0}.
+
+ Aspire CLI found at {0}. The extension will use this path.
+
Aspire CLI is not available on PATH. Please install it and restart VS Code.
diff --git a/extension/package.nls.json b/extension/package.nls.json
index 75e0719f912..03c1794715e 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -93,6 +93,7 @@
"aspire-vscode.strings.lookingForDevkitBuildTask": "C# Dev Kit is installed, looking for C# Dev Kit build task...",
"aspire-vscode.strings.csharpDevKitNotInstalled": "C# Dev Kit is not installed, building using dotnet CLI...",
"aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.",
+ "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.",
"aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions",
"aspire-vscode.strings.dismissLabel": "Dismiss"
}
diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts
index 5d8bd3307a7..e1e158d7b4b 100644
--- a/extension/src/commands/add.ts
+++ b/extension/src/commands/add.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
export async function addCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('add');
+ await terminalProvider.sendAspireCommandToAspireTerminal('add');
}
diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts
index a40590e1891..057d419f6ca 100644
--- a/extension/src/commands/deploy.ts
+++ b/extension/src/commands/deploy.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
export async function deployCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('deploy');
+ await terminalProvider.sendAspireCommandToAspireTerminal('deploy');
}
diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts
index 642bfa23aa3..3d6c60e25d9 100644
--- a/extension/src/commands/init.ts
+++ b/extension/src/commands/init.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from "../utils/AspireTerminalProvider";
export async function initCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('init');
+ await terminalProvider.sendAspireCommandToAspireTerminal('init');
};
\ No newline at end of file
diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts
index d8a26eab433..ab2936e0af3 100644
--- a/extension/src/commands/new.ts
+++ b/extension/src/commands/new.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from "../utils/AspireTerminalProvider";
export async function newCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('new');
+ await terminalProvider.sendAspireCommandToAspireTerminal('new');
};
diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts
index 181d590337a..276ea03a7a8 100644
--- a/extension/src/commands/publish.ts
+++ b/extension/src/commands/publish.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
export async function publishCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('publish');
+ await terminalProvider.sendAspireCommandToAspireTerminal('publish');
}
diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts
index 31ab5b9f89e..23e8070920e 100644
--- a/extension/src/commands/update.ts
+++ b/extension/src/commands/update.ts
@@ -1,5 +1,5 @@
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
export async function updateCommand(terminalProvider: AspireTerminalProvider) {
- terminalProvider.sendAspireCommandToAspireTerminal('update');
+ await terminalProvider.sendAspireCommandToAspireTerminal('update');
}
diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts
index ba4c8d98c14..643db6ed958 100644
--- a/extension/src/debugger/AspireDebugConfigurationProvider.ts
+++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts
@@ -1,15 +1,8 @@
import * as vscode from 'vscode';
import { defaultConfigurationName } from '../loc/strings';
-import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { checkCliAvailableOrRedirect } from '../utils/workspace';
export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
- private _terminalProvider: AspireTerminalProvider;
-
- constructor(terminalProvider: AspireTerminalProvider) {
- this._terminalProvider = terminalProvider;
- }
-
async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise {
if (folder === undefined) {
return [];
@@ -28,9 +21,8 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise {
// Check if CLI is available before starting debug session
- const cliPath = this._terminalProvider.getAspireCliExecutablePath();
- const isCliAvailable = await checkCliAvailableOrRedirect(cliPath);
- if (!isCliAvailable) {
+ const result = await checkCliAvailableOrRedirect();
+ if (!result.available) {
return undefined; // Cancel the debug session
}
diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts
index bc35aceeb6c..434eb0cf793 100644
--- a/extension/src/debugger/AspireDebugSession.ts
+++ b/extension/src/debugger/AspireDebugSession.ts
@@ -29,6 +29,8 @@ export class AspireDebugSession implements vscode.DebugAdapter {
private _trackedDebugAdapters: string[] = [];
private _rpcClient?: ICliRpcClient;
private readonly _disposables: vscode.Disposable[] = [];
+ private _disposed = false;
+ private _userInitiatedStop = false;
public readonly onDidSendMessage = this._onDidSendMessage.event;
public readonly debugSessionId: string;
@@ -93,18 +95,19 @@ export class AspireDebugSession implements vscode.DebugAdapter {
if (isDirectory(appHostPath)) {
this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath));
- this.spawnRunCommand(args, appHostPath, noDebug);
+ void this.spawnRunCommand(args, appHostPath, noDebug);
}
else {
this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath));
const workspaceFolder = path.dirname(appHostPath);
args.push('--project', appHostPath);
- this.spawnRunCommand(args, workspaceFolder, noDebug);
+ void this.spawnRunCommand(args, workspaceFolder, noDebug);
}
}
else if (message.command === 'disconnect' || message.command === 'terminate') {
this.sendMessageWithEmoji("🔌", disconnectingFromSession);
+ this._userInitiatedStop = true;
this.dispose();
this.sendEvent({
@@ -133,7 +136,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
}
- spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) {
+ async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) {
const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => {
if (client.debugSessionId === this.debugSessionId) {
this._rpcClient = client;
@@ -143,7 +146,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
spawnCliProcess(
this._terminalProvider,
- this._terminalProvider.getAspireCliExecutablePath(),
+ await this._terminalProvider.getAspireCliExecutablePath(),
args,
{
stdoutCallback: (data) => {
@@ -173,7 +176,9 @@ export class AspireDebugSession implements vscode.DebugAdapter {
this._disposables.push({
dispose: () => {
- this._rpcClient?.stopCli();
+ this._rpcClient?.stopCli().catch((err) => {
+ extensionLogOutputChannel.info(`stopCli failed (connection may already be closed): ${err}`);
+ });
extensionLogOutputChannel.info(`Requested Aspire CLI exit with args: ${args.join(' ')}`);
}
});
@@ -219,9 +224,15 @@ export class AspireDebugSession implements vscode.DebugAdapter {
const disposable = vscode.debug.onDidTerminateDebugSession(async session => {
if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) {
- // We should also dispose of the parent Aspire debug session whenever the AppHost stops.
+ const shouldRestart = !this._userInitiatedStop;
+ const config = this.configuration;
+ // Always dispose the current Aspire debug session when the AppHost stops.
this.dispose();
- disposable.dispose();
+
+ if (shouldRestart) {
+ extensionLogOutputChannel.info('AppHost terminated unexpectedly, restarting Aspire debug session');
+ await vscode.debug.startDebugging(undefined, config);
+ }
}
});
@@ -281,11 +292,14 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
dispose(): void {
+ if (this._disposed) {
+ return;
+ }
+ this._disposed = true;
extensionLogOutputChannel.info('Stopping the Aspire debug session');
vscode.debug.stopDebugging(this._session);
this._disposables.forEach(disposable => disposable.dispose());
this._trackedDebugAdapters = [];
- this._rpcClient?.stopCli();
}
private sendResponse(request: any, body: any = {}) {
diff --git a/extension/src/extension.ts b/extension/src/extension.ts
index f2e2c44f8eb..de001575696 100644
--- a/extension/src/extension.ts
+++ b/extension/src/extension.ts
@@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration);
context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration);
- const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider);
+ const debugConfigProvider = new AspireDebugConfigurationProvider();
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic)
);
@@ -114,9 +114,8 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe
const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"];
if (!cliCheckExcludedCommands.includes(commandName)) {
- const cliPath = terminalProvider.getAspireCliExecutablePath();
- const isCliAvailable = await checkCliAvailableOrRedirect(cliPath);
- if (!isCliAvailable) {
+ const result = await checkCliAvailableOrRedirect();
+ if (!result.available) {
return;
}
}
diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts
index 484ca92ec30..1b02e953ff7 100644
--- a/extension/src/loc/strings.ts
+++ b/extension/src/loc/strings.ts
@@ -71,3 +71,4 @@ export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not install
export const dismissLabel = vscode.l10n.t('Dismiss');
export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions');
export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.');
+export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path);
diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts
index dc70ca4c3fb..fa139b51715 100644
--- a/extension/src/test/aspireTerminalProvider.test.ts
+++ b/extension/src/test/aspireTerminalProvider.test.ts
@@ -2,94 +2,58 @@ import * as assert from 'assert';
import * as vscode from 'vscode';
import * as sinon from 'sinon';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
+import * as cliPathModule from '../utils/cliPath';
suite('AspireTerminalProvider tests', () => {
let terminalProvider: AspireTerminalProvider;
- let configStub: sinon.SinonStub;
+ let resolveCliPathStub: sinon.SinonStub;
let subscriptions: vscode.Disposable[];
setup(() => {
subscriptions = [];
terminalProvider = new AspireTerminalProvider(subscriptions);
- configStub = sinon.stub(vscode.workspace, 'getConfiguration');
+ resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath');
});
teardown(() => {
- configStub.restore();
+ resolveCliPathStub.restore();
subscriptions.forEach(s => s.dispose());
});
suite('getAspireCliExecutablePath', () => {
- test('returns "aspire" when no custom path is configured', () => {
- configStub.returns({
- get: sinon.stub().returns('')
- });
+ test('returns "aspire" when CLI is on PATH', async () => {
+ resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' });
- const result = terminalProvider.getAspireCliExecutablePath();
+ const result = await terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, 'aspire');
});
- test('returns custom path when configured', () => {
- configStub.returns({
- get: sinon.stub().returns('/usr/local/bin/aspire')
- });
+ test('returns resolved path when CLI found at default install location', async () => {
+ resolveCliPathStub.resolves({ cliPath: '/home/user/.aspire/bin/aspire', available: true, source: 'default-install' });
- const result = terminalProvider.getAspireCliExecutablePath();
- assert.strictEqual(result, '/usr/local/bin/aspire');
+ const result = await terminalProvider.getAspireCliExecutablePath();
+ assert.strictEqual(result, '/home/user/.aspire/bin/aspire');
});
- test('returns custom path with spaces', () => {
- configStub.returns({
- get: sinon.stub().returns('/my path/with spaces/aspire')
- });
-
- const result = terminalProvider.getAspireCliExecutablePath();
- assert.strictEqual(result, '/my path/with spaces/aspire');
- });
+ test('returns configured custom path', async () => {
+ resolveCliPathStub.resolves({ cliPath: '/usr/local/bin/aspire', available: true, source: 'configured' });
- test('trims whitespace from configured path', () => {
- configStub.returns({
- get: sinon.stub().returns(' /usr/local/bin/aspire ')
- });
-
- const result = terminalProvider.getAspireCliExecutablePath();
+ const result = await terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, '/usr/local/bin/aspire');
});
- test('returns "aspire" when configured path is only whitespace', () => {
- configStub.returns({
- get: sinon.stub().returns(' ')
- });
+ test('returns "aspire" when CLI is not found', async () => {
+ resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' });
- const result = terminalProvider.getAspireCliExecutablePath();
+ const result = await terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, 'aspire');
});
- test('handles Windows-style paths', () => {
- configStub.returns({
- get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe')
- });
+ test('handles Windows-style paths', async () => {
+ resolveCliPathStub.resolves({ cliPath: 'C:\\Program Files\\Aspire\\aspire.exe', available: true, source: 'configured' });
- const result = terminalProvider.getAspireCliExecutablePath();
+ const result = await terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe');
});
-
- test('handles Windows-style paths without spaces', () => {
- configStub.returns({
- get: sinon.stub().returns('C:\\aspire\\aspire.exe')
- });
-
- const result = terminalProvider.getAspireCliExecutablePath();
- assert.strictEqual(result, 'C:\\aspire\\aspire.exe');
- });
-
- test('handles paths with special characters', () => {
- configStub.returns({
- get: sinon.stub().returns('/path/with$dollar/aspire')
- });
-
- const result = terminalProvider.getAspireCliExecutablePath();
- assert.strictEqual(result, '/path/with$dollar/aspire');
- });
});
});
diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts
new file mode 100644
index 00000000000..e70519b3ebe
--- /dev/null
+++ b/extension/src/test/cliPath.test.ts
@@ -0,0 +1,211 @@
+import * as assert from 'assert';
+import * as sinon from 'sinon';
+import * as os from 'os';
+import * as path from 'path';
+import { getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies } from '../utils/cliPath';
+
+const bundlePath = '/home/user/.aspire/bin/aspire';
+const globalToolPath = '/home/user/.dotnet/tools/aspire';
+const defaultPaths = [bundlePath, globalToolPath];
+
+function createMockDeps(overrides: Partial = {}): CliPathDependencies {
+ return {
+ getConfiguredPath: () => '',
+ getDefaultPaths: () => defaultPaths,
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => undefined,
+ tryExecute: async () => false,
+ setConfiguredPath: async () => {},
+ ...overrides,
+ };
+}
+
+suite('utils/cliPath tests', () => {
+
+ suite('getDefaultCliInstallPaths', () => {
+ test('returns bundle path (~/.aspire/bin) as first entry', () => {
+ const paths = getDefaultCliInstallPaths();
+ const homeDir = os.homedir();
+
+ assert.ok(paths.length >= 2, 'Should return at least 2 default paths');
+ assert.ok(paths[0].startsWith(path.join(homeDir, '.aspire', 'bin')), `First path should be bundle install: ${paths[0]}`);
+ });
+
+ test('returns global tool path (~/.dotnet/tools) as second entry', () => {
+ const paths = getDefaultCliInstallPaths();
+ const homeDir = os.homedir();
+
+ assert.ok(paths[1].startsWith(path.join(homeDir, '.dotnet', 'tools')), `Second path should be global tool: ${paths[1]}`);
+ });
+
+ test('uses correct executable name for current platform', () => {
+ const paths = getDefaultCliInstallPaths();
+
+ for (const p of paths) {
+ const basename = path.basename(p);
+ if (process.platform === 'win32') {
+ assert.strictEqual(basename, 'aspire.exe');
+ } else {
+ assert.strictEqual(basename, 'aspire');
+ }
+ }
+ });
+ });
+
+ suite('resolveCliPath', () => {
+ test('falls back to default install path when CLI is not on PATH', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => bundlePath,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.available, true);
+ assert.strictEqual(result.source, 'default-install');
+ assert.strictEqual(result.cliPath, bundlePath);
+ assert.ok(setConfiguredPath.calledOnceWith(bundlePath), 'should update the VS Code setting to the found path');
+ });
+
+ test('updates VS Code setting when CLI found at default path but not on PATH', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => '',
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => bundlePath,
+ setConfiguredPath,
+ });
+
+ await resolveCliPath(deps);
+
+ assert.ok(setConfiguredPath.calledOnce, 'setConfiguredPath should be called once');
+ assert.strictEqual(setConfiguredPath.firstCall.args[0], bundlePath, 'should set the path to the found install location');
+ });
+
+ test('prefers PATH over default install path', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ isOnPath: async () => true,
+ findAtDefaultPath: async () => bundlePath,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.available, true);
+ assert.strictEqual(result.source, 'path');
+ assert.strictEqual(result.cliPath, 'aspire');
+ assert.ok(setConfiguredPath.notCalled, 'should not update settings when CLI is on PATH');
+ });
+
+ test('clears setting when CLI is on PATH and setting was previously set to a default path', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => bundlePath,
+ isOnPath: async () => true,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.source, 'path');
+ assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting');
+ });
+
+ test('clears setting when CLI is on PATH and setting was previously set to global tool path', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => globalToolPath,
+ isOnPath: async () => true,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.source, 'path');
+ assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting');
+ });
+
+ test('returns not-found when CLI is not on PATH and not at any default path', async () => {
+ const deps = createMockDeps({
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => undefined,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.available, false);
+ assert.strictEqual(result.source, 'not-found');
+ });
+
+ test('uses custom configured path when valid and not a default', async () => {
+ const customPath = '/custom/path/aspire';
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => customPath,
+ tryExecute: async (p) => p === customPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.available, true);
+ assert.strictEqual(result.source, 'configured');
+ assert.strictEqual(result.cliPath, customPath);
+ });
+
+ test('falls through to PATH check when custom configured path is invalid', async () => {
+ const deps = createMockDeps({
+ getConfiguredPath: () => '/bad/path/aspire',
+ tryExecute: async () => false,
+ isOnPath: async () => true,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.source, 'path');
+ assert.strictEqual(result.available, true);
+ });
+
+ test('falls through to default path when custom configured path is invalid and not on PATH', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => '/bad/path/aspire',
+ tryExecute: async () => false,
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => bundlePath,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.source, 'default-install');
+ assert.strictEqual(result.cliPath, bundlePath);
+ assert.ok(setConfiguredPath.calledOnceWith(bundlePath));
+ });
+
+ test('does not update setting when already set to the found default path', async () => {
+ const setConfiguredPath = sinon.stub().resolves();
+
+ const deps = createMockDeps({
+ getConfiguredPath: () => bundlePath,
+ isOnPath: async () => false,
+ findAtDefaultPath: async () => bundlePath,
+ setConfiguredPath,
+ });
+
+ const result = await resolveCliPath(deps);
+
+ assert.strictEqual(result.source, 'default-install');
+ assert.ok(setConfiguredPath.notCalled, 'should not re-set the path if it already matches');
+ });
+ });
+});
+
diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts
index 35762287729..95ed6bf5426 100644
--- a/extension/src/utils/AspireTerminalProvider.ts
+++ b/extension/src/utils/AspireTerminalProvider.ts
@@ -5,6 +5,7 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer';
import { DcpServerConnectionInfo } from '../dcp/types';
import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities';
import { EnvironmentVariables } from './environment';
+import { resolveCliPath } from './cliPath';
import path from 'path';
export const enum AnsiColors {
@@ -57,8 +58,8 @@ export class AspireTerminalProvider implements vscode.Disposable {
this._dcpServerConnectionInfo = value;
}
- sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) {
- const cliPath = this.getAspireCliExecutablePath();
+ async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) {
+ const cliPath = await this.getAspireCliExecutablePath();
// On Windows, use & to execute paths, especially those with special characters
// On Unix, just use the path directly
@@ -200,15 +201,9 @@ export class AspireTerminalProvider implements vscode.Disposable {
}
- getAspireCliExecutablePath(): string {
- const aspireCliPath = vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '');
- if (aspireCliPath && aspireCliPath.trim().length > 0) {
- extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`);
- return aspireCliPath.trim();
- }
-
- extensionLogOutputChannel.debug('No user-configured Aspire CLI path found');
- return "aspire";
+ async getAspireCliExecutablePath(): Promise {
+ const result = await resolveCliPath();
+ return result.cliPath;
}
isCliDebugLoggingEnabled(): boolean {
diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts
new file mode 100644
index 00000000000..6290ac6d945
--- /dev/null
+++ b/extension/src/utils/cliPath.ts
@@ -0,0 +1,194 @@
+import * as vscode from 'vscode';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { extensionLogOutputChannel } from './logging';
+
+const execFileAsync = promisify(execFile);
+const fsAccessAsync = promisify(fs.access);
+
+/**
+ * Gets the default installation paths for the Aspire CLI, in priority order.
+ *
+ * The CLI can be installed in two ways:
+ * 1. Bundle install (recommended): ~/.aspire/bin/aspire
+ * 2. .NET global tool: ~/.dotnet/tools/aspire
+ *
+ * @returns An array of default CLI paths to check, ordered by priority
+ */
+export function getDefaultCliInstallPaths(): string[] {
+ const homeDir = os.homedir();
+ const exeName = process.platform === 'win32' ? 'aspire.exe' : 'aspire';
+
+ return [
+ // Bundle install (recommended): ~/.aspire/bin/aspire
+ path.join(homeDir, '.aspire', 'bin', exeName),
+ // .NET global tool: ~/.dotnet/tools/aspire
+ path.join(homeDir, '.dotnet', 'tools', exeName),
+ ];
+}
+
+/**
+ * Checks if a file exists and is accessible.
+ */
+async function fileExists(filePath: string): Promise {
+ try {
+ await fsAccessAsync(filePath, fs.constants.F_OK);
+ return true;
+ }
+ catch {
+ return false;
+ }
+}
+
+/**
+ * Tries to execute the CLI at the given path to verify it works.
+ */
+async function tryExecuteCli(cliPath: string): Promise {
+ try {
+ await execFileAsync(cliPath, ['--version'], { timeout: 5000 });
+ return true;
+ }
+ catch {
+ return false;
+ }
+}
+
+/**
+ * Checks if the Aspire CLI is available on the system PATH.
+ */
+export async function isCliOnPath(): Promise {
+ return await tryExecuteCli('aspire');
+}
+
+/**
+ * Finds the first default installation path where the Aspire CLI exists and is executable.
+ *
+ * @returns The path where CLI was found, or undefined if not found at any default location
+ */
+export async function findCliAtDefaultPath(): Promise {
+ for (const defaultPath of getDefaultCliInstallPaths()) {
+ if (await fileExists(defaultPath) && await tryExecuteCli(defaultPath)) {
+ return defaultPath;
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Gets the VS Code configuration setting for the Aspire CLI path.
+ */
+export function getConfiguredCliPath(): string {
+ return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim();
+}
+
+/**
+ * Updates the VS Code configuration setting for the Aspire CLI path.
+ * Uses ConfigurationTarget.Global to set it at the user level.
+ */
+export async function setConfiguredCliPath(cliPath: string): Promise {
+ extensionLogOutputChannel.info(`Setting aspire.aspireCliExecutablePath to: ${cliPath || '(empty)'}`);
+ await vscode.workspace.getConfiguration('aspire').update(
+ 'aspireCliExecutablePath',
+ cliPath || undefined, // Use undefined to remove the setting
+ vscode.ConfigurationTarget.Global
+ );
+}
+
+/**
+ * Result of checking CLI availability.
+ */
+export interface CliPathResolutionResult {
+ /** The resolved CLI path to use */
+ cliPath: string;
+ /** Whether the CLI is available */
+ available: boolean;
+ /** Where the CLI was found */
+ source: 'path' | 'default-install' | 'configured' | 'not-found';
+}
+
+/**
+ * Dependencies for resolveCliPath that can be overridden for testing.
+ */
+export interface CliPathDependencies {
+ getConfiguredPath: () => string;
+ getDefaultPaths: () => string[];
+ isOnPath: () => Promise;
+ findAtDefaultPath: () => Promise;
+ tryExecute: (cliPath: string) => Promise;
+ setConfiguredPath: (cliPath: string) => Promise;
+}
+
+const defaultDependencies: CliPathDependencies = {
+ getConfiguredPath: getConfiguredCliPath,
+ getDefaultPaths: getDefaultCliInstallPaths,
+ isOnPath: isCliOnPath,
+ findAtDefaultPath: findCliAtDefaultPath,
+ tryExecute: tryExecuteCli,
+ setConfiguredPath: setConfiguredCliPath,
+};
+
+/**
+ * Resolves the Aspire CLI path, checking multiple locations in order:
+ * 1. User-configured path in VS Code settings
+ * 2. System PATH
+ * 3. Default installation directories (~/.aspire/bin, ~/.dotnet/tools)
+ *
+ * If the CLI is found at a default installation path but not on PATH,
+ * the VS Code setting is updated to use that path.
+ *
+ * If the CLI is on PATH and a setting was previously auto-configured to a default path,
+ * the setting is cleared to prefer PATH.
+ */
+export async function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise {
+ const configuredPath = deps.getConfiguredPath();
+ const defaultPaths = deps.getDefaultPaths();
+
+ // 1. Check if user has configured a custom path (not one of the defaults)
+ if (configuredPath && !defaultPaths.includes(configuredPath)) {
+ const isValid = await deps.tryExecute(configuredPath);
+ if (isValid) {
+ extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`);
+ return { cliPath: configuredPath, available: true, source: 'configured' };
+ }
+
+ extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`);
+ // Continue to check other locations
+ }
+
+ // 2. Check if CLI is on PATH
+ const onPath = await deps.isOnPath();
+ if (onPath) {
+ extensionLogOutputChannel.info('Aspire CLI found on system PATH');
+
+ // If we previously auto-set the path to a default install location, clear it
+ // since PATH is now working
+ if (defaultPaths.includes(configuredPath)) {
+ extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH');
+ await deps.setConfiguredPath('');
+ }
+
+ return { cliPath: 'aspire', available: true, source: 'path' };
+ }
+
+ // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools)
+ const foundPath = await deps.findAtDefaultPath();
+ if (foundPath) {
+ extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`);
+
+ // Update the setting so future invocations use this path
+ if (configuredPath !== foundPath) {
+ extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location');
+ await deps.setConfiguredPath(foundPath);
+ }
+
+ return { cliPath: foundPath, available: true, source: 'default-install' };
+ }
+
+ // 4. CLI not found anywhere
+ extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations');
+ return { cliPath: 'aspire', available: false, source: 'not-found' };
+}
diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts
index ca9f4ea3c64..bd342a5feb5 100644
--- a/extension/src/utils/configInfoProvider.ts
+++ b/extension/src/utils/configInfoProvider.ts
@@ -9,11 +9,13 @@ import * as strings from '../loc/strings';
* Gets configuration information from the Aspire CLI.
*/
export async function getConfigInfo(terminalProvider: AspireTerminalProvider): Promise {
+ const cliPath = await terminalProvider.getAspireCliExecutablePath();
+
return new Promise((resolve) => {
const args = ['config', 'info', '--json'];
let output = '';
- spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, {
+ spawnCliProcess(terminalProvider, cliPath, args, {
stdoutCallback: (data) => {
output += data;
},
diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts
index 302b11dc716..f1335aa87d4 100644
--- a/extension/src/utils/workspace.ts
+++ b/extension/src/utils/workspace.ts
@@ -1,13 +1,13 @@
import * as vscode from 'vscode';
-import { cliNotAvailable, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings';
+import { 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, execFile } from 'child_process';
+import { ChildProcessWithoutNullStreams } from 'child_process';
import { AspireSettingsFile } from './cliTypes';
import { extensionLogOutputChannel } from './logging';
import { EnvironmentVariables } from './environment';
-import { promisify } from 'util';
+import { resolveCliPath } from './cliPath';
/**
* Common file patterns to exclude from workspace file searches.
@@ -158,13 +158,14 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A
extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire extension get-apphosts');
let proc: ChildProcessWithoutNullStreams;
+ const cliPath = await terminalProvider.getAspireCliExecutablePath();
new Promise((resolve, reject) => {
const args = ['extension', 'get-apphosts'];
if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') {
args.push('--cli-wait-for-debugger');
}
- proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, {
+ proc = spawnCliProcess(terminalProvider, cliPath, args, {
errorCallback: error => {
extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`);
reject();
@@ -268,44 +269,38 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch
extensionLogOutputChannel.info(`Successfully set appHostPath to: ${appHostToUse} in ${settingsFileLocation.fsPath}`);
}
-const execFileAsync = promisify(execFile);
-
-let cliAvailableOnPath: boolean | undefined = undefined;
-
/**
- * Checks if the Aspire CLI is available. If not, shows a message prompting to open Aspire CLI installation steps on the repo.
- * @param cliPath The path to the Aspire CLI executable
- * @returns true if CLI is available, false otherwise
+ * Checks if the Aspire CLI is available. If not found on PATH, it checks the default
+ * installation directory and updates the VS Code setting accordingly.
+ *
+ * If not available, shows a message prompting to open Aspire CLI installation steps.
+ * @returns An object containing the CLI path to use and whether CLI is available
*/
-export async function checkCliAvailableOrRedirect(cliPath: string): Promise {
- if (cliAvailableOnPath === true) {
- // Assume, for now, that CLI availability does not change during the session if it was previously confirmed
- return Promise.resolve(true);
+export async function checkCliAvailableOrRedirect(): Promise<{ cliPath: string; available: boolean }> {
+ // Resolve CLI path fresh each time — settings or PATH may have changed
+ const result = await resolveCliPath();
+
+ if (result.available) {
+ // Show informational message if CLI was found at default path (not on PATH)
+ if (result.source === 'default-install') {
+ extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`);
+ vscode.window.showInformationMessage(cliFoundAtDefaultPath(result.cliPath));
+ }
+
+ return { cliPath: result.cliPath, available: true };
}
- try {
- // Remove surrounding quotes if present (both single and double quotes)
- let cleanPath = cliPath.trim();
- if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) ||
- (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) {
- cleanPath = cleanPath.slice(1, -1);
+ // CLI not found - show error message with install instructions
+ vscode.window.showErrorMessage(
+ cliNotAvailable,
+ openCliInstallInstructions,
+ dismissLabel
+ ).then(selection => {
+ if (selection === openCliInstallInstructions) {
+ // Go to Aspire CLI installation instruction page in external browser
+ vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/'));
}
- await execFileAsync(cleanPath, ['--version'], { timeout: 5000 });
- cliAvailableOnPath = true;
- return true;
- } catch (error) {
- cliAvailableOnPath = false;
- vscode.window.showErrorMessage(
- cliNotAvailable,
- openCliInstallInstructions,
- dismissLabel
- ).then(selection => {
- if (selection === openCliInstallInstructions) {
- // Go to Aspire CLI installation instruction page in external browser
- vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/'));
- }
- });
+ });
- return false;
- }
+ return { cliPath: result.cliPath, available: false };
}