Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,12 @@ These instructions are comprehensive and tested. Only search for additional info
3. You need details about new features not yet documented

For most development tasks, following these instructions should be sufficient to build, test, and validate changes successfully.

## Typescript

* When possible, you should create Typescript files instead of Javascript files.
* You must not use dynamic imports unless absolutely necessary. Instead, use static imports.

## Aspire VS Code Extension

* When displaying text to the user, ensure that the strings are localized. New localized strings must be put both in the extension `package.nls.json` and also `src/loc/strings.ts`.
9 changes: 9 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Aspire",
"description": "%extension.description%",
"publisher": "microsoft-aspire",
"version": "0.5.0",
"version": "0.6.0",
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
"icon": "dotnet-aspire-logo-128.png",
"license": "SEE LICENSE IN LICENSE.TXT",
Expand Down
5 changes: 4 additions & 1 deletion extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,8 @@
"aspire-vscode.strings.authorizationAndDcpHeadersRequired": "Authorization and Microsoft-Developer-DCP-Instance-ID headers are required.",
"aspire-vscode.strings.buildFailedForProjectWithError": "Build failed for project {0} with error: {1}.",
"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.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.openCliInstallInstructions": "See CLI installation instructions",
"aspire-vscode.strings.dismissLabel": "Dismiss"
}
17 changes: 16 additions & 1 deletion extension/src/debugger/AspireDebugConfigurationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
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<vscode.DebugConfiguration[]> {
if (folder === undefined) {
return [];
Expand All @@ -18,7 +26,14 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
return configurations;
}

async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration> {
async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
// Check if CLI is available before starting debug session
const cliPath = this._terminalProvider.getAspireCliExecutablePath();
const isCliAvailable = await checkCliAvailableOrRedirect(cliPath);
if (!isCliAvailable) {
return undefined; // Cancel the debug session
}

if (!config.type) {
config.type = 'aspire';
}
Expand Down
2 changes: 1 addition & 1 deletion extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {

spawnCliProcess(
this._terminalProvider,
this._terminalProvider.getAspireCliExecutablePath(false),
this._terminalProvider.getAspireCliExecutablePath(),
args,
{
stdoutCallback: (data) => {
Expand Down
15 changes: 13 additions & 2 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { MessageConnection } from 'vscode-jsonrpc';
import { openTerminalCommand } from './commands/openTerminal';
import { updateCommand } from './commands/update';
import { settingsCommand } from './commands/settings';
import { checkForExistingAppHostPathInWorkspace } from './utils/workspace';
import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace } from './utils/workspace';
import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider';

let aspireExtensionContext = new AspireExtensionContext();
Expand Down Expand Up @@ -66,7 +66,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliConfigCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration);
context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration);

const debugConfigProvider = new AspireDebugConfigurationProvider();
const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider);
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic)
);
Expand All @@ -93,6 +93,17 @@ export function deactivate() {
async function tryExecuteCommand(commandName: string, terminalProvider: AspireTerminalProvider, command: (terminalProvider: AspireTerminalProvider) => Promise<void>): Promise<void> {
try {
sendTelemetryEvent(`${commandName}.invoked`);

const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"];

if (!cliCheckExcludedCommands.includes(commandName)) {
const cliPath = terminalProvider.getAspireCliExecutablePath();
const isCliAvailable = await checkCliAvailableOrRedirect(cliPath);
if (!isCliAvailable) {
return;
}
}

await command(terminalProvider);
}
catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,6 @@ export const authorizationAndDcpHeadersRequired = vscode.l10n.t('Authorization a
export const buildFailedForProjectWithError = (project: string, error: string) => vscode.l10n.t('Build failed for project {0} with error: {1}.', project, error);
export const lookingForDevkitBuildTask = vscode.l10n.t('C# Dev Kit is installed, looking for C# Dev Kit build task...');
export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not installed, building using dotnet CLI...');
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.');
95 changes: 95 additions & 0 deletions extension/src/test/aspireTerminalProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import * as sinon from 'sinon';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';

suite('AspireTerminalProvider tests', () => {
let terminalProvider: AspireTerminalProvider;
let configStub: sinon.SinonStub;
let subscriptions: vscode.Disposable[];

setup(() => {
subscriptions = [];
terminalProvider = new AspireTerminalProvider(subscriptions);
configStub = sinon.stub(vscode.workspace, 'getConfiguration');
});

teardown(() => {
configStub.restore();
subscriptions.forEach(s => s.dispose());
});

suite('getAspireCliExecutablePath', () => {
test('returns "aspire" when no custom path is configured', () => {
configStub.returns({
get: sinon.stub().returns('')
});

const result = terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, 'aspire');
});

test('returns custom path when configured', () => {
configStub.returns({
get: sinon.stub().returns('/usr/local/bin/aspire')
});

const result = terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, '/usr/local/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('trims whitespace from configured path', () => {
configStub.returns({
get: sinon.stub().returns(' /usr/local/bin/aspire ')
});

const result = terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, '/usr/local/bin/aspire');
});

test('returns "aspire" when configured path is only whitespace', () => {
configStub.returns({
get: sinon.stub().returns(' ')
});

const result = terminalProvider.getAspireCliExecutablePath();
assert.strictEqual(result, 'aspire');
});

test('handles Windows-style paths', () => {
configStub.returns({
get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe')
});

const result = 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');
});
});
});
24 changes: 16 additions & 8 deletions extension/src/utils/AspireTerminalProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,20 @@ export class AspireTerminalProvider implements vscode.Disposable {
}

sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) {
let command = `${this.getAspireCliExecutablePath()} ${subcommand}`;
const cliPath = this.getAspireCliExecutablePath();

// On Windows, use & to execute paths, especially those with special characters
// On Unix, just use the path directly
let command: string;
if (process.platform === 'win32') {
// Use & call operator with quoted path for Windows
command = `& "${cliPath}" ${subcommand}`;
} else {
// For Unix-like systems, quote only if needed
const quotedPath = /[\s"'`$!*?()&|<>;]/.test(cliPath) ? `'${cliPath.replace(/'/g, `'\"'\"'`)}'` : cliPath;
command = `${quotedPath} ${subcommand}`;
}

if (this.isCliDebugLoggingEnabled()) {
command += ' --debug';
}
Expand Down Expand Up @@ -186,20 +199,15 @@ export class AspireTerminalProvider implements vscode.Disposable {
}


getAspireCliExecutablePath(surroundWithQuotes: boolean = true): string {
getAspireCliExecutablePath(): string {
const aspireCliPath = vscode.workspace.getConfiguration('aspire').get<string>('aspireCliExecutablePath', '');
if (aspireCliPath && aspireCliPath.trim().length > 0) {
extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`);
const path = shellEscapeSingleQuotes(aspireCliPath.trim());
return surroundWithQuotes ? `'${path}'` : path;
return aspireCliPath.trim();
}

extensionLogOutputChannel.debug('No user-configured Aspire CLI path found');
return "aspire";

function shellEscapeSingleQuotes(str: string): string {
return str.replace(/'/g, `'\\''`);
}
}

isCliDebugLoggingEnabled(): boolean {
Expand Down
49 changes: 46 additions & 3 deletions extension/src/utils/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as vscode from 'vscode';
import { dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, selectDefaultLaunchApphost, yesLabel } from '../loc/strings';
import { cliNotAvailable, 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 { ChildProcessWithoutNullStreams, execFile } from 'child_process';
import { AspireSettingsFile } from './cliTypes';
import { extensionLogOutputChannel } from './logging';
import { EnvironmentVariables } from './environment';
import { promisify } from 'util';

export function isWorkspaceOpen(showErrorMessage: boolean = true): boolean {
const isOpen = !!vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0;
Expand Down Expand Up @@ -99,7 +100,7 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A
args.push('--cli-wait-for-debugger');
}

proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(false), args, {
proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, {
errorCallback: error => {
extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`);
reject();
Expand Down Expand Up @@ -203,3 +204,45 @@ 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
*/
export async function checkCliAvailableOrRedirect(cliPath: string): Promise<boolean> {
if (cliAvailableOnPath === true) {
// Assume, for now, that CLI availability does not change during the session if it was previously confirmed
return Promise.resolve(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);
}
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;
}
}