Skip to main content
Glama
extension-manager.ts11.9 kB
/** * AL Extension Manager * * Handles downloading and managing the Microsoft AL Language extension * which contains the EditorServices.Host executable for LSP communication. */ import * as fs from 'fs'; import * as path from 'path'; import axios from 'axios'; import decompress from 'decompress'; import { ALConfig, ALExtensionInfo } from '../config/types.js'; import { getLogger } from '../utils/logger.js'; const MARKETPLACE_API = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'; const AL_EXTENSION_ID = 'ms-dynamics-smb.al'; interface MarketplaceVersion { version: string; assetUri: string; } interface MarketplaceFile { assetType: string; source: string; } interface MarketplaceExtensionVersion { version: string; assetUri?: string; fallbackAssetUri?: string; files?: MarketplaceFile[]; } interface MarketplaceExtension { versions?: MarketplaceExtensionVersion[]; } interface MarketplaceResult { extensions?: MarketplaceExtension[]; } interface MarketplaceResponse { results?: MarketplaceResult[]; } /** * AL Extension Manager */ export class ALExtensionManager { private config: ALConfig; private logger = getLogger(); constructor(config: ALConfig) { this.config = config; } /** * Get or download the AL extension */ async getExtension(): Promise<ALExtensionInfo> { // Check if custom path specified if (this.config.extensionPath) { return this.loadExistingExtension(this.config.extensionPath); } // Check cache const cached = this.findCachedExtension(); if (cached) { this.logger.info(`Using cached AL extension: ${cached.version}`); return cached; } // Download from marketplace return this.downloadExtension(); } /** * Load an existing extension from a specified path */ private loadExistingExtension(extensionPath: string): ALExtensionInfo { const editorServicesPath = this.findEditorServices(extensionPath); if (!editorServicesPath) { throw new Error(`EditorServices.Host not found in: ${extensionPath}`); } // Try to get version from package.json const packageJsonPath = path.join(extensionPath, 'package.json'); let version = 'unknown'; if (fs.existsSync(packageJsonPath)) { try { const content = fs.readFileSync(packageJsonPath, 'utf-8'); const pkg = JSON.parse(content) as { version?: string }; version = pkg.version || 'unknown'; } catch { // Ignore parse errors } } return { path: extensionPath, editorServicesPath, version, }; } /** * Find cached extension */ private findCachedExtension(): ALExtensionInfo | null { const cacheDir = this.config.extensionCachePath; if (!fs.existsSync(cacheDir)) { return null; } // Look for extension directories const entries = fs.readdirSync(cacheDir); for (const entry of entries) { const entryPath = path.join(cacheDir, entry); const stat = fs.statSync(entryPath); if (stat.isDirectory()) { const editorServicesPath = this.findEditorServices(entryPath); if (editorServicesPath) { // Extract version from directory name or package.json const version = entry.replace('al-', '') || 'cached'; return { path: entryPath, editorServicesPath, version, }; } } } return null; } /** * Download extension from VS Code Marketplace */ private async downloadExtension(): Promise<ALExtensionInfo> { this.logger.info('Downloading AL extension from VS Code Marketplace...'); // Query marketplace for extension info const versionInfo = await this.queryMarketplace(); if (!versionInfo) { throw new Error('Failed to find AL extension in marketplace'); } this.logger.info(`Found AL extension version: ${versionInfo.version}`); // Download VSIX const vsixUrl = `${versionInfo.assetUri}/Microsoft.VisualStudio.Services.VSIXPackage`; const vsixPath = path.join(this.config.extensionCachePath, `al-${versionInfo.version}.vsix`); const extractPath = path.join(this.config.extensionCachePath, `al-${versionInfo.version}`); // Ensure cache directory exists fs.mkdirSync(this.config.extensionCachePath, { recursive: true }); // Download await this.downloadFile(vsixUrl, vsixPath); // Verify download if (!fs.existsSync(vsixPath)) { throw new Error(`VSIX file not downloaded: ${vsixPath}`); } const vsixSize = fs.statSync(vsixPath).size; this.logger.debug(`Downloaded VSIX: ${vsixSize} bytes`); if (vsixSize < 1000) { throw new Error(`VSIX file too small (${vsixSize} bytes), download may have failed`); } // Extract this.logger.info('Extracting AL extension...'); try { const files = await decompress(vsixPath, extractPath); this.logger.debug(`Extracted ${files.length} files`); if (files.length === 0) { throw new Error('No files extracted from VSIX'); } } catch (extractError) { this.logger.error('Extraction failed:', extractError); // Keep VSIX for debugging const errorMessage = extractError instanceof Error ? extractError.message : String(extractError); throw new Error(`Failed to extract VSIX: ${errorMessage}`); } // Clean up VSIX fs.unlinkSync(vsixPath); // Find EditorServices const editorServicesPath = this.findEditorServices(extractPath); if (!editorServicesPath) { // List what was extracted for debugging const extracted = this.listFilesRecursive(extractPath, 3); this.logger.debug(`Extracted files: ${extracted.join(', ')}`); throw new Error('EditorServices.Host not found in downloaded extension'); } this.logger.info(`AL extension ready: ${extractPath}`); return { path: extractPath, editorServicesPath, version: versionInfo.version, }; } /** * Query VS Code Marketplace for extension info */ private async queryMarketplace(): Promise<MarketplaceVersion | null> { try { // Flags: 0x1 (IncludeVersions) | 0x2 (IncludeFiles) | 0x80 (IncludeAssetUri) | 0x200 (IncludeVersionProperties) const FLAGS = 0x1 | 0x2 | 0x80 | 0x200; const response = await axios.post<MarketplaceResponse>( MARKETPLACE_API, { filters: [ { criteria: [ { filterType: 7, value: AL_EXTENSION_ID }, ], }, ], flags: FLAGS, }, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json;api-version=6.0-preview.1', }, } ); const extension = response.data?.results?.[0]?.extensions?.[0]; if (!extension?.versions?.[0]) { return null; } const latestVersion = extension.versions[0]; // Try to get the asset URI from different sources let assetUri = latestVersion.assetUri || latestVersion.fallbackAssetUri; // If no direct assetUri, look for the VSIX in the files array if (!assetUri && latestVersion.files) { const vsixFile = latestVersion.files.find( f => f.assetType === 'Microsoft.VisualStudio.Services.VSIXPackage' ); if (vsixFile) { // The source is the full download URL return { version: latestVersion.version, assetUri: vsixFile.source.replace('/Microsoft.VisualStudio.Services.VSIXPackage', ''), }; } } if (!assetUri) { // Construct the download URL manually as fallback assetUri = `https://ms-dynamics-smb.gallery.vsassets.io/_apis/public/gallery/publisher/ms-dynamics-smb/extension/al/${latestVersion.version}`; } return { version: latestVersion.version, assetUri: assetUri, }; } catch (error) { this.logger.error('Failed to query marketplace:', error); return null; } } /** * Download a file */ private async downloadFile(url: string, destPath: string): Promise<void> { this.logger.debug(`Downloading: ${url}`); const response = await axios.get<NodeJS.ReadableStream>(url, { responseType: 'stream', }); const writer = fs.createWriteStream(destPath); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); } /** * Find EditorServices.Host executable in extension directory */ private findEditorServices(extensionPath: string): string | null { // Determine platform folder name const platformFolder = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; // Platform-specific paths (AL extension 14.0+) const possiblePaths = [ // New structure with platform folders path.join(extensionPath, 'extension', 'bin', platformFolder, 'Microsoft.Dynamics.Nav.EditorServices.Host.exe'), path.join(extensionPath, 'extension', 'bin', platformFolder, 'Microsoft.Dynamics.Nav.EditorServices.Host'), // Legacy structure path.join(extensionPath, 'extension', 'bin', 'EditorServices.Host.exe'), path.join(extensionPath, 'extension', 'bin', 'EditorServices.Host'), path.join(extensionPath, 'extension', 'bin', 'Roslyn', 'Microsoft.Dynamics.Nav.EditorServices.Host.exe'), path.join(extensionPath, 'extension', 'bin', 'Roslyn', 'Microsoft.Dynamics.Nav.EditorServices.Host'), // Direct extraction path.join(extensionPath, 'bin', platformFolder, 'Microsoft.Dynamics.Nav.EditorServices.Host.exe'), path.join(extensionPath, 'bin', platformFolder, 'Microsoft.Dynamics.Nav.EditorServices.Host'), path.join(extensionPath, 'bin', 'EditorServices.Host.exe'), path.join(extensionPath, 'bin', 'EditorServices.Host'), ]; for (const p of possiblePaths) { if (fs.existsSync(p)) { this.logger.debug(`Found EditorServices at: ${p}`); return p; } } // Search recursively as fallback return this.searchForEditorServices(extensionPath); } /** * Recursively search for EditorServices executable */ private searchForEditorServices(dir: string, depth = 0): string | null { if (depth > 5) return null; // Limit recursion depth try { const entries = fs.readdirSync(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); if (entry.includes('EditorServices.Host') && !entry.endsWith('.pdb')) { return fullPath; } const stat = fs.statSync(fullPath); if (stat.isDirectory()) { const found = this.searchForEditorServices(fullPath, depth + 1); if (found) return found; } } } catch { // Ignore access errors } return null; } /** * List files recursively for debugging */ private listFilesRecursive(dir: string, maxDepth: number, depth = 0): string[] { if (depth >= maxDepth || !fs.existsSync(dir)) { return []; } const results: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); results.push(entry.isDirectory() ? `${entry.name}/` : entry.name); if (entry.isDirectory() && depth < maxDepth - 1) { const subFiles = this.listFilesRecursive(fullPath, maxDepth, depth + 1); results.push(...subFiles.map(f => ` ${f}`)); } } } catch { // Ignore errors } return results; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ciellosinc/partnercore-proxy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server