diff --git a/sample/package-lock.json b/sample/package-lock.json index da910044f2..2f82c30e12 100644 --- a/sample/package-lock.json +++ b/sample/package-lock.json @@ -4759,10 +4759,13 @@ } }, "../vscode-dotnet-runtime-extension/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha1-4QLxbKNVQkhldV0sno6k8k1Yw+I=", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "../vscode-dotnet-runtime-extension/node_modules/pump": { "version": "3.0.3", @@ -8986,10 +8989,13 @@ "license": "ISC" }, "../vscode-dotnet-runtime-library/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha1-4QLxbKNVQkhldV0sno6k8k1Yw+I=", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "../vscode-dotnet-runtime-library/node_modules/randombytes": { "version": "2.1.0", diff --git a/sample/src/extension.ts b/sample/src/extension.ts index af21f33ede..410e5e1c07 100644 --- a/sample/src/extension.ts +++ b/sample/src/extension.ts @@ -8,14 +8,14 @@ import * as path from 'path'; import * as vscode from 'vscode'; // import * as runtimeExtension from 'vscode-dotnet-runtime'; // comment this out when packing the extension import -{ - DotnetInstallMode, - DotnetVersionSpecRequirement, - IDotnetAcquireContext, - IDotnetAcquireResult, - IDotnetFindPathContext, - IDotnetListVersionsResult, -} from 'vscode-dotnet-runtime-library'; + { + DotnetInstallMode, + DotnetVersionSpecRequirement, + IDotnetAcquireContext, + IDotnetAcquireResult, + IDotnetFindPathContext, + IDotnetListVersionsResult, + } from 'vscode-dotnet-runtime-library'; export function activate(context: vscode.ExtensionContext) { @@ -106,17 +106,17 @@ ${stderr}`); } } - const sampleAcquireRegistration = vscode.commands.registerCommand('sample.dotnet.acquire', async (version) => + const sampleAcquireRegistration = vscode.commands.registerCommand('sample.dotnet.acquire', async (version: string | undefined) => { await callAcquireAPI(version, undefined); }); - const sampleAcquireASPNETRegistration = vscode.commands.registerCommand('sample.dotnet.acquireASPNET', async (version) => + const sampleAcquireASPNETRegistration = vscode.commands.registerCommand('sample.dotnet.acquireASPNET', async (version: string | undefined) => { await callAcquireAPI(version, 'aspnetcore'); }); - const sampleAcquireNoForceRegistration = vscode.commands.registerCommand('sample.dotnet.acquireNoForce', async (version) => + const sampleAcquireNoForceRegistration = vscode.commands.registerCommand('sample.dotnet.acquireNoForce', async (version: string | undefined) => { const mode = await vscode.window.showInputBox({ placeHolder: 'runtime', @@ -127,7 +127,7 @@ ${stderr}`); await callAcquireAPI(undefined, mode as DotnetInstallMode, false); }); - const sampleAcquireStatusRegistration = vscode.commands.registerCommand('sample.dotnet.acquireStatus', async (version) => + const sampleAcquireStatusRegistration = vscode.commands.registerCommand('sample.dotnet.acquireStatus', async (version: string | undefined) => { if (!version) { @@ -222,7 +222,7 @@ ${stderr}`); } }); - const sampleGlobalSDKFromRuntimeRegistration = vscode.commands.registerCommand('sample.dotnet.acquireGlobalSDK', async (version) => + const sampleGlobalSDKFromRuntimeRegistration = vscode.commands.registerCommand('sample.dotnet.acquireGlobalSDK', async (version: string | undefined) => { if (!version) { @@ -233,6 +233,11 @@ ${stderr}`); }); } + if (!version) + { + return; + } + try { await vscode.commands.executeCommand('dotnet.showAcquisitionLog'); @@ -289,7 +294,7 @@ ${stderr}`); ${JSON.stringify(result) ?? 'undefined'}`); }); - const sampleAvailableInstallsRegistration = vscode.commands.registerCommand('sample.dotnet.availableInstalls', async (version) => + const sampleAvailableInstallsRegistration = vscode.commands.registerCommand('sample.dotnet.availableInstalls', async (version: string | undefined) => { let dotnetPath = await vscode.window.showInputBox({ placeHolder: 'undefined', @@ -342,7 +347,7 @@ ${JSON.stringify(result) ?? 'undefined'}`); // ---------------------sdk extension registrations-------------------------- - const sampleSDKAcquireRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquire', async (version) => + const sampleSDKAcquireRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquire', async (version: string | undefined) => { if (!version) { @@ -364,7 +369,7 @@ ${JSON.stringify(result) ?? 'undefined'}`); } }); - const sampleSDKGlobalAcquireRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquireGlobal', async (version) => + const sampleSDKGlobalAcquireRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquireGlobal', async (version: string | undefined) => { if (!version) { @@ -375,6 +380,11 @@ ${JSON.stringify(result) ?? 'undefined'}`); }); } + if (!version) + { + return; + } + try { await vscode.commands.executeCommand('dotnet-sdk.showAcquisitionLog'); @@ -387,7 +397,7 @@ ${JSON.stringify(result) ?? 'undefined'}`); } }); - const sampleSDKAcquireStatusRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquireStatus', async (version) => + const sampleSDKAcquireStatusRegistration = vscode.commands.registerCommand('sample.dotnet-sdk.acquireStatus', async (version: string | undefined) => { if (!version) { diff --git a/vscode-dotnet-runtime-extension/package-lock.json b/vscode-dotnet-runtime-extension/package-lock.json index ac37669e6c..8d7acf0c04 100644 --- a/vscode-dotnet-runtime-extension/package-lock.json +++ b/vscode-dotnet-runtime-extension/package-lock.json @@ -1565,6 +1565,12 @@ "is-retry-allowed": "^2.2.0" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha1-4QLxbKNVQkhldV0sno6k8k1Yw+I=", + "license": "MIT" + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -5056,12 +5062,6 @@ "retry": "^0.10.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha1-4QLxbKNVQkhldV0sno6k8k1Yw+I=", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/pump/-/pump-3.0.3.tgz", diff --git a/vscode-dotnet-runtime-extension/package.json b/vscode-dotnet-runtime-extension/package.json index e1f982d0f3..de131fd3f2 100644 --- a/vscode-dotnet-runtime-extension/package.json +++ b/vscode-dotnet-runtime-extension/package.json @@ -40,6 +40,166 @@ "main": "./dist/extension.js", "types": "./dist/extension.d.ts", "contributes": { + "languageModelTools": [ + { + "name": "install_dotnet_sdk", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "Install .NET SDK", + "toolReferenceName": "installDotNetSdk", + "canBeReferencedInPrompt": true, + "icon": "$(tools)", + "userDescription": "Installs a .NET SDK system-wide for building and running .NET projects", + "modelDescription": "Installs .NET SDK system-wide (requires admin). Use when user wants to install, set up, or add .NET. Supports Windows, macOS, Ubuntu, Debian, RHEL; returns manual instructions for unsupported platforms. Install exactly what user requests, even EOL/preview. Version resolution: (1) user request, (2) TargetFramework in .csproj (net8.0→'8'), (3) global.json, (4) call listDotNetVersions. Formats: '6', '6.0', '6.0.1xx'.", + "inputSchema": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "The .NET SDK version. Examples: '6', '6.0', '6.0.1xx'. Install exactly what user requests, even if EOL." + } + }, + "required": [ + "version" + ] + } + }, + { + "name": "list_available_dotnet_versions_to_install", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "List Available .NET Versions", + "toolReferenceName": "listDotNetVersions", + "canBeReferencedInPrompt": true, + "icon": "$(list-unordered)", + "userDescription": "Lists available .NET SDK and Runtime versions that can be installed", + "modelDescription": "Lists .NET SDK or Runtime versions available from Microsoft with support phase (active/maintenance/eol). Use when user asks what versions exist, which to install, or needs to pick a version. Default: SDKs. Set listRuntimes=true for runtimes.", + "inputSchema": { + "type": "object", + "properties": { + "listRuntimes": { + "type": "boolean", + "description": "If true, lists runtime versions. Default: SDK versions." + } + } + } + }, + { + "name": "find_dotnet_executable_path", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "Find .NET Path", + "toolReferenceName": "findDotNetPath", + "canBeReferencedInPrompt": true, + "icon": "$(search)", + "userDescription": "Finds the path to a .NET installation that meets specified requirements", + "modelDescription": "Checks if .NET is already installed before suggesting installation. Use when user asks 'is .NET installed?', 'where is dotnet?', or before calling installDotNetSdk. Searches: existingDotnetPath → PATH → DOTNET_ROOT → extension-managed installs. Extension-managed local installs are NOT on PATH.", + "inputSchema": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "Minimum .NET version required (e.g., '8.0')" + }, + "mode": { + "type": "string", + "enum": [ + "sdk", + "runtime", + "aspnetcore" + ], + "description": "SDK or runtime. Default: 'runtime'." + }, + "architecture": { + "type": "string", + "enum": [ + "x64", + "x86", + "arm64" + ], + "description": "CPU architecture. Default: current system architecture." + } + }, + "required": [ + "version" + ] + } + }, + { + "name": "uninstall_dotnet", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "Uninstall .NET", + "toolReferenceName": "uninstallDotNet", + "canBeReferencedInPrompt": true, + "icon": "$(trash)", + "userDescription": "Uninstalls a .NET SDK or Runtime version", + "modelDescription": "Uninstalls a .NET SDK or runtime. Use when user wants to remove, uninstall, or clean up .NET. Supports Windows, macOS, Ubuntu, Debian, RHEL; returns manual instructions for unsupported platforms. Call listInstalledDotNetVersions first to get exact version+mode. Global uninstalls require admin.", + "inputSchema": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "The .NET version to uninstall. Call listInstalledDotNetVersions first." + }, + "mode": { + "type": "string", + "enum": [ + "sdk", + "runtime", + "aspnetcore" + ], + "description": "SDK or runtime to uninstall." + }, + "global": { + "type": "boolean", + "description": "True: global/system-wide. False: VS Code-managed local." + } + }, + "required": [ + "version" + ] + } + }, + { + "name": "get_settings_info_for_dotnet_installation_management", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "Get .NET Install Tool Guide", + "toolReferenceName": "getDotNetSettingsInfo", + "canBeReferencedInPrompt": true, + "icon": "$(info)", + "userDescription": "Get information about .NET Install Tool settings, installation types, and troubleshooting", + "modelDescription": "Returns .NET Install Tool settings guide and current config. Use when user asks about .NET settings, troubleshooting, existingDotnetPath, 'C# extension can't find .NET', or extension configuration. Covers: existingDotnetPath (extension runtime only — NOT project SDK, use global.json), local vs global installs, common fixes.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "list_installed_dotnet_versions", + "when": "config.dotnetAcquisitionExtension.enableLanguageModelTools", + "displayName": "List Installed .NET Versions", + "toolReferenceName": "listInstalledDotNetVersions", + "canBeReferencedInPrompt": true, + "icon": "$(list-tree)", + "userDescription": "Lists .NET SDK or Runtime versions that are currently installed on the system", + "modelDescription": "Lists .NET SDKs and runtimes currently installed on the system. Use when user asks 'what .NET do I have?', 'which versions are installed?', or before uninstall. Default: queries PATH. Pass dotnetPath for a specific executable. Windows Desktop Runtime not tracked — use 'dotnet --list-runtimes' in terminal if needed for that.", + "inputSchema": { + "type": "object", + "properties": { + "dotnetPath": { + "type": "string", + "description": "Path to a dotnet executable to query. Default: PATH lookup." + }, + "mode": { + "type": "string", + "enum": [ + "sdk", + "runtime", + "aspnetcore" + ], + "description": "Omit for both SDKs and Runtimes. Set to filter." + } + } + } + } + ], "commands": [ { "command": "dotnet.reportIssue", @@ -66,6 +226,11 @@ "configuration": { "title": ".NET Install Tool", "properties": { + "dotnetAcquisitionExtension.enableLanguageModelTools": { + "type": "boolean", + "default": true, + "description": "Enable AI-powered language model tools for .NET management (install, uninstall, list versions, etc.) in GitHub Copilot and agent mode. Set to false to hide all .NET tools from the LLM. Restart VS Code to apply changes." + }, "dotnetAcquisitionExtension.enableTelemetry": { "type": "boolean", "default": true, @@ -93,7 +258,7 @@ }, "dotnetAcquisitionExtension.sharedExistingDotnetPath": { "type": "string", - "description": "The path of the preexisting .NET Runtime you'd like to use for ALL extensions. Restart VS Code to apply changes.", + "description": "The path of a preexisting .NET Runtime for ALL VS Code extensions to use for their internal components. This does NOT control the SDK or runtime used by your projects. Restart VS Code to apply changes.", "examples": [ "C:\\Program Files\\dotnet\\dotnet.exe", "/usr/local/share/dotnet/dotnet", @@ -141,6 +306,7 @@ "compile": "npm run clean && tsc -p ./", "watch": "npm run compile && tsc -watch -p ./", "test": "npm run compile --silent && node ./dist/test/functional/runTest.js", + "test:lm-tools": "npm run compile --silent && node ./dist/test/functional/runLmToolsTest.js", "clean": "npx rimraf dist", "compile-all": "cd ../vscode-dotnet-runtime-library && npm install && npm run compile && cd ../vscode-dotnet-runtime-extension && npm install && npm run compile", "lint": "eslint -c .eslintrc.js --ext=.ts vscode-dotnet-runtime-library/src/**/*.ts vscode-dotnet-runtime-extension/src/**/*.ts --ignore-pattern \"**/test/\" --fix", diff --git a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts new file mode 100644 index 0000000000..cad0b13102 --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts @@ -0,0 +1,799 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import * as vscode from 'vscode'; +import + { + AcquireErrorConfiguration, + checkForUnsupportedLinux, + DotnetAcquisitionCompleted, + DotnetAcquisitionStarted, + DotnetBeginGlobalInstallerExecution, + DotnetInstallMode, + DotnetUninstallCompleted, + DotnetUninstallFailed, + DotnetUninstallStarted, + EventStream, + IDotnetAcquireContext, + IDotnetAcquireResult, + IDotnetFindPathContext, + IDotnetListVersionsContext, + IDotnetListVersionsResult, + IDotnetSearchContext, + IDotnetSearchResult, + IDotnetVersion, + IEventStream, + LanguageModelToolInvoked, + LanguageModelToolPrepareInvocation + } from 'vscode-dotnet-runtime-library'; +import { settingsInfoContent } from './SettingsInfoContent'; + +/** + * Tool name constants matching those in package.json + */ +export namespace ToolNames +{ + export const installSdk = 'install_dotnet_sdk'; + export const listVersions = 'list_available_dotnet_versions_to_install'; + export const listInstalledVersions = 'list_installed_dotnet_versions'; + export const findPath = 'find_dotnet_executable_path'; + export const uninstall = 'uninstall_dotnet'; + export const getSettingsInfo = 'get_settings_info_for_dotnet_installation_management'; +} + + +/** + * Returns a standardized tool result for WSL or unsupported Linux distros. + * Centralizes the fallback message so install/uninstall tools stay consistent. + */ +function unsupportedPlatformResult(action: string, reason: string): vscode.LanguageModelToolResult +{ + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `To ${action}, it is essential to read https://learn.microsoft.com/dotnet/core/install/linux to find distro-specific ${action} commands. Then run those commands in the terminal. Do NOT use dotnet-install.sh.` + ) + ]); +} + +/** + * Registers all Language Model Tools for the .NET Install Tool extension. + * These tools enable AI agents (like GitHub Copilot) to help users manage .NET installations. + */ +export function registerLanguageModelTools(context: vscode.ExtensionContext, eventStream: EventStream): void +{ + // Install SDK Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.installSdk, new InstallSdkTool(eventStream)) + ); + + // List Versions Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.listVersions, new ListVersionsTool(eventStream)) + ); + + // Find Path Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.findPath, new FindPathTool(eventStream)) + ); + + // Uninstall Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.uninstall, new UninstallTool(eventStream)) + ); + + // Settings Info Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.getSettingsInfo, new GetSettingsInfoTool(eventStream)) + ); + + // List Installed Versions Tool + context.subscriptions.push( + vscode.lm.registerTool(ToolNames.listInstalledVersions, new ListInstalledVersionsTool(eventStream)) + ); +} + +/** + * Tool to install .NET SDK system-wide + */ +class InstallSdkTool implements vscode.LanguageModelTool<{ version?: string }> +{ + constructor(private readonly eventStream: EventStream) {} + + prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions<{ version?: string }>, + token: vscode.CancellationToken + ): vscode.PreparedToolInvocation + { + const input = JSON.stringify(options.input); + this.eventStream.post(new LanguageModelToolPrepareInvocation(ToolNames.installSdk, input)); + + const version = options.input?.version; + return { + invocationMessage: version + ? `Installing .NET SDK version ${version}...` + : `Installing latest .NET SDK (no version specified)...`, + }; + } + + async invoke( + options: vscode.LanguageModelToolInvocationOptions<{ version?: string }>, + token: vscode.CancellationToken + ): Promise + { + const rawInput = JSON.stringify(options.input); + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.installSdk, rawInput)); + + // Early exit on WSL or unsupported Linux — this tool cannot install there. + const linuxCheck = await checkForUnsupportedLinux(); + if (linuxCheck.isUnsupported) + { + return unsupportedPlatformResult('install', linuxCheck.reason ?? 'unsupported Linux distro'); + } + + const version = options.input?.version; + + // Version is now required by the schema - if not provided, guide the model + if (!version) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + 'ERROR: version parameter is required.\n\n' + + 'To determine version: (1) Check user request, (2) TargetFramework in .csproj (net8.0 -> "8"), ' + + '(3) global.json sdk.version, (4) Call listDotNetVersions and pick latest Active Support version.' + ) + ]); + } + + try + { + // Show the acquisition log so user can see progress + await vscode.commands.executeCommand('dotnet.showAcquisitionLog'); + + const acquireContext: IDotnetAcquireContext = { + version, + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime', // Self-reference for user-initiated installs + installType: 'global', + mode: 'sdk' as DotnetInstallMode, + errorConfiguration: AcquireErrorConfiguration.DisplayAllErrorPopups, + rethrowError: true // Rethrow errors so the LLM tool can capture the actual error message + }; + + const result: IDotnetAcquireResult | undefined = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing .NET SDK ${version}`, + cancellable: false + }, + async (progress) => + { + progress.report({ message: 'Preparing...' }); + + const subscription = this.eventStream.subscribe(event => + { + if (event instanceof DotnetAcquisitionStarted) + { + progress.report({ message: 'Downloading installer...', increment: 20 }); + } + else if (event instanceof DotnetBeginGlobalInstallerExecution) + { + progress.report({ message: 'Running installer (this may require elevation)...', increment: 30 }); + } + else if (event instanceof DotnetAcquisitionCompleted) + { + progress.report({ message: 'Installation complete.', increment: 50 }); + } + }); + + try + { + return await vscode.commands.executeCommand( + 'dotnet.acquireGlobalSDK', + acquireContext + ); + } + finally + { + subscription.dispose(); + } + } + ); + + if (result?.dotnetPath) + { + const platform = process.platform; + const installMethod = platform === 'win32' ? 'MSI installer' : platform === 'darwin' ? 'PKG installer' : 'package manager'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Successfully installed .NET SDK ${version} via ${installMethod}.\n` + + `Path: ${result.dotnetPath}\n` + + `Restart terminal or VS Code for PATH changes. Verify: \`dotnet --version\`` + ) + ]); + } else + { + // No path returned means installation failed or was cancelled by the user + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `ERROR: .NET SDK ${version} installation did not complete.\n` + + `Likely cancelled by user (declined admin/elevation prompt or installer dialog).\n` + + `The SDK is NOT installed. Check ".NET Install Tool" output channel for details.\n` + + `If retrying, user must accept all prompts including admin/elevation dialogs.` + ) + ]); + } + } catch (error) + { + const errorMessage = error instanceof Error ? error.message : String(error); + const isUserCancellation = /cancel|user rejected|user denied|password request/i.test(errorMessage); + + if (isUserCancellation) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Install of .NET SDK${version ? ` ${version}` : ''} cancelled/rejected by user.\n` + + `Error: ${errorMessage}\n` + + `Ask user to retry — they must accept all prompts including admin/elevation.` + ) + ]); + } + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Extension-based install of .NET SDK${version ? ` ${version}` : ''} failed.\n` + + `Error: ${errorMessage}\n` + + `Check ".NET Install Tool" output channel. Verify admin privileges and internet.\n` + + `If unresolved, see https://learn.microsoft.com/dotnet/core/install for manual install instructions.` + ) + ]); + } + } +} + +/** + * Tool to list available .NET versions + */ +class ListVersionsTool implements vscode.LanguageModelTool<{ listRuntimes?: boolean }> +{ + constructor(private readonly eventStream: IEventStream) {} + + async invoke( + options: vscode.LanguageModelToolInvocationOptions<{ listRuntimes?: boolean }>, + token: vscode.CancellationToken + ): Promise + { + const rawInput = JSON.stringify(options.input); + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.listVersions, rawInput)); + + const listRuntimes = options.input.listRuntimes ?? false; + + try + { + const listContext: IDotnetListVersionsContext = { + listRuntimes + }; + + const versions: IDotnetListVersionsResult | undefined = await vscode.commands.executeCommand( + 'dotnet.listVersions', + listContext, + undefined // customWebWorker + ); + + if (!versions || versions.length === 0) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `No ${listRuntimes ? 'runtime' : 'SDK'} versions retrieved. Check internet connection.` + ) + ]); + } + + const versionType = listRuntimes ? 'Runtime' : 'SDK'; + let responseText = `# Available .NET ${versionType} Versions\n\n`; + + // Group by support phase for better readability + const activeVersions = versions.filter((v: IDotnetVersion) => v.supportPhase === 'active'); + const maintenanceVersions = versions.filter((v: IDotnetVersion) => v.supportPhase === 'maintenance'); + const eolVersions = versions.filter((v: IDotnetVersion) => v.supportPhase === 'eol'); + + if (activeVersions.length > 0) + { + responseText += `## Active Support (Recommended)\n`; + for (const v of activeVersions) + { + responseText += `- ${v.version}${v.channelVersion ? ` (Channel: ${v.channelVersion})` : ''}\n`; + } + responseText += '\n'; + } + + if (maintenanceVersions.length > 0) + { + responseText += `## Maintenance Support\n`; + for (const v of maintenanceVersions) + { + responseText += `- ${v.version}${v.channelVersion ? ` (Channel: ${v.channelVersion})` : ''}\n`; + } + responseText += '\n'; + } + + if (eolVersions.length > 0) + { + responseText += `## End of Life\n`; + for (const v of eolVersions) + { + responseText += `- ${v.version}${v.channelVersion ? ` (Channel: ${v.channelVersion})` : ''}\n`; + } + responseText += '\n'; + } + + responseText += `\nRecommendation: Install an Active Support version.`; + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(responseText) + ]); + } catch (error) + { + const errorMessage = error instanceof Error ? error.message : String(error); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Failed to list .NET versions. Error: ${errorMessage}. Check internet connection.` + ) + ]); + } + } +} + +/** + * Tool to find an existing .NET installation path + */ +class FindPathTool implements vscode.LanguageModelTool<{ version: string; mode?: string; architecture?: string }> +{ + constructor(private readonly eventStream: IEventStream) {} + + async invoke( + options: vscode.LanguageModelToolInvocationOptions<{ version: string; mode?: string; architecture?: string }>, + token: vscode.CancellationToken + ): Promise + { + const rawInput = JSON.stringify(options.input); + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.findPath, rawInput)); + + const { version, mode, architecture } = options.input; + + if (!version) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + 'Please specify a .NET version to search for (e.g., "8.0" or "6.0").' + ) + ]); + } + + try + { + const resolvedMode = (mode as DotnetInstallMode) || 'runtime'; + const resolvedArchitecture = architecture || os.arch(); + + const findContext: IDotnetFindPathContext = { + acquireContext: { + version, + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime', + mode: resolvedMode, + architecture: resolvedArchitecture + }, + versionSpecRequirement: 'greater_than_or_equal' + }; + + const result: IDotnetAcquireResult | undefined = await vscode.commands.executeCommand( + 'dotnet.findPath', + findContext + ); + + if (result?.dotnetPath) + { + const modeDisplay = resolvedMode === 'sdk' ? 'SDK' : resolvedMode === 'aspnetcore' ? 'ASP.NET Core Runtime' : 'Runtime'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `.NET ${modeDisplay} Found\n` + + `Version requested: ${version} or later\n` + + `Architecture: ${resolvedArchitecture}\n` + + `Path: \`${result.dotnetPath}\`\n` + + `${resolvedMode !== 'sdk' ? 'This is likely what extensions like C# and C# DevKit are using.' : ''}` + ) + ]); + } else + { + const modeDisplay = resolvedMode === 'sdk' ? 'SDK' : resolvedMode === 'aspnetcore' ? 'ASP.NET Core Runtime' : 'Runtime'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `.NET ${modeDisplay} Not Found\n` + + `No .NET ${modeDisplay} >=${version} found for ${resolvedArchitecture}.\n` + + `Searched: existingDotnetPath setting, PATH, DOTNET_ROOT, extension-managed installs.\n` + + `${resolvedMode === 'sdk' + ? 'Use installDotNetSdk tool or "Install .NET SDK System-Wide" command.' + : 'Install the SDK (includes runtimes) via installDotNetSdk tool.'}` + ) + ]); + } + } catch (error) + { + const errorMessage = error instanceof Error ? error.message : String(error); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Failed to search for .NET installation. Error: ${errorMessage}` + ) + ]); + } + } +} + +/** + * Tool to uninstall .NET versions + */ +class UninstallTool implements vscode.LanguageModelTool<{ version?: string; mode?: string; global?: boolean }> +{ + constructor(private readonly eventStream: EventStream) {} + + async invoke( + options: vscode.LanguageModelToolInvocationOptions<{ version?: string; mode?: string; global?: boolean }>, + token: vscode.CancellationToken + ): Promise + { + const rawInput = JSON.stringify(options.input); + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.uninstall, rawInput)); + + // Early exit on WSL or unsupported Linux — this tool cannot uninstall there. + const linuxCheck = await checkForUnsupportedLinux(); + if (linuxCheck.isUnsupported) + { + return unsupportedPlatformResult('uninstall', linuxCheck.reason ?? 'unsupported Linux distro'); + } + + const { version, mode, global } = options.input; + + try + { + // If no specific version provided, fall back to interactive picker + if (!version) + { + await vscode.commands.executeCommand('dotnet.uninstallPublic'); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + 'Launched interactive .NET uninstall dialog. ' + + 'Outcome unknown — user may have selected a version or cancelled. Ask user for result. ' + + 'Tip: call listInstalledDotNetVersions first, then provide version+mode for deterministic uninstall.' + ) + ]); + } + + // Specific version uninstall + const resolvedMode = (mode as DotnetInstallMode) || 'sdk'; + const isGlobal = global ?? true; + + const acquireContext: IDotnetAcquireContext = { + version, + mode: resolvedMode, + installType: isGlobal ? 'global' : 'local', + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime', + rethrowError: true // Rethrow errors so the LLM tool can capture the actual error message + }; + + const result: string = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Uninstalling .NET ${resolvedMode === 'sdk' ? 'SDK' : 'Runtime'} ${version}`, + cancellable: false + }, + async (progress) => + { + progress.report({ message: 'Preparing...' }); + + const subscription = this.eventStream.subscribe(event => + { + if (event instanceof DotnetUninstallStarted) + { + progress.report({ message: 'Downloading uninstall tool...', increment: 25 }); + } + else if (event instanceof DotnetUninstallCompleted) + { + progress.report({ message: 'Uninstall complete.', increment: 75 }); + } + else if (event instanceof DotnetUninstallFailed) + { + progress.report({ message: 'Uninstall failed.' }); + } + }); + + try + { + return await vscode.commands.executeCommand('dotnet.uninstall', acquireContext); + } + finally + { + subscription.dispose(); + } + } + ); + + if (result === '0' || result === '') + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Successfully uninstalled .NET ${resolvedMode === 'sdk' ? 'SDK' : 'Runtime'} ${version}. Restart terminal for changes.` + ) + ]); + } else + { + // Non-zero or unexpected result - likely an error or cancellation + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `WARNING: Uninstall of .NET ${version} returned unexpected result: ${result}\n` + + `May not have completed. Check ".NET Install Tool" output channel.` + ) + ]); + } + } catch (error) + { + const errorMessage = error instanceof Error ? error.message : String(error); + const isUserCancellation = /cancel|user rejected|user denied|password request/i.test(errorMessage); + + if (isUserCancellation) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Uninstall of .NET${version ? ` ${version}` : ''} cancelled/rejected by user.\n` + + `Error: ${errorMessage}\n` + + `Ask user to retry — they must accept all prompts including admin/elevation.` + ) + ]); + } + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Extension-based uninstall of .NET${version ? ` ${version}` : ''} failed.\n` + + `Error: ${errorMessage}\n` + + `Check ".NET Install Tool" output channel. Verify admin privileges and internet.\n` + + `If unresolved, see https://learn.microsoft.com/dotnet/core/install for manual uninstall instructions.` + ) + ]); + } + } +} + +/** + * Tool to get settings information + */ +class GetSettingsInfoTool implements vscode.LanguageModelTool> +{ + constructor(private readonly eventStream: IEventStream) {} + + invoke( + options: vscode.LanguageModelToolInvocationOptions>, + token: vscode.CancellationToken + ): vscode.LanguageModelToolResult + { + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.getSettingsInfo, '{}')); + + // Also include current settings values for context + const config = vscode.workspace.getConfiguration('dotnetAcquisitionExtension'); + const existingPath = config.get('existingDotnetPath'); + const sharedPath = config.get('sharedExistingDotnetPath'); + + let currentSettingsInfo = '\n\n---\n\n# Current Settings Values\n\n'; + + if (existingPath && existingPath.length > 0) + { + currentSettingsInfo += `**existingDotnetPath:** ${JSON.stringify(existingPath)}\n\n`; + } else + { + currentSettingsInfo += `**existingDotnetPath:** Not configured (extension will auto-manage .NET)\n\n`; + } + + if (sharedPath) + { + currentSettingsInfo += `**sharedExistingDotnetPath:** ${sharedPath}\n\n`; + } else + { + currentSettingsInfo += `**sharedExistingDotnetPath:** Not configured\n\n`; + } + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(settingsInfoContent + currentSettingsInfo) + ]); + } +} + +/** + * Tool to list installed .NET versions for a given dotnet executable/hive + */ +class ListInstalledVersionsTool implements vscode.LanguageModelTool<{ dotnetPath?: string; mode?: string }> +{ + constructor(private readonly eventStream: IEventStream) {} + + prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions<{ dotnetPath?: string; mode?: string }>, + token: vscode.CancellationToken + ): vscode.PreparedToolInvocation + { + this.eventStream.post(new LanguageModelToolPrepareInvocation(ToolNames.listInstalledVersions, JSON.stringify(options.input))); + return { + invocationMessage: 'Querying installed .NET SDKs and Runtimes via extension API (no terminal command needed)', + }; + } + + async invoke( + options: vscode.LanguageModelToolInvocationOptions<{ dotnetPath?: string; mode?: string }>, + token: vscode.CancellationToken + ): Promise + { + this.eventStream.post(new LanguageModelToolInvoked(ToolNames.listInstalledVersions, JSON.stringify(options.input))); + const { dotnetPath, mode } = options.input; + + try + { + const pathInfo = dotnetPath ? `Queried path: \`${dotnetPath}\`` : 'Queried: system PATH (global install)'; + + // If no mode specified, return BOTH SDKs and Runtimes (like dotnet --info) + if (!mode) + { + const sdkContext: IDotnetSearchContext = { + mode: 'sdk', + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime' + }; + const runtimeContext: IDotnetSearchContext = { + mode: 'runtime', + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime' + }; + + if (dotnetPath) + { + sdkContext.dotnetExecutablePath = dotnetPath; + runtimeContext.dotnetExecutablePath = dotnetPath; + } + + const [sdkResults, runtimeResults] = await Promise.all([ + vscode.commands.executeCommand('dotnet.availableInstalls', sdkContext), + vscode.commands.executeCommand('dotnet.availableInstalls', runtimeContext) + ]); + + let resultText = `# Installed .NET SDKs and Runtimes\n\n`; + resultText += `${pathInfo}\n\n`; + + // SDKs section + resultText += `## SDKs\n\n`; + if (sdkResults && sdkResults.length > 0) + { + resultText += '| Version | Architecture |\n'; + resultText += '|---------|--------------|\n'; + for (const install of sdkResults) + { + resultText += `| ${install.version} | ${install.architecture || 'unknown'} |\n`; + } + } + else + { + resultText += `No SDKs installed.\n\n`; + } + + // Runtimes section - group by mode for compact display + resultText += `\n## Runtimes\n\n`; + if (runtimeResults && runtimeResults.length > 0) + { + // Group runtimes by their mode (each result already has mode set to 'runtime' or 'aspnetcore') + const runtimesByMode = new Map(); + for (const install of runtimeResults) + { + const modeKey = install.mode ?? 'runtime'; + if (!runtimesByMode.has(modeKey)) + { + runtimesByMode.set(modeKey, []); + } + runtimesByMode.get(modeKey)!.push(install.version); + } + + // Display grouped runtimes with friendly names + const modeDisplayNames: Record = { + 'runtime': 'Microsoft.NETCore.App (.NET Runtime)', + 'aspnetcore': 'Microsoft.AspNetCore.App (ASP.NET Core Runtime)', + }; + + resultText += '| Runtime | Versions |\n'; + resultText += '|---------|----------|\n'; + for (const [modeKey, versions] of runtimesByMode) + { + const displayName = modeDisplayNames[modeKey] ?? modeKey; + // Sort versions and join with commas + const sortedVersions = versions.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + resultText += `| ${displayName} | ${sortedVersions.join(', ')} |\n`; + } + } + else + { + resultText += `No Runtimes installed.\n\n`; + } + + resultText += `\nWindows Desktop Runtime not tracked. Use 'dotnet --list-runtimes' for that.`; + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(resultText) + ]); + } + + // Guard against unsupported modes (e.g. 'windowsdesktop') + const lowerMode = mode?.toLowerCase(); + if (lowerMode && lowerMode !== 'sdk' && lowerMode !== 'runtime' && lowerMode !== 'aspnetcore') + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `The mode '${mode}' is not supported by this tool.\n\n` + + `**Supported modes:** sdk, runtime, aspnetcore\n\n` + + `**Note:** Windows Desktop Runtime (Microsoft.WindowsDesktop.App) is not tracked by this extension. ` + + `To check installed Windows Desktop Runtimes, run \`dotnet --list-runtimes\` in the terminal and look for 'Microsoft.WindowsDesktop.App' entries.` + ) + ]); + } + + // Specific mode requested + const resolvedMode: DotnetInstallMode = (lowerMode === 'runtime' || lowerMode === 'aspnetcore') + ? lowerMode as DotnetInstallMode + : 'sdk'; + + const searchContext: IDotnetSearchContext = { + mode: resolvedMode, + requestingExtensionId: 'ms-dotnettools.vscode-dotnet-runtime' + }; + + if (dotnetPath) + { + searchContext.dotnetExecutablePath = dotnetPath; + } + + const results: IDotnetSearchResult[] = await vscode.commands.executeCommand( + 'dotnet.availableInstalls', + searchContext + ); + + if (!results || results.length === 0) + { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `# No .NET ${resolvedMode === 'sdk' ? 'SDKs' : 'Runtimes'} Found\n\n` + + `${pathInfo}\n\n` + + `**Suggestions:**\n` + + `- Install .NET using the \`installSdk\` tool\n` + + `- Verify the PATH includes the .NET installation directory` + ) + ]); + } + + // Format the results + let singleModeResultText = `# Installed .NET ${resolvedMode === 'sdk' ? 'SDKs' : 'Runtimes'}\n\n`; + singleModeResultText += `${pathInfo}\n\n`; + + singleModeResultText += '| Version | Architecture | Directory |\n'; + singleModeResultText += '|---------|--------------|----------|\n'; + + for (const install of results) + { + singleModeResultText += `| ${install.version} | ${install.architecture || 'unknown'} | \`${install.directory}\` |\n`; + } + + singleModeResultText += `\nTotal: ${results.length} versions.`; + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(singleModeResultText) + ]); + } catch (error) + { + const errorMessage = error instanceof Error ? error.message : String(error); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Failed to list installed .NET versions. Error: ${errorMessage}\n` + + `Ensure .NET is installed. If specifying a path, verify the executable exists. Use installSdk tool to install.` + ) + ]); + } + } +} diff --git a/vscode-dotnet-runtime-extension/src/SettingsInfoContent.ts b/vscode-dotnet-runtime-extension/src/SettingsInfoContent.ts new file mode 100644 index 0000000000..d07ac097a1 --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/SettingsInfoContent.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ + +/** + * Comprehensive information for the AI agent about the .NET Install Tool extension. + * This explains settings, architecture, installation types, and useful tricks. + * + * Extracted into its own file to keep LanguageModelTools.ts focused on tool logic. + */ +export const settingsInfoContent = ` +# .NET Install Tool - Guide + +## Overview +The .NET Install Tool is a VS Code extension that manages .NET installations. It serves two distinct purposes: +1. **For VS Code Extensions**: Automatically installs .NET runtimes that other extensions (C#, C# DevKit, Unity, Bicep, etc.) need to run their internal components +2. **For Users**: Provides commands to find and install .NET SDKs system-wide for development + +--- + +## Installation Types + +### LOCAL (Extension-Managed) Runtime Installs +- Small, isolated .NET runtime installs stored in VS Code's extension data folder +- NOT on the system PATH, NOT visible via \`dotnet --list-runtimes\` +- Used solely by VS Code extensions to run their internal components +- Auto-managed; users rarely need to interact with these +- The extension's uninstall list ONLY shows these local installs for runtimes + +### GLOBAL/Admin SDK Installs (system-wide) +- System-wide .NET SDK installs (includes runtimes) +- Typical Locations: Windows: \`%ProgramFiles%\\dotnet\` (admin required) | macOS: \`/usr/local/share/dotnet\` (.pkg) | Linux: \`/usr/lib/dotnet\` or \`/usr/share/dotnet\` (package manager, officially Ubuntu/Debian only; WSL is not supported) +- Requires administrator/sudo privileges; users must accept elevation prompts +- IS on the system PATH after installation +- Visible via \`dotnet --list-sdks\` and \`dotnet --list-runtimes\` +- Use "Install .NET SDK System-Wide" command for this + +--- + +## existingDotnetPath Setting (Commonly Misunderstood) + +### What It Does +Controls which .NET runtime VS Code **extensions** use to run their internal components. + +### What It Does NOT Do +- Does NOT change what .NET the user's code runs on +- Does NOT affect \`dotnet build\` or \`dotnet run\` commands +- Does NOT change a project's target framework + +### When Users Need This +- Extensions fail to start with "could not find .NET runtime" errors +- Corporate/restricted environments where the extension cannot auto-download .NET +- Air-gapped machines without internet or PowerShell script execution restrictions +- User wants extensions to use a specific pre-installed .NET version + +**IMPORTANT:** If a user wants to pin which SDK their PROJECT uses (for \`dotnet build\`, \`dotnet run\`, etc.), existingDotnetPath is the WRONG setting. They should use \`global.json\` instead — see the "I want to use a local/repo-specific SDK" scenario below. + +Setting names and formats: +- \`dotnetAcquisitionExtension.existingDotnetPath\` — per-extension: +\`\`\`json +[{ "extensionId": "ms-dotnettools.csharp", "path": "C:\\\\Program Files\\\\dotnet\\\\dotnet.exe" }] +\`\`\` +- \`dotnetAcquisitionExtension.sharedExistingDotnetPath\` — all extensions: +\`\`\`json +"C:\\\\Program Files\\\\dotnet\\\\dotnet.exe" +\`\`\` + +--- + +## How to See What's Installed + +**Extension-Managed Local Installs:** Run the "Uninstall .NET" command — the dropdown shows all extension-managed installs. + +**System-Wide Global Installs:** Run \`dotnet --list-sdks\` and \`dotnet --list-runtimes\` in terminal, or use the listInstalledVersions tool. + +**listInstalledVersions Tool:** Calls \`dotnet.availableInstalls\` to scan SDKs/runtimes for a given dotnet executable. If no path provided, it uses PATH (typically global install). Returns version, architecture, and directory for each install. + +--- + +## Uninstall Notes + +- The uninstall list only shows extension-managed installs, not system-wide ones +- **Trick:** To uninstall a global SDK not in the list, first install the SAME version via the extension (this registers it), then it appears in the uninstall list +- Global SDK uninstall requires the same admin/elevated privileges as installing + +--- + +## Version Selection + +Check \`global.json\` first — if present, install \`sdk.version\` (respecting rollForward). Otherwise, latest LTS. + +SDK and runtime share major.minor but differ in patch: + +| SDK | Includes Runtime | +|---------|-----------------| +| 8.0.100 | 8.0.0 | +| 8.0.204 | 8.0.4 | + +Installing an SDK always includes the corresponding runtime. + +--- + +## global.json paths (.NET 10+) + +For repo-local SDK resolution, use the \`paths\` property in global.json: +\`\`\`json +{ + "sdk": { + "version": "10.0.100", + "paths": [".dotnet", "$host$"], + } +} +\`\`\` +- First matching SDK wins +- Only works with SDK commands (\`dotnet run\`, \`dotnet build\`), NOT with native apphost +- The host \`dotnet\` must be .NET 10+ +- Ref: https://learn.microsoft.com/dotnet/core/tools/global-json#paths + +--- + +## Common Scenarios + +- **"I want to develop .NET applications"** \u2192 Install an SDK globally via "Install .NET SDK System-Wide." This provides the \`dotnet\` CLI for build, run, test, and publish. +- **"C# extension won't start / can't find .NET"** \u2192 Check \`dotnet --version\` in terminal. If missing, install SDK globally. If installed but not detected, set existingDotnetPath or sharedExistingDotnetPath. +- **"Extension installed .NET but I can't use it in terminal"** \u2192 Extension-managed runtimes are LOCAL and not on PATH. For terminal/CLI usage, install globally. +- **"I want to use a different .NET version for my project"** \u2192 NOT existingDotnetPath. Create \`global.json\` in the project root or install the desired SDK globally. +- **"I want a local/repo-specific SDK (not global)"** \u2192 NOT existingDotnetPath. Use the \`paths\` property in global.json (.NET 10+ required) — see section above. +- **"Which dotnet does the C# extension use?"** \u2192 Use the findDotNetPath tool. It searches in order: existingDotnetPath setting \u2192 PATH \u2192 DOTNET_ROOT \u2192 extension-managed local installs. + +--- + +## Additional Settings + +- **installTimeoutValue**: Seconds to wait for downloads (default: 600). Increase for slow connections. +- **proxyUrl**: HTTP proxy URL for corporate firewalls. + +--- + +## .NET Hives Architecture +.NET supports multiple installation "hives" (locations). Extension-managed local installs do not conflict with global system installs. +- The \`dotnet\` executable typically only sees installs in its own folder +- \`dotnet.findPath\` shows which hive extensions like C# DevKit will use +- \`dotnet.availableInstalls\` lists installs in a specific hive when given an executable path +`; diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 6ec2062f84..0f044c203b 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -70,6 +70,7 @@ import InvalidUninstallRequest, IUtilityContext, JsonInstaller, + LanguageModelToolsRegistrationError, LinuxVersionResolver, LocalInstallUpdateService, LocalMemoryCacheSingleton, @@ -89,6 +90,7 @@ import import { InstallTrackerSingleton } from 'vscode-dotnet-runtime-library/dist/Acquisition/InstallTrackerSingleton'; import { EventStreamTaggingDecorator } from 'vscode-dotnet-runtime-library/dist/EventStream/EventStreamTaggingDecorator'; import { dotnetCoreAcquisitionExtensionId } from './DotnetCoreAcquisitionId'; +import { registerLanguageModelTools } from './LanguageModelTools'; import open = require('open'); const packageJson = require('../package.json'); @@ -107,6 +109,7 @@ namespace configKeys export const suppressOutput = 'suppressOutput'; export const highVerbosity = 'highVerbosity'; export const runtimeUpdateDelaySeconds = 'runtimeUpdateDelaySeconds'; + export const enableLanguageModelTools = 'enableLanguageModelTools'; } namespace commandKeys @@ -348,7 +351,7 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex new CommandExecutor(workerContext, utilContext).setPathEnvVar(dotnetPath.dotnetPath, moreInfoUrl, displayWorker, vsCodeExtensionContext, true); return dotnetPath; - }, getIssueContext(existingPathConfigWorker)(commandContext.errorConfiguration, commandKeys.acquireGlobalSDK), commandContext.requestingExtensionId, workerContext); + }, getIssueContext(existingPathConfigWorker)(commandContext.errorConfiguration, commandKeys.acquireGlobalSDK), commandContext.requestingExtensionId, workerContext, commandContext.rethrowError); const installationId = getInstallIdCustomArchitecture(commandContext.version, commandContext.architecture, commandContext.mode, 'global'); const install = { @@ -753,6 +756,22 @@ ${JSON.stringify(commandContext)}`)); async function uninstall(commandContext: IDotnetAcquireContext | undefined, force = false, onlyCheckLiveDependents = false): Promise { let result = '1'; + + // Create workerContext early if we have enough info (for error handling context) + // Wrapped in try-catch to avoid unhandled exceptions if context creation fails + let workerContext: IAcquisitionWorkerContext | undefined; + try + { + workerContext = commandContext?.mode && commandContext?.requestingExtensionId + ? getAcquisitionWorkerContext(commandContext.mode, commandContext) + : undefined; + } + catch + { + // If context creation fails, continue without it - errors will still be handled + workerContext = undefined; + } + await callWithErrorHandling(async () => { if (!commandContext?.version || !commandContext?.installType || !commandContext?.mode || !commandContext?.requestingExtensionId) @@ -765,11 +784,12 @@ ${JSON.stringify(commandContext)}`)); else { const worker = getAcquisitionWorker(); - const workerContext = getAcquisitionWorkerContext(commandContext.mode, commandContext); + // Use the pre-created workerContext if available, otherwise create it + const ctx = workerContext ?? getAcquisitionWorkerContext(commandContext.mode, commandContext); if (commandContext.installType === 'local' && !force && !(onlyCheckLiveDependents && commandContext.version.split('.').length > 1)) // if using force mode, we are also using the UI, which passes the fully specified version to uninstall only { - const versionResolver = new VersionResolver(workerContext); + const versionResolver = new VersionResolver(ctx); const resolvedVersion = await versionResolver.getFullVersion(commandContext.version, commandContext.mode); commandContext.version = resolvedVersion; } @@ -782,15 +802,15 @@ ${JSON.stringify(commandContext)}`)); if (commandContext.installType === 'local') { - result = await worker.uninstallLocal(workerContext, install, force, false, onlyCheckLiveDependents); + result = await worker.uninstallLocal(ctx, install, force, false, onlyCheckLiveDependents); } else { - const globalInstallerResolver = new GlobalInstallerResolver(workerContext, commandContext.version); - result = await worker.uninstallGlobal(workerContext, install, globalInstallerResolver, force); + const globalInstallerResolver = new GlobalInstallerResolver(ctx, commandContext.version); + result = await worker.uninstallGlobal(ctx, install, globalInstallerResolver, force); } } - }, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall')); + }, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall'), commandContext?.requestingExtensionId, workerContext, commandContext?.rethrowError); return result; } @@ -1006,6 +1026,23 @@ Installation will timeout in ${timeoutValue} seconds.`)) extensionEventStream = globalEventStream; extensionGlobalState = vsCodeContext.globalState; + // Register Language Model Tools for AI agent integration (GitHub Copilot, etc.) + const enableLanguageModelTools = extensionConfiguration.get(configKeys.enableLanguageModelTools) ?? true; + + if (enableLanguageModelTools) + { + try + { + registerLanguageModelTools(vsCodeContext, globalEventStream); + } catch (e) + { + // Language Model Tools API may not be available in older VS Code versions + // Log telemetry for the failure but continue - extension works without AI tool integration + const error = e instanceof Error ? e : new Error(String(e)); + globalEventStream.post(new LanguageModelToolsRegistrationError(error)); + } + } + // Exposing API Endpoints vsCodeContext.subscriptions.push( dotnetAcquireRegistration, diff --git a/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts b/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts new file mode 100644 index 0000000000..88d1ef781a --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts @@ -0,0 +1,856 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ +import * as chai from 'chai'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import +{ + MockEnvironmentVariableCollection, + MockExtensionConfiguration, + MockExtensionContext, + MockTelemetryReporter, + MockWindowDisplayWorker +} from 'vscode-dotnet-runtime-library'; +import * as extension from '../../extension'; +import { ToolNames } from '../../LanguageModelTools'; + +const assert: any = chai.assert; +const standardTimeoutTime = 30000; +const networkTimeoutTime = 60000; // Longer timeout for network-dependent tests + +/** + * Helper to extract text content from a LanguageModelToolResult + */ +function extractTextContent(result: vscode.LanguageModelToolResult): string +{ + return result.content + .filter((part: any) => part instanceof vscode.LanguageModelTextPart) + .map((part: any) => part.value) + .join(''); +} + +/** + * Language Model Tools Tests + * + * NOTE FOR AI AGENTS: When iterating on Language Model Tools (LanguageModelTools.ts), + * use `npm run test:lm-tools` for faster feedback (~30 seconds vs ~7 minutes for full suite). + */ +suite('LanguageModelTools Tests', function () +{ + this.retries(1); + + const storagePath = path.join(__dirname, 'tmp-lm-tools'); + const mockState = new MockExtensionContext(); + const extensionPath = path.join(__dirname, '/../../..'); + const logPath = path.join(__dirname, 'logs'); + const mockDisplayWorker = new MockWindowDisplayWorker(); + let extensionContext: vscode.ExtensionContext; + const environmentVariableCollection = new MockEnvironmentVariableCollection(); + + this.beforeAll(async () => + { + // Only activate if not already activated by prior test suites. + // Check if the extension's commands are already registered to avoid double activation. + const commands = await vscode.commands.getCommands(true); + const alreadyActivated = commands.includes('dotnet.acquire'); + + if (alreadyActivated) + { + // Extension was already activated by prior tests (e.g., DotnetCoreAcquisitionExtension.test.ts) + // The Language Model Tools should already be registered, so we just need a mock context for cleanup. + extensionContext = { + subscriptions: [], + globalStoragePath: storagePath, + globalState: mockState, + extensionPath, + logPath, + environmentVariableCollection + } as any; + return; + } + + // Running in isolation (e.g., `npm run test:lm-tools`) - need to activate the extension + extensionContext = { + subscriptions: [], + globalStoragePath: storagePath, + globalState: mockState, + extensionPath, + logPath, + environmentVariableCollection + } as any; + + process.env.DOTNET_INSTALL_TOOL_UNDER_TEST = 'true'; + extension.ReEnableActivationForManualActivation(); + extension.activate(extensionContext, { + telemetryReporter: new MockTelemetryReporter(), + extensionConfiguration: new MockExtensionConfiguration([], true, ''), + displayWorker: mockDisplayWorker, + }); + }); + + suite('Tool Registration', function () + { + test('All six Language Model Tools are registered after activation', async () => + { + const tools = vscode.lm.tools; + + // Check that our tools are registered + const toolNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + + for (const toolName of toolNames) + { + const tool = tools.find((t: vscode.LanguageModelToolInformation) => t.name === toolName); + assert.exists(tool, `Tool ${toolName} should be registered`); + } + + // Verify we have exactly 6 tools matching our tool names + const expectedNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + const ourTools = tools.filter(t => expectedNames.some(name => t.name.endsWith(name))); + assert.equal(ourTools.length, 6, 'Should have exactly 6 .NET Install Tool tools registered'); + }).timeout(standardTimeoutTime); + + test('Tool names match package.json definitions', async () => + { + assert.equal(ToolNames.installSdk, 'install_dotnet_sdk'); + assert.equal(ToolNames.listVersions, 'list_available_dotnet_versions_to_install'); + assert.equal(ToolNames.listInstalledVersions, 'list_installed_dotnet_versions'); + assert.equal(ToolNames.findPath, 'find_dotnet_executable_path'); + assert.equal(ToolNames.uninstall, 'uninstall_dotnet'); + assert.equal(ToolNames.getSettingsInfo, 'get_settings_info_for_dotnet_installation_management'); + }).timeout(standardTimeoutTime); + + test('Tool names follow expected naming convention', async () => + { + const expectedNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + const tools = vscode.lm.tools.filter(t => expectedNames.some(name => t.name.endsWith(name))); + + for (const tool of tools) + { + // Name should contain an underscore-separated identifier + assert.match(tool.name, /_[a-z_]+$/, `Tool name ${tool.name} should follow naming convention`); + } + }).timeout(standardTimeoutTime); + }); + + suite('Tool Metadata', function () + { + test('All registered tools have meaningful descriptions', async () => + { + const tools = vscode.lm.tools; + + for (const tool of tools) + { + const expectedNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + if (expectedNames.some(name => tool.name.endsWith(name))) + { + assert.exists(tool.description, `Tool ${tool.name} should have a description`); + assert.isString(tool.description, `Tool ${tool.name} description should be a string`); + assert.isAbove(tool.description.length, 20, `Tool ${tool.name} description should be meaningful (at least 20 chars)`); + } + } + }).timeout(standardTimeoutTime); + + test('Tools have display names accessible via vscode.lm.tools', async () => + { + const tools = vscode.lm.tools; + const expectedNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + const ourTools = tools.filter(t => expectedNames.some(name => t.name.endsWith(name))); + + // All our tools should be retrievable + assert.isAbove(ourTools.length, 0, 'Should find our tools in vscode.lm.tools'); + }).timeout(standardTimeoutTime); + }); + + suite('GetSettingsInfo Tool', function () + { + test('Returns comprehensive guide content', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + assert.isArray(result.content, 'Content should be an array'); + assert.isAbove(result.content.length, 0, 'Content should not be empty'); + + const textContent = extractTextContent(result); + + // Verify key sections are present + assert.include(textContent, 'existingDotnetPath', 'Content should explain existingDotnetPath setting'); + assert.include(textContent, 'LOCAL', 'Content should explain local installs'); + assert.include(textContent, 'GLOBAL', 'Content should explain global installs'); + assert.include(textContent, 'PATH', 'Content should mention PATH'); + }).timeout(standardTimeoutTime); + + test('Explains installation types (local vs global)', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should explain the difference between local and global installs + assert.include(textContent, 'Extension-Managed', 'Should explain extension-managed installs'); + assert.include(textContent, 'system-wide', 'Should explain system-wide installs'); + assert.include(textContent, 'Program Files', 'Should mention Program Files for Windows'); + }).timeout(standardTimeoutTime); + + test('Explains global.json handling', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should explain global.json + assert.include(textContent, 'global.json', 'Should mention global.json'); + assert.include(textContent, 'sdk', 'Should mention SDK context'); + }).timeout(standardTimeoutTime); + + test('Includes current settings values', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should include a section about current settings + assert.include(textContent, 'Current Settings', 'Should have current settings section'); + }).timeout(standardTimeoutTime); + + test('Explains SDK vs Runtime versioning', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should explain SDK/Runtime relationship + assert.include(textContent, 'SDK', 'Should mention SDK'); + assert.include(textContent, 'Runtime', 'Should mention Runtime'); + }).timeout(standardTimeoutTime); + }); + + suite('ListVersions Tool', function () + { + test('Returns SDK versions when listRuntimes is false', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listVersions, + { input: { listRuntimes: false }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + assert.isArray(result.content, 'Content should be an array'); + assert.isAbove(result.content.length, 0, 'Content should not be empty'); + + const textContent = extractTextContent(result); + + // Should contain SDK version information or an error message + const hasVersionInfo = textContent.includes('SDK') || textContent.includes('version') || textContent.includes('network'); + assert.isTrue(hasVersionInfo, 'Content should contain version information or network error'); + }).timeout(networkTimeoutTime); + + test('Returns Runtime versions when listRuntimes is true', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listVersions, + { input: { listRuntimes: true }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + + const textContent = extractTextContent(result); + + // Should contain Runtime version information or an error message + const hasVersionInfo = textContent.includes('Runtime') || textContent.includes('version') || textContent.includes('network'); + assert.isTrue(hasVersionInfo, 'Content should contain version information or network error'); + }).timeout(networkTimeoutTime); + + test('Defaults to SDK versions when no input provided', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listVersions, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + }).timeout(networkTimeoutTime); + + test('Groups versions by support phase when available', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listVersions, + { input: { listRuntimes: false }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // If we got versions (not a network error), should have support phase grouping + if (textContent.includes('Available')) + { + // Should group by support phase (Active, Maintenance, EOL) + const hasGrouping = textContent.includes('Active') || + textContent.includes('Maintenance') || + textContent.includes('Recommended'); + assert.isTrue(hasGrouping, 'Should group versions by support phase or have recommendation'); + } + }).timeout(networkTimeoutTime); + }); + + suite('FindPath Tool', function () + { + test('Requires version parameter', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should indicate version is required + assert.include(textContent.toLowerCase(), 'version', 'Should mention version requirement'); + }).timeout(standardTimeoutTime); + + test('Searches for .NET with valid version input', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: { version: '8.0' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + + const textContent = extractTextContent(result); + + // Should either find .NET or report not found + const hasResult = textContent.includes('Found') || + textContent.includes('Not Found') || + textContent.includes('Path') || + textContent.includes('not found') || + textContent.includes('resolve'); + assert.isTrue(hasResult, 'Should report whether .NET was found or not'); + }).timeout(standardTimeoutTime); + + test('Accepts mode parameter (sdk vs runtime)', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: { version: '8.0', mode: 'sdk' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + + const textContent = extractTextContent(result); + + // Should mention SDK in response + const mentionsSdk = textContent.includes('SDK') || textContent.includes('sdk'); + assert.isTrue(mentionsSdk, 'Response should reference SDK mode'); + }).timeout(standardTimeoutTime); + + test('Accepts architecture parameter', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: { version: '8.0', architecture: 'x64' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + + const textContent = extractTextContent(result); + + // Should mention architecture in response + assert.include(textContent, 'x64', 'Response should mention the architecture'); + }).timeout(standardTimeoutTime); + + test('Explains search locations when .NET not found', async () => + { + // Use a version that likely doesn't exist + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: { version: '99.0' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should explain where we searched + if (textContent.includes('Not Found') || textContent.includes('not found')) + { + const explainedLocations = textContent.includes('PATH') || + textContent.includes('DOTNET_ROOT') || + textContent.includes('existingDotnetPath'); + assert.isTrue(explainedLocations, 'Should explain search locations when not found'); + } + }).timeout(standardTimeoutTime); + }); + + suite('ListInstalledVersions Tool', function () + { + test('Can be invoked without parameters (uses PATH)', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listInstalledVersions, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + + const textContent = extractTextContent(result); + + // Should either list versions or explain none found + const hasResult = textContent.includes('Installed') || + textContent.includes('found') || + textContent.includes('SDK') || + textContent.includes('No .NET'); + assert.isTrue(hasResult, 'Should report installed versions or indicate none found'); + }).timeout(standardTimeoutTime); + + test('Accepts optional dotnetPath parameter', async () => + { + // Provide a path (may or may not exist) + const result = await vscode.lm.invokeTool( + ToolNames.listInstalledVersions, + { input: { dotnetPath: 'C:\\Program Files\\dotnet\\dotnet.exe' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + }).timeout(standardTimeoutTime); + + test('Accepts mode parameter (sdk vs runtime)', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listInstalledVersions, + { input: { mode: 'runtime' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + + const textContent = extractTextContent(result); + + // Should mention Runtime in response + const mentionsRuntime = textContent.includes('Runtime') || textContent.includes('runtime'); + assert.isTrue(mentionsRuntime, 'Response should reference runtime mode'); + }).timeout(standardTimeoutTime); + + test('Returns table format with version details', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listInstalledVersions, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // If versions are found, should have table format + if (textContent.includes('|') && textContent.includes('Version')) + { + // SDKs table has Architecture column; Runtimes are grouped by mode + assert.include(textContent, 'Architecture', 'Table should include Architecture column'); + } + }).timeout(standardTimeoutTime); + }); + + suite('Uninstall Tool', function () + { + test('Can be invoked without parameters (launches interactive picker)', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + + const textContent = extractTextContent(result); + + // Should mention interactive dialog or selection + const mentionsInteractive = textContent.includes('interactive') || + textContent.includes('dialog') || + textContent.includes('select') || + textContent.includes('dropdown'); + assert.isTrue(mentionsInteractive, 'Should mention interactive uninstall when no version provided'); + }).timeout(standardTimeoutTime); + + test('Accepts version parameter', async () => + { + // This won't actually uninstall anything, but should accept the parameter + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: { version: '6.0.0' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + assert.exists(result.content, 'Result should have content'); + }).timeout(standardTimeoutTime); + + test('Accepts mode parameter', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: { version: '6.0.0', mode: 'sdk' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + }).timeout(standardTimeoutTime); + + test('Accepts global parameter', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: { version: '6.0.0', global: true }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Tool should return a result'); + }).timeout(standardTimeoutTime); + }); + + suite('InstallSdk Tool (Validation Only)', function () + { + // Note: We don't actually install in tests to avoid side effects + // These tests validate the tool accepts parameters correctly + + test('Tool is registered and can be found', async () => + { + const tools = vscode.lm.tools; + const installTool = tools.find((t: vscode.LanguageModelToolInformation) => t.name === ToolNames.installSdk); + + assert.exists(installTool, 'Install SDK tool should be registered'); + assert.exists(installTool?.description, 'Install SDK tool should have a description'); + }).timeout(standardTimeoutTime); + + test('Tool description mentions global/system-wide installation', async () => + { + const tools = vscode.lm.tools; + const installTool = tools.find((t: vscode.LanguageModelToolInformation) => t.name === ToolNames.installSdk); + + const description = installTool?.description?.toLowerCase() || ''; + const mentionsGlobal = description.includes('global') || description.includes('system'); + assert.isTrue(mentionsGlobal, 'Description should mention global/system-wide installation'); + }).timeout(standardTimeoutTime); + }); + + suite('Tool Error Handling', function () + { + test('FindPath handles missing version gracefully', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should not throw, should return helpful message + assert.include(textContent.toLowerCase(), 'version', 'Should mention version is needed'); + }).timeout(standardTimeoutTime); + + test('FindPath handles non-existent version gracefully', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.findPath, + { input: { version: '999.999' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should return not found, not throw + assert.exists(result, 'Should return a result, not throw'); + const hasNotFoundMsg = textContent.includes('Not Found') || + textContent.includes('not found') || + textContent.includes('No .NET'); + assert.isTrue(hasNotFoundMsg, 'Should indicate version was not found'); + }).timeout(standardTimeoutTime); + + test('ListInstalledVersions handles invalid path gracefully', async () => + { + const result = await vscode.lm.invokeTool( + ToolNames.listInstalledVersions, + { input: { dotnetPath: '/nonexistent/path/dotnet' }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, 'Should return a result, not throw'); + }).timeout(standardTimeoutTime); + + test('All tools return LanguageModelToolResult with content array', async () => + { + const toolsToTest = [ + { name: ToolNames.getSettingsInfo, input: {} }, + { name: ToolNames.findPath, input: { version: '8.0' } }, + { name: ToolNames.listInstalledVersions, input: {} }, + { name: ToolNames.uninstall, input: {} } + ]; + + for (const toolTest of toolsToTest) + { + const result = await vscode.lm.invokeTool( + toolTest.name, + { input: toolTest.input, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + assert.exists(result, `${toolTest.name} should return a result`); + assert.exists(result.content, `${toolTest.name} result should have content`); + assert.isArray(result.content, `${toolTest.name} content should be an array`); + } + }).timeout(networkTimeoutTime); + }); + + suite('Cancellation Token Support', function () + { + test('Tools accept cancellation token without error', async () => + { + const cts = new vscode.CancellationTokenSource(); + + // Don't cancel, just verify token is accepted + const result = await vscode.lm.invokeTool( + ToolNames.getSettingsInfo, + { input: {}, toolInvocationToken: undefined }, + cts.token + ); + + assert.exists(result, 'Tool should complete with cancellation token'); + cts.dispose(); + }).timeout(standardTimeoutTime); + }); + + suite('Error Propagation to LLM', function () + { + /** + * These tests verify that errors are properly surfaced to the LLM + * so it has context about what went wrong (e.g., user cancelled, installation failed). + */ + + test('InstallSdk tool returns ERROR message when installation fails or returns undefined', async () => + { + // We can't easily simulate a failed global SDK install without admin privileges, + // but we can verify the tool handles the case when version is missing + const result = await vscode.lm.invokeTool( + ToolNames.installSdk, + { input: {} /* no version */, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // When no version provided, should return clear ERROR message + assert.include(textContent, 'ERROR', 'Should include ERROR keyword for missing version'); + assert.include(textContent, 'version', 'Should mention version is required'); + }).timeout(standardTimeoutTime); + + test('InstallSdk tool context includes rethrowError property', async () => + { + // Verify the context object is built correctly by checking the source code behavior + // The actual rethrowError test is in ErrorHandler.test.ts + // Here we verify the tool configuration is correct + + // This test validates that when an error occurs, the tool's catch block + // will receive the actual error message (verified by the unit tests in ErrorHandler.test.ts) + + const tools = vscode.lm.tools; + const installTool = tools.find(t => t.name === ToolNames.installSdk); + + assert.exists(installTool, 'Install SDK tool should be registered'); + // The actual rethrowError behavior is tested via ErrorHandler.test.ts + // This integration test just confirms the tool is properly configured + }).timeout(standardTimeoutTime); + + test('Uninstall tool surfaces errors when operation fails', async () => + { + // Try to uninstall a non-existent version + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: { version: '1.0.0', mode: 'sdk', global: true }, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should either fail gracefully or provide an informative message + // The key is that it returns something informative, not just "undefined" or silence + assert.exists(result, 'Should return a result even on failure'); + assert.isAbove(textContent.length, 0, 'Should provide informative feedback'); + }).timeout(standardTimeoutTime); + + test('Interactive uninstall mentions unknown outcome for LLM awareness', async () => + { + // When no version is provided, interactive picker is launched + // The outcome is unknown to the tool - it should inform the LLM of this + const result = await vscode.lm.invokeTool( + ToolNames.uninstall, + { input: {}, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should indicate the outcome is unknown (user might have cancelled) + const mentionsUnknownOutcome = textContent.includes('unknown') || + textContent.includes('cancelled') || + textContent.includes('Ask the user') || + textContent.includes('IMPORTANT'); + assert.isTrue(mentionsUnknownOutcome, 'Should inform LLM that interactive outcome is unknown'); + }).timeout(standardTimeoutTime); + + test('Error messages contain actionable information for LLM', async () => + { + // Test that error responses contain enough context for the LLM to help the user + const result = await vscode.lm.invokeTool( + ToolNames.installSdk, + { input: {} /* missing required version */, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token + ); + + const textContent = extractTextContent(result); + + // Should contain guidance on how to fix the issue + const hasActionableGuidance = textContent.includes('How to') || + textContent.includes('TargetFramework') || + textContent.includes('global.json') || + textContent.includes('listDotNetVersions') || + textContent.includes('call'); + + assert.isTrue(hasActionableGuidance, 'Error messages should provide actionable guidance'); + }).timeout(standardTimeoutTime); + }); + + suite('Enable/Disable Setting', function () + { + test('enableLanguageModelTools setting exists and defaults to true', async () => + { + const config = vscode.workspace.getConfiguration('dotnetAcquisitionExtension'); + const value = config.get('enableLanguageModelTools'); + // The default in package.json is true, so unless explicitly overridden it should be true + assert.isTrue(value, 'enableLanguageModelTools should default to true'); + }).timeout(standardTimeoutTime); + + test('Tools are registered when enableLanguageModelTools is true (default)', async () => + { + // Since the extension activated with the default (true), all tools should be present + const expectedNames = [ + ToolNames.installSdk, + ToolNames.listVersions, + ToolNames.listInstalledVersions, + ToolNames.findPath, + ToolNames.uninstall, + ToolNames.getSettingsInfo + ]; + const tools = vscode.lm.tools; + for (const name of expectedNames) + { + const tool = tools.find((t: vscode.LanguageModelToolInformation) => t.name === name); + assert.exists(tool, `Tool ${name} should be registered when setting is true`); + } + }).timeout(standardTimeoutTime); + + test('Tools are visible when enableLanguageModelTools setting is true (default)', async () => + { + // With the config.* when clause, VS Code reads the setting directly. + // Since the default is true, tools should be visible and registered. + const tool = vscode.lm.tools.find((t: vscode.LanguageModelToolInformation) => t.name === ToolNames.installSdk); + assert.exists(tool, 'Tools should be available when setting is true'); + }).timeout(standardTimeoutTime); + + test('package.json tools all have when clause for enableLanguageModelTools', async () => + { + // Read the package.json to verify all tools have the when clause. + // This is a structural test to prevent regressions - if someone adds a new tool + // without a when clause, the disable setting won't fully work. + const fs = await import('fs'); + const packageJsonPath = path.join(extensionPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const tools = packageJson?.contributes?.languageModelTools; + assert.isArray(tools, 'package.json should have languageModelTools'); + assert.isAbove(tools.length, 0, 'Should have at least one tool defined'); + + for (const tool of tools) + { + assert.property(tool, 'when', `Tool "${tool.name}" should have a "when" clause`); + assert.include( + tool.when, + 'config.dotnetAcquisitionExtension.enableLanguageModelTools', + `Tool "${tool.name}" when clause should reference config.dotnetAcquisitionExtension.enableLanguageModelTools` + ); + } + }).timeout(standardTimeoutTime); + }); +}); diff --git a/vscode-dotnet-runtime-extension/src/test/functional/index.ts b/vscode-dotnet-runtime-extension/src/test/functional/index.ts index 755e7691a3..af44a777a0 100644 --- a/vscode-dotnet-runtime-extension/src/test/functional/index.ts +++ b/vscode-dotnet-runtime-extension/src/test/functional/index.ts @@ -1,50 +1,55 @@ -/*--------------------------------------------------------------------------------------------- -* Licensed to the .NET Foundation under one or more agreements. -* The .NET Foundation licenses this file to you under the MIT license. -*--------------------------------------------------------------------------------------------*/ -import * as glob from 'glob'; -import * as Mocha from 'mocha'; -import * as path from 'path'; - -export function run(): Promise -{ - // Create the mocha test - const mocha = new Mocha({ - ui: 'tdd', - color: true, - }); - - const testsRoot = path.resolve(__dirname, '..'); - - return new Promise((c, e) => - { - glob('**/functional/**.test.js', { cwd: testsRoot }, (err, files) => - { - if (err) - { - return e(err); - } - - // Add files to the test suite - files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); - - try - { - // Run the mocha test - mocha.run(failures => - { - if (failures > 0) - { - e(new Error(`${failures} tests failed.`)); - } else - { - c(); - } - }); - } catch (err) - { - e(err); - } - }); - }); -} +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ +import * as glob from 'glob'; +import * as Mocha from 'mocha'; +import * as path from 'path'; + +export function run(): Promise +{ + // Create the mocha test + const mocha = new Mocha({ + ui: 'tdd', + color: true, + // Support filtering tests by grep pattern via environment variable + grep: process.env.TEST_GREP ? new RegExp(process.env.TEST_GREP) : undefined, + }); + + const testsRoot = path.resolve(__dirname, '..'); + + // Support filtering test files via environment variable (e.g., "LanguageModelTools" to only run that file) + const testFilePattern = process.env.TEST_FILE_PATTERN || '**/functional/**.test.js'; + + return new Promise((c, e) => + { + glob(testFilePattern, { cwd: testsRoot }, (err, files) => + { + if (err) + { + return e(err); + } + + // Add files to the test suite + files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); + + try + { + // Run the mocha test + mocha.run(failures => + { + if (failures > 0) + { + e(new Error(`${failures} tests failed.`)); + } else + { + c(); + } + }); + } catch (err) + { + e(err); + } + }); + }); +} diff --git a/vscode-dotnet-runtime-extension/src/test/functional/indexLmTools.ts b/vscode-dotnet-runtime-extension/src/test/functional/indexLmTools.ts new file mode 100644 index 0000000000..980c3fda31 --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/test/functional/indexLmTools.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ + +/** + * Test index for Language Model Tools tests only. + * This runs only LanguageModelTools.test.js for faster iteration during development. + */ + +import * as Mocha from 'mocha'; +import * as path from 'path'; + +export function run(): Promise { + const mocha = new Mocha({ + ui: 'tdd', + color: true, + }); + + const testsRoot = path.resolve(__dirname, '..'); + + return new Promise((c, e) => { + // Only add the Language Model Tools test file + const lmToolsTestFile = path.resolve(testsRoot, 'functional/LanguageModelTools.test.js'); + mocha.addFile(lmToolsTestFile); + + try { + mocha.run(failures => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + e(err); + } + }); +} diff --git a/vscode-dotnet-runtime-extension/src/test/functional/runLmToolsTest.ts b/vscode-dotnet-runtime-extension/src/test/functional/runLmToolsTest.ts new file mode 100644 index 0000000000..a0fd72aab4 --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/test/functional/runLmToolsTest.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ + +/** + * This is a dedicated test runner for Language Model Tools tests only. + * Use `npm run test:lm-tools` to run these tests in isolation for faster iteration. + * + * NOTE FOR AI AGENTS: When working on Language Model Tools (LanguageModelTools.ts), + * use `npm run test:lm-tools` instead of `npm test` for faster feedback. + * The full test suite takes ~7 minutes, while LM tools tests take ~30 seconds. + */ + +import * as os from 'os'; +import * as path from 'path'; + +import { runTests } from '@vscode/test-electron'; + +async function main() +{ + try + { + const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); + const extensionTestsPath = path.resolve(__dirname, './indexLmTools'); + + let platformValue = ''; + switch (os.platform()) + { + case 'win32': + platformValue = 'win32-x64-archive'; + break; + case 'darwin': + platformValue = 'darwin'; + break; + case 'linux': + platformValue = 'linux-x64'; + break; + } + + await runTests( + { + ...(platformValue !== '' && { platform: platformValue }), + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [ + '--disable-extensions', + ], + extensionTestsEnv: { DOTNET_INSTALL_TOOL_UNDER_TEST: 'true' } + } + ); + } + catch (err) + { + console.error(err); + console.error('Failed to run Language Model Tools tests'); + process.exit(1); + } +} + +main(); diff --git a/vscode-dotnet-runtime-extension/yarn.lock b/vscode-dotnet-runtime-extension/yarn.lock index 0031f8d8b7..0e906fa34c 100644 --- a/vscode-dotnet-runtime-extension/yarn.lock +++ b/vscode-dotnet-runtime-extension/yarn.lock @@ -481,6 +481,46 @@ ora "^8.1.0" semver "^7.6.2" +"@vscode/vsce-sign-alpine-arm64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz" + integrity sha1-LNJEyvXo7FQ/QvuR1N87kzZByPo= + +"@vscode/vsce-sign-alpine-x64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz" + integrity sha1-sOgKR5IAHGbif+7iwR6CGtH6FoA= + +"@vscode/vsce-sign-darwin-arm64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz" + integrity sha1-S4+hq1XygKmZhb48BvtzDleBDM4= + +"@vscode/vsce-sign-darwin-x64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz" + integrity sha1-0skYbZUFSYJyy93YODuwOOvPWCA= + +"@vscode/vsce-sign-linux-arm@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz" + integrity sha1-CifEKkrbN+lu7HjNe/o4jNTp++8= + +"@vscode/vsce-sign-linux-arm64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz" + integrity sha1-s9hWAUQEC5INjG7dQ3QxS1glVIE= + +"@vscode/vsce-sign-linux-x64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz" + integrity sha1-reEcru7VJPwWvWxDykmuoAKV3ow= + +"@vscode/vsce-sign-win32-arm64@2.0.6": + version "2.0.6" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz" + integrity sha1-BoiWgUjgPrOSR5yEkcclBnIb7/w= + "@vscode/vsce-sign-win32-x64@2.0.6": version "2.0.6" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz" diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index 86fc03d96f..6ae81e3f70 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -163,7 +163,7 @@ If you cannot change this flag, try setting a custom existingDotnetPath via the catch (error: any) { // Remove this when https://github.com/typescript-eslint/typescript-eslint/issues/2728 is done - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const newError = new EventBasedError('DotnetAcquisitionUnexpectedError', error?.message, error?.stack); this.eventStream.post(new DotnetAcquisitionUnexpectedError(newError, install)); reject(newError); diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index a9d359cb37..b46823af58 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -415,7 +415,7 @@ export class DotnetCoreAcquisitionWorker implements IDotnetCoreAcquisitionWorker private async acquireGlobalCore(context: IAcquisitionWorkerContext, globalInstallerResolver: GlobalInstallerResolver, install: DotnetInstall): Promise { - if (await isRunningUnderWSL(context, this.utilityContext)) + if (await isRunningUnderWSL(context.eventStream)) { const err = new DotnetWSLSecurityError(new EventCancellationError('DotnetWSLSecurityError', `Automatic .NET SDK Installation is not yet supported in WSL due to VS Code & WSL limitations. diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetHostPathFinder.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetHostPathFinder.ts index bbbc875898..c1e89d9e85 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetHostPathFinder.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetHostPathFinder.ts @@ -327,7 +327,7 @@ export class DotnetHostPathFinder implements IDotnetPathFinder paths.push(path.join(realPath, getDotnetExecutable())); } } - catch (error: any) // eslint-disable-line @typescript-eslint/no-explicit-any + catch (error: any) { // readfile throws if the file gets deleted in between the existing check and now // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetResolver.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetResolver.ts index 5da63f6f20..2a93d74002 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetResolver.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetResolver.ts @@ -230,7 +230,7 @@ export class DotnetResolver implements IDotnetResolver * * @remarks Will return '' if the architecture cannot be determined for some peculiar reason (e.g. dotnet --info is broken or changed). */ - // eslint-disable-next-line @typescript-eslint/require-await + private async getHostArchitectureViaInfo(hostPath: string, expectedArchitecture?: string | null): Promise { // dotnet --info is not machine-readable and subject to breaking changes. See https://github.com/dotnet/sdk/issues/33697 and https://github.com/dotnet/runtime/issues/98735/ diff --git a/vscode-dotnet-runtime-library/src/Acquisition/IDistroDotnetSDKProvider.ts b/vscode-dotnet-runtime-library/src/Acquisition/IDistroDotnetSDKProvider.ts index d8b6862b19..2134a9869b 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/IDistroDotnetSDKProvider.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/IDistroDotnetSDKProvider.ts @@ -319,13 +319,13 @@ If you would like to contribute to the list of supported distros, please visit: protected myDistroStrings(stringKey: string): string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return this.distroJson[this.distroVersion.distro][stringKey]; } protected myDistroCommands(commandKey: string): CommandExecutorCommand[] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return this.distroJson[this.distroVersion.distro][commandKey] as CommandExecutorCommand[]; } @@ -333,11 +333,11 @@ If you would like to contribute to the list of supported distros, please visit: { const validCommands: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const baseCommands = (Object.values(this.distroJson[this.distroVersion.distro]) .filter((x: any) => x && Array.isArray(x) && ((x[0] as CommandExecutorCommand).commandParts))).flat(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + let preInstallCommands = this.myVersionDetails()[this.preinstallCommandKey] as CommandExecutorCommand[]; if (!preInstallCommands) { @@ -367,15 +367,15 @@ If you would like to contribute to the list of supported distros, please visit: { let allPackages: string[] = []; // Remove this when https://github.com/typescript-eslint/typescript-eslint/issues/2728 is done - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const distroPackages = this.distroJson[this.distroVersion.distro][this.distroPackagesKey]; for (const packageSet of distroPackages) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + allPackages = allPackages.concat(packageSet[this.sdkKey]); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + allPackages = allPackages.concat(packageSet[this.runtimeKey]) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + allPackages = allPackages.concat(packageSet[this.aspNetKey]) } return allPackages; diff --git a/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts b/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts index 0242fe46f4..234a6dfa17 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts @@ -203,6 +203,8 @@ export class InstallTrackerSingleton const isDead = await this.isSessionDead(sessionId); if (isDead) { + + this.eventStream.post(new DependentIsDead(`Dependent Session ${sessionId} is no longer live - continue searching dependents.`)) existingSessionsWithUsedExecutablePaths.delete(sessionId); await this.extensionState.update(this.sessionInstallsKey, serializeMapOfSets(existingSessionsWithUsedExecutablePaths)); } diff --git a/vscode-dotnet-runtime-library/src/Acquisition/LinuxVersionResolver.ts b/vscode-dotnet-runtime-library/src/Acquisition/LinuxVersionResolver.ts index 2d0a49d987..75bd3278f5 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/LinuxVersionResolver.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/LinuxVersionResolver.ts @@ -3,7 +3,9 @@ * The .NET Foundation licenses this file to you under the MIT license. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ +import * as os from 'os'; import * as path from 'path'; +import { IEventStream } from '../EventStream/EventStream'; import { DotnetAcquisitionDistroUnknownError, @@ -20,6 +22,7 @@ import { FileUtilities } from '../Utils/FileUtilities'; import { ICommandExecutor } from '../Utils/ICommandExecutor'; import { getInstallFromContext } from '../Utils/InstallIdUtilities'; import { IUtilityContext } from '../Utils/IUtilityContext'; +import { getRunningDistro, microsoftSupportedDistroIds } from '../Utils/TypescriptUtilities'; import { DebianDistroSDKProvider } from './DebianDistroSDKProvider'; import { DotnetInstallMode } from './DotnetInstallMode'; import { GenericDistroSDKProvider } from './GenericDistroSDKProvider'; @@ -95,9 +98,6 @@ Follow the instructions here to download the .NET SDK: https://learn.microsoft.c Or, install Red Hat Enterprise Linux 8.0 or Red Hat Enterprise Linux 9.0 from https://access.redhat.com/downloads/;` protected acquireCtx: IDotnetAcquireContext | null | undefined; - // This includes all distros that we officially support for this tool as a company. If a distro is not in this list, it can still have community member support. - public microsoftSupportedDistroIds = [RED_HAT_DISTRO_INFO_KEY, UBUNTU_DISTRO_INFO_KEY]; - constructor(private readonly workerContext: IAcquisitionWorkerContext, private readonly utilityContext: IUtilityContext, executor: ICommandExecutor | null = null, distroProvider: IDistroDotnetSDKProvider | null = null) { @@ -112,48 +112,20 @@ Or, install Red Hat Enterprise Linux 8.0 or Red Hat Enterprise Linux 9.0 from ht } /** + * Instance method that delegates to the static getRunningDistro, adding error events and caching. * @remarks relies on /etc/os-release currently. public for testing purposes. - * @returns The linux distro and version thats running this app. Should only ever be ran on linux. + * @returns The linux distro and version. Throws if it cannot be determined. */ - public async getRunningDistro(): Promise + public async getRunningDistroInstance(): Promise { if (this.distro) { return this.distro; } - const mainOSDeclarationFile = `/etc/os-release`; - // Some distros may not include the os-release file specified by system d, https://0pointer.de/blog/projects/os-release and this is a recommended fallback https://man7.org/linux/man-pages/man5/os-release.5.html - const backupOSDeclarationFile = `/usr/lib/os-release`; - const osDeclarationFile = await new FileUtilities().exists(mainOSDeclarationFile) ? mainOSDeclarationFile : backupOSDeclarationFile; + const result = await getRunningDistro(this.workerContext.eventStream); - const distroNameKey = 'NAME'; - const distroVersionKey = 'VERSION_ID'; - try - { - const osInfo = (await new FileUtilities().read(osDeclarationFile)).split('\n'); - // We need to remove the quotes from the KEY="VALUE"\n pairs returned by the command stdout, and then turn it into a dictionary. We can't use replaceAll for older browsers. - // Replace only replaces one quote, so we remove the 2nd one later. - const infoWithQuotesRemoved = osInfo.map(x => x.replace('"', '')); - const infoWithSeparatedKeyValues = infoWithQuotesRemoved.map(x => x.split('=')); - const keyValueMap = Object.fromEntries(infoWithSeparatedKeyValues.map(x => [x[0], x[1]])); - - // Remove the 2nd quotes. - const distroName: string = keyValueMap[distroNameKey]?.replace('"', '') ?? ''; - const distroVersion: string = keyValueMap[distroVersionKey]?.replace('"', '') ?? ''; - - if (distroName === '' || distroVersion === '') - { - const error = new DotnetAcquisitionDistroUnknownError(new EventCancellationError('DotnetAcquisitionDistroUnknownError', - this.baseUnsupportedDistroErrorMessage), getInstallFromContext(this.workerContext)); - this.workerContext.eventStream.post(error); - throw error.error; - } - - const pair: DistroVersionPair = { distro: distroName, version: distroVersion }; - return pair; - } - catch (error) + if (!result || result.distro === '' || result.version === '') { const err = new DotnetAcquisitionDistroUnknownError(new EventCancellationError('DotnetAcquisitionDistroUnknownError', `${this.baseUnsupportedDistroErrorMessage} ... does /etc/os-release or /usr/lib/os-release exist?`), @@ -161,6 +133,9 @@ Or, install Red Hat Enterprise Linux 8.0 or Red Hat Enterprise Linux 9.0 from ht this.workerContext.eventStream.post(err); throw err.error; } + + this.distro = result; + return this.distro; } @@ -173,7 +148,7 @@ Or, install Red Hat Enterprise Linux 8.0 or Red Hat Enterprise Linux 9.0 from ht { if (!this.distro) { - this.distro = await this.getRunningDistro(); + this.distro = await this.getRunningDistroInstance(); } if (!this.distroSDKProvider) @@ -192,7 +167,7 @@ Or, install Red Hat Enterprise Linux 8.0 or Red Hat Enterprise Linux 9.0 from ht } else { - if (!this.microsoftSupportedDistroIds.includes(this.distro.distro)) + if (!microsoftSupportedDistroIds.includes(this.distro.distro)) { // UX: Could eventually add a 'Go away' button via the callback: this.utilityContext.ui.showInformationMessage(`Automated SDK installation for the distro ${this.distro.distro} is not officially supported, except for community implemented and Microsoft approved support. diff --git a/vscode-dotnet-runtime-library/src/Acquisition/RegistryReader.ts b/vscode-dotnet-runtime-library/src/Acquisition/RegistryReader.ts index 19c2283158..62495564ce 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/RegistryReader.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/RegistryReader.ts @@ -13,7 +13,7 @@ import { DOTNET_INFORMATION_CACHE_DURATION_MS } from './CacheTimeConstants'; import { IAcquisitionWorkerContext } from './IAcquisitionWorkerContext'; import { IRegistryReader } from "./IRegistryReader"; -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + export class RegistryReader extends IRegistryReader { diff --git a/vscode-dotnet-runtime-library/src/Acquisition/VersionResolver.ts b/vscode-dotnet-runtime-library/src/Acquisition/VersionResolver.ts index 27ef3e4abd..07e02b7e69 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/VersionResolver.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/VersionResolver.ts @@ -80,7 +80,7 @@ export class VersionResolver implements IVersionResolver // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (release?.['release-type'] === 'lts' || release?.['release-type'] === 'sts') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + availableVersions?.push({ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access supportStatus: (release?.['release-type'] as DotnetVersionSupportStatus) ?? 'sts', diff --git a/vscode-dotnet-runtime-library/src/Acquisition/WinMacGlobalInstaller.ts b/vscode-dotnet-runtime-library/src/Acquisition/WinMacGlobalInstaller.ts index 2c0ccf44df..22636ed43c 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/WinMacGlobalInstaller.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/WinMacGlobalInstaller.ts @@ -354,7 +354,7 @@ Permissions: ${JSON.stringify(await this.commandRunner.execute(CommandExecutor.m } // async is needed to match the interface even if we don't use await. - // eslint-disable-next-line @typescript-eslint/require-await + public async getExpectedGlobalSDKPath(specificSDKVersionInstalled: string, installedArch: string, macPathShouldExist = true): Promise { if (os.platform() === 'win32') diff --git a/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts b/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts index 76c5b260f9..1f6797ef2c 100644 --- a/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts +++ b/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts @@ -1054,13 +1054,13 @@ export class DotnetUninstallStarted extends DotnetCustomMessageEvent export class DotnetUninstallCompleted extends DotnetCustomMessageEvent { - public readonly eventName = 'DotnetUninstallStarted'; + public readonly eventName = 'DotnetUninstallCompleted'; public type = EventType.DotnetUninstallMessage; } export class DotnetUninstallFailed extends DotnetCustomMessageEvent { - public readonly eventName = 'DotnetUninstallStarted'; + public readonly eventName = 'DotnetUninstallFailed'; public type = EventType.DotnetUninstallMessage; } @@ -2000,6 +2000,76 @@ export class TestAcquireCalled extends IEvent } } +/** + * Event for when a Language Model Tool is invoked by an AI agent. + * This provides visibility into AI tool usage in the logs. + */ +export class LanguageModelToolInvoked extends DotnetAcquisitionMessage +{ + public readonly eventName = 'LanguageModelToolInvoked'; + + constructor(public readonly toolName: string, public readonly input: string) + { + super(); + } + + public getProperties(telemetry = false): { [id: string]: string } | undefined + { + return { + ToolName: this.toolName, + // Don't include full input in telemetry to avoid PII, but include in local logs + Input: telemetry ? 'Redacted' : this.input + }; + } +} + +/** + * Event for when a Language Model Tool prepares for invocation. + */ +export class LanguageModelToolPrepareInvocation extends DotnetAcquisitionMessage +{ + public readonly eventName = 'LanguageModelToolPrepareInvocation'; + + constructor(public readonly toolName: string, public readonly input: string) + { + super(); + } + + public getProperties(telemetry = false): { [id: string]: string } | undefined + { + return { + ToolName: this.toolName, + Input: telemetry ? 'Redacted' : this.input + }; + } +} + +/** + * Event for when Language Model Tools registration fails. + * This is a non-fatal error - the extension continues to work without AI tool integration. + */ +export class LanguageModelToolsRegistrationError extends DotnetNonAcquisitionError +{ + public readonly eventName = 'LanguageModelToolsRegistrationError'; + + constructor(error: Error) + { + super(error); + } + + public getProperties(telemetry = false): { [id: string]: string } | undefined + { + // Only capture error name and a sanitized message - avoid PII like paths + return { + ErrorName: this.error.name ?? 'UnknownError', + // Only capture error code if present, not the full message which might contain paths + ErrorCode: (this.error as NodeJS.ErrnoException).code ?? 'NoCode', + // Use hashed stack trace to avoid path PII + StackTrace: this.error.stack ? TelemetryUtilities.HashAllPaths(this.error.stack) : '' + }; + } +} + function getDisabledTelemetryOnChance(percentIntToSend: number): { [disableTelemetryId: string]: string } { return { suppressTelemetry: (!(Math.random() < percentIntToSend / 100)).toString() }; diff --git a/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts b/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts index def89e9b15..2272729316 100644 --- a/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts +++ b/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts @@ -42,6 +42,10 @@ export interface IDotnetAcquireContext * Instead, it will automatically update local runtimes that it manages over time. * If a runtime has a breaking change or a security update, we recommend setting forceUpdate to true to ensure the latest major.minor requested is used. * If you want to have the same behavior as before 3.0.0, please set forceUpdate to true. The default will assume false if it's undefined. + * + * @property rethrowError - If true, errors will be rethrown after being handled (popups shown, telemetry sent, etc.) + * This allows callers (like LLM tools) to catch the actual exception and get the error message. + * Default is false for backward compatibility. */ version: string; requestingExtensionId?: string; @@ -50,6 +54,7 @@ export interface IDotnetAcquireContext architecture?: string | null | undefined; mode?: DotnetInstallMode; forceUpdate?: boolean; + rethrowError?: boolean; } /** diff --git a/vscode-dotnet-runtime-library/src/Utils/CommandExecutor.ts b/vscode-dotnet-runtime-library/src/Utils/CommandExecutor.ts index 4f78fb0176..72a8a99b17 100644 --- a/vscode-dotnet-runtime-library/src/Utils/CommandExecutor.ts +++ b/vscode-dotnet-runtime-library/src/Utils/CommandExecutor.ts @@ -117,7 +117,7 @@ export class CommandExecutor extends ICommandExecutor } } - if (await isRunningUnderWSL(this.context, this.utilityContext, this)) + if (await isRunningUnderWSL(this.context?.eventStream)) { // For WSL, vscode/sudo-prompt does not work. // This is because it relies on pkexec or a GUI app to popup and request sudo privilege. @@ -433,7 +433,7 @@ ${stderr}`)); const fullCommandString = `${command.commandRoot} ${command.commandParts.join(' ')}`; let useCache = false; // Remove this when https://github.com/typescript-eslint/typescript-eslint/issues/2728 is done - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (options) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/vscode-dotnet-runtime-library/src/Utils/ErrorHandler.ts b/vscode-dotnet-runtime-library/src/Utils/ErrorHandler.ts index 42cf4ec092..eac5d00818 100644 --- a/vscode-dotnet-runtime-library/src/Utils/ErrorHandler.ts +++ b/vscode-dotnet-runtime-library/src/Utils/ErrorHandler.ts @@ -8,14 +8,14 @@ import { DotnetCoreAcquisitionWorker } from '../Acquisition/DotnetCoreAcquisitio import { GetDotnetInstallInfo } from '../Acquisition/DotnetInstall'; import { IAcquisitionWorkerContext } from '../Acquisition/IAcquisitionWorkerContext'; import -{ - DotnetAcquisitionFinalError, - DotnetCommandFailed, - DotnetCommandSucceeded, - DotnetInstallExpectedAbort, - DotnetNotInstallRelatedCommandFailed, - EventCancellationError -} from '../EventStream/EventStreamEvents'; + { + DotnetAcquisitionFinalError, + DotnetCommandFailed, + DotnetCommandSucceeded, + DotnetInstallExpectedAbort, + DotnetNotInstallRelatedCommandFailed, + EventCancellationError + } from '../EventStream/EventStreamEvents'; import { IIssueContext } from './IIssueContext'; import { getInstallFromContext } from './InstallIdUtilities'; import { formatIssueUrl } from './IssueReporter'; @@ -59,12 +59,12 @@ Our CDN may be blocked in China or experience significant slowdown, in which cas let showMessage = true; -export async function callWithErrorHandling(callback: () => T, context: IIssueContext, requestingExtensionId?: string, acquireContext?: IAcquisitionWorkerContext): Promise +export async function callWithErrorHandling(callback: () => T, context: IIssueContext, requestingExtensionId?: string, acquireContext?: IAcquisitionWorkerContext, rethrowError?: boolean): Promise { const isAcquisitionError = acquireContext ? true : false; try { - /* eslint-disable @typescript-eslint/await-thenable */ + const result = await callback(); context.eventStream.post(new DotnetCommandSucceeded(context.commandName)); return result; @@ -138,6 +138,13 @@ export async function callWithErrorHandling(callback: () => T, context: IIssu }, ...errorOptions); } } + + // If rethrowError is true, rethrow the error so the caller can handle it (useful for LLM tools) + if (rethrowError) + { + throw error; + } + return undefined; } finally diff --git a/vscode-dotnet-runtime-library/src/Utils/TypescriptUtilities.ts b/vscode-dotnet-runtime-library/src/Utils/TypescriptUtilities.ts index eb576d7bd3..7e2a69f9e9 100644 --- a/vscode-dotnet-runtime-library/src/Utils/TypescriptUtilities.ts +++ b/vscode-dotnet-runtime-library/src/Utils/TypescriptUtilities.ts @@ -4,17 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as os from 'os'; import { SYSTEM_INFORMATION_CACHE_DURATION_MS } from '../Acquisition/CacheTimeConstants'; -import { IAcquisitionWorkerContext } from '../Acquisition/IAcquisitionWorkerContext'; +import type { DistroVersionPair } from '../Acquisition/LinuxVersionResolver'; +import { RED_HAT_DISTRO_INFO_KEY, UBUNTU_DISTRO_INFO_KEY } from '../Acquisition/StringConstants'; import { IEventStream } from '../EventStream/EventStream'; -import -{ - DotnetWSLCheckEvent -} from '../EventStream/EventStreamEvents'; +import { DotnetWSLCheckEvent } from '../EventStream/EventStreamEvents'; import { IEvent } from '../EventStream/IEvent'; import { CommandExecutor } from './CommandExecutor'; import { EventStreamNodeIPCMutexLoggerWrapper } from './EventStreamNodeIPCMutexWrapper'; +import { FileUtilities } from './FileUtilities'; import { ICommandExecutor } from './ICommandExecutor'; -import { IUtilityContext } from './IUtilityContext'; import { NodeIPCMutex } from './NodeIPCMutex'; export async function loopWithTimeoutOnCond(sampleRatePerMs: number, durationToWaitBeforeTimeoutMs: number, conditionToStop: () => boolean, doAfterStop: () => void, @@ -33,39 +31,13 @@ export async function loopWithTimeoutOnCond(sampleRatePerMs: number, durationToW throw new Error(`The promise timed out at ${durationToWaitBeforeTimeoutMs}.`); } -/** - * Returns true if the linux agent is running under WSL, else false. - */ -export async function isRunningUnderWSL(acquisitionContext: IAcquisitionWorkerContext, utilityContext: IUtilityContext, executor?: ICommandExecutor): Promise -{ - // See https://github.com/microsoft/WSL/issues/4071 for evidence that we can rely on this behavior. - - acquisitionContext.eventStream?.post(new DotnetWSLCheckEvent(`Checking if system is WSL. OS: ${os.platform()}`)); - - if (os.platform() !== 'linux') - { - return false; - } - - const command = CommandExecutor.makeCommand('grep', ['-i', 'Microsoft', '/proc/version']); - executor ??= new CommandExecutor(acquisitionContext, utilityContext); - const commandResult = await executor.execute(command, {}, false); - - if (!commandResult || !commandResult.stdout) - { - return false; - } - - return true; -} - export async function executeWithLock(eventStream: IEventStream, alreadyHoldingLock: boolean, lockId: string, retryTimeMs: number, timeoutTimeMs: number, f: (...args: A) => R, ...args: A): Promise { // Are we in a mutex-relevant inner function call, that is called by a parent function that already holds the lock? // If so, we don't need to acquire the lock again and we also shouldn't release it as the parent function will do that. if (alreadyHoldingLock || process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX === 'true') { - // eslint-disable-next-line @typescript-eslint/await-thenable + return f(...(args)); } else @@ -83,7 +55,7 @@ Report this issue to our vscode-dotnet-runtime GitHub for help.` const result = await mutex.acquire(async () => { // await must be used to make the linter allow f to be async, which it must be. - // eslint-disable-next-line no-return-await, @typescript-eslint/await-thenable + return await f(...(args)); }, retryTimeMs, timeoutTimeMs, `${lockId}-${crypto.randomUUID()}`); return result; @@ -180,4 +152,120 @@ export function EnvironmentVariableIsDefined(variable: any): boolean export function getPathSeparator(): string { return os.platform() === 'win32' ? ';' : ':'; +} + +// All distros that Microsoft officially supports for this tool. Community distros (e.g. Debian) are not in this list. +export const microsoftSupportedDistroIds = [RED_HAT_DISTRO_INFO_KEY, UBUNTU_DISTRO_INFO_KEY]; + +/** + * Checks if the system is running under WSL. + * Checks env vars first, then falls back to /proc/version. + * @param eventStream Optional event stream for diagnostic logging. + */ +export async function isRunningUnderWSL(eventStream?: IEventStream): Promise +{ + eventStream?.post(new DotnetWSLCheckEvent(`Checking if system is WSL. OS: ${os.platform()}`)); + + if (os.platform() !== 'linux') + { + return false; + } + + if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) + { + return true; + } + + try + { + const procVersion = await new FileUtilities().read('/proc/version'); + return procVersion.toLowerCase().includes('microsoft'); + } + catch + { + return false; + } +} + +/** + * Detects the Linux distro and version from /etc/os-release. + * @param eventStream Optional event stream for diagnostic logging. + * @returns The distro name and version, or null if it can't be determined. + */ +export async function getRunningDistro(eventStream?: IEventStream): Promise +{ + if (os.platform() !== 'linux') + { + return null; + } + + const mainOSDeclarationFile = `/etc/os-release`; + // Fallback per https://man7.org/linux/man-pages/man5/os-release.5.html + const backupOSDeclarationFile = `/usr/lib/os-release`; + const fileUtils = new FileUtilities(); + const osDeclarationFile = await fileUtils.exists(mainOSDeclarationFile) ? mainOSDeclarationFile : backupOSDeclarationFile; + + try + { + const osInfo = (await fileUtils.read(osDeclarationFile)).split('\n'); + const infoWithQuotesRemoved = osInfo.map(x => x.replace('"', '')); + const infoWithSeparatedKeyValues = infoWithQuotesRemoved.map(x => x.split('=')); + const keyValueMap = Object.fromEntries(infoWithSeparatedKeyValues.map(x => [x[0], x[1]])); + + const distroName: string = keyValueMap.NAME?.replace('"', '') ?? ''; + const distroVersion: string = keyValueMap.VERSION_ID?.replace('"', '') ?? ''; + + if (distroName === '' || distroVersion === '') + { + return null; + } + + return { distro: distroName, version: distroVersion }; + } + catch + { + return null; + } +} + +/** + * Checks if the given distro (or the current running distro) is in microsoftSupportedDistroIds. + * @param distro Optional distro to check. If not provided, detects the current distro. + * @param eventStream Optional event stream for diagnostic logging. + */ +export async function isDistroSupported(distro?: DistroVersionPair | null, eventStream?: IEventStream): Promise +{ + const resolvedDistro = distro ?? await getRunningDistro(eventStream); + if (!resolvedDistro || resolvedDistro.distro === '') + { + return false; + } + return microsoftSupportedDistroIds.includes(resolvedDistro.distro); +} + +/** + * Checks for WSL or non-Microsoft-supported Linux distro. + * Returns { isUnsupported: true, reason } if on WSL or a community/unsupported distro. + * Note: the extension itself can still install on community distros (e.g. Debian via DebianDistroSDKProvider). + * This method is intended for LM tools that should not attempt community-support installs. + * @param eventStream Optional event stream for diagnostic logging. + */ +export async function checkForUnsupportedLinux(eventStream?: IEventStream): Promise<{ isUnsupported: boolean; reason?: string }> +{ + if (os.platform() !== 'linux') + { + return { isUnsupported: false }; + } + + if (await isRunningUnderWSL(eventStream)) + { + return { isUnsupported: true, reason: 'WSL' }; + } + + if (!await isDistroSupported(undefined, eventStream)) + { + return { isUnsupported: true, reason: 'Linux Distro' }; + } + + return { isUnsupported: false }; } \ No newline at end of file diff --git a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts index baaf5bc55f..41a6abef67 100644 --- a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts @@ -462,7 +462,7 @@ export class WebRequestWorkerSingleton { const axiosBasedError = error as AxiosError; // Remove this when https://github.com/typescript-eslint/typescript-eslint/issues/2728 is done - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const summarizedError = new EventBasedError('WebRequestFailedFromAxios', `Request to ${url} Failed: ${axiosBasedError?.message}. Aborting. ${axiosBasedError.cause ? `Error Cause: ${JSON.stringify(axiosBasedError.cause ?? '')}` : ``} diff --git a/vscode-dotnet-runtime-library/src/index.ts b/vscode-dotnet-runtime-library/src/index.ts index 137c424205..e5722301d7 100644 --- a/vscode-dotnet-runtime-library/src/index.ts +++ b/vscode-dotnet-runtime-library/src/index.ts @@ -32,6 +32,7 @@ export * from './Acquisition/LinuxVersionResolver'; export * from './Acquisition/LocalInstallUpdateService'; export * from './Acquisition/RuntimeInstallationDirectoryProvider'; export * from './Acquisition/SdkInstallationDirectoryProvider'; +export * from './Acquisition/StringConstants'; export * from './Acquisition/VersionResolver'; export * from './Acquisition/VersionUtilities'; export * from './Acquisition/WinMacGlobalInstaller'; diff --git a/vscode-dotnet-runtime-library/src/test/unit/DebianDistroTests.test.ts b/vscode-dotnet-runtime-library/src/test/unit/DebianDistroTests.test.ts index f92714f44c..0056dc990c 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/DebianDistroTests.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/DebianDistroTests.test.ts @@ -40,7 +40,7 @@ suite('Debian Distro Logic Unit Tests', () => assert.equal(mockExecutor.attemptedCommand, 'apt-cache -o DPkg::Lock::Timeout=180 search --names-only ^dotnet-sdk-9.0$', 'Searched for the newest package last with regex'); // this may fail if test not exec'd first // the data is cached so --version may not be executed. - const distroVersion = await new LinuxVersionResolver(acquisitionContext, getMockUtilityContext()).getRunningDistro(); + const distroVersion = await new LinuxVersionResolver(acquisitionContext, getMockUtilityContext()).getRunningDistroInstance(); assert.equal(recVersion, '9.0.1xx', 'Resolved the most recent available version : will eventually break if the mock data is not updated'); } }).timeout(standardTimeoutTime); diff --git a/vscode-dotnet-runtime-library/src/test/unit/ErrorHandler.test.ts b/vscode-dotnet-runtime-library/src/test/unit/ErrorHandler.test.ts index b8b7e8f59d..f50721598f 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/ErrorHandler.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/ErrorHandler.test.ts @@ -7,12 +7,12 @@ import { DotnetCommandSucceeded, DotnetNotInstallRelatedCommandFailed } from '.. import { ExistingPathKeys, IExistingPaths } from '../../IExtensionContext'; import { LocalMemoryCacheSingleton } from '../../LocalMemoryCacheSingleton'; import -{ - callWithErrorHandling, - errorConstants, - timeoutConstants, - UninstallErrorConfiguration, -} from '../../Utils/ErrorHandler'; + { + callWithErrorHandling, + errorConstants, + timeoutConstants, + UninstallErrorConfiguration, + } from '../../Utils/ErrorHandler'; import { IIssueContext } from '../../Utils/IIssueContext'; import { WebRequestWorkerSingleton } from '../../Utils/WebRequestWorkerSingleton'; import { MockExtensionConfigurationWorker } from '../mocks/MockExtensionConfigurationWorker'; @@ -147,4 +147,87 @@ suite('ErrorHandler Unit Tests', function () assert.exists(eventStream.events.find(event => event instanceof DotnetNotInstallRelatedCommandFailed)); }); + + test('Error is rethrown when rethrowError is true', async () => + { + const errorString = 'Test error for rethrow'; + const displayWorker = new MockWindowDisplayWorker(); + const eventStream = new MockEventStream(); + + let caughtError: Error | null = null; + try + { + await callWithErrorHandling(() => + { + throw new Error(errorString); + }, issueContext(displayWorker, eventStream), 'MockId', undefined, true /* rethrowError */); + } + catch (error) + { + caughtError = error as Error; + } + + // Verify error was rethrown + assert.isNotNull(caughtError, 'Error should have been rethrown'); + assert.include(caughtError!.message, errorString, 'Rethrown error should contain original message'); + + // Verify error popup was still shown (error handling still happened before rethrow) + assert.include(displayWorker.errorMessage, errorString, 'Error popup should still be displayed'); + + // Verify event was still posted + assert.exists(eventStream.events.find(event => event instanceof DotnetNotInstallRelatedCommandFailed)); + }); + + test('Error is NOT rethrown when rethrowError is false (default behavior)', async () => + { + const errorString = 'Test error no rethrow'; + const displayWorker = new MockWindowDisplayWorker(); + const eventStream = new MockEventStream(); + + let caughtError: Error | null = null; + let result: string | undefined; + try + { + result = await callWithErrorHandling(() => + { + throw new Error(errorString); + }, issueContext(displayWorker, eventStream), 'MockId', undefined, false /* rethrowError */); + } + catch (error) + { + caughtError = error as Error; + } + + // Verify error was NOT rethrown + assert.isNull(caughtError, 'Error should NOT have been rethrown'); + assert.isUndefined(result, 'Result should be undefined on error'); + + // Verify error popup was still shown + assert.include(displayWorker.errorMessage, errorString, 'Error popup should still be displayed'); + }); + + test('Error is NOT rethrown when rethrowError is undefined (backward compatibility)', async () => + { + const errorString = 'Test error undefined rethrow'; + const displayWorker = new MockWindowDisplayWorker(); + const eventStream = new MockEventStream(); + + let caughtError: Error | null = null; + let result: string | undefined; + try + { + result = await callWithErrorHandling(() => + { + throw new Error(errorString); + }, issueContext(displayWorker, eventStream), 'MockId' /* rethrowError not specified */); + } + catch (error) + { + caughtError = error as Error; + } + + // Verify error was NOT rethrown (backward compatible default) + assert.isNull(caughtError, 'Error should NOT have been rethrown when rethrowError is undefined'); + assert.isUndefined(result, 'Result should be undefined on error'); + }); }); diff --git a/vscode-dotnet-runtime-library/src/test/unit/LinuxVersionResolver.test.ts b/vscode-dotnet-runtime-library/src/test/unit/LinuxVersionResolver.test.ts index 0d386d9ea9..80807d79bd 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/LinuxVersionResolver.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/LinuxVersionResolver.test.ts @@ -42,7 +42,7 @@ suite('Linux Version Resolver Tests', function () { if (shouldRun) { - const distroVersion = await resolver.getRunningDistro(); + const distroVersion = await resolver.getRunningDistroInstance(); assert.exists(distroVersion.distro); assert.exists(distroVersion.version); } diff --git a/vscode-dotnet-runtime-library/src/test/unit/RedHatDistroTests.test.ts b/vscode-dotnet-runtime-library/src/test/unit/RedHatDistroTests.test.ts index f44fbad77b..ea8fd0a793 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/RedHatDistroTests.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/RedHatDistroTests.test.ts @@ -46,7 +46,7 @@ suite('Red Hat For Linux Distro Logic Unit Tests', function () test('Package Check Succeeds', async () => { - shouldRun = os.platform() === 'linux' && (await versionResolver.getRunningDistro()).distro === RED_HAT_DISTRO_INFO_KEY; + shouldRun = os.platform() === 'linux' && (await versionResolver.getRunningDistroInstance()).distro === RED_HAT_DISTRO_INFO_KEY; if (shouldRun) { diff --git a/vscode-dotnet-runtime-library/src/test/unit/TestUtility.ts b/vscode-dotnet-runtime-library/src/test/unit/TestUtility.ts index 8cf2fd2607..5807e8fb02 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/TestUtility.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/TestUtility.ts @@ -130,7 +130,7 @@ export async function getDistroInfo(context: IAcquisitionWorkerContext): Promise { return { distro: '', version: '' }; } - return new LinuxVersionResolver(context, getMockUtilityContext()).getRunningDistro(); + return new LinuxVersionResolver(context, getMockUtilityContext()).getRunningDistroInstance(); } /** diff --git a/vscode-dotnet-runtime-library/yarn.lock b/vscode-dotnet-runtime-library/yarn.lock index 0000380ae7..c5cb0f7b6c 100644 --- a/vscode-dotnet-runtime-library/yarn.lock +++ b/vscode-dotnet-runtime-library/yarn.lock @@ -644,6 +644,11 @@ fs.realpath@^1.0.0: resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@^2.3.3: + version "2.3.3" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fsevents/-/fsevents-2.3.3.tgz" + integrity sha1-ysZAd4XQNnWipeGlMFxpezR9kNY= + function-bind@^1.1.2: version "1.1.2" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/function-bind/-/function-bind-1.1.2.tgz"