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..293beade0d7 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -93,14 +93,14 @@ 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') { @@ -133,7 +133,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 +143,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { spawnCliProcess( this._terminalProvider, - this._terminalProvider.getAspireCliExecutablePath(), + await this._terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { 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 }; }