/**
* 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;
}
}