Skip to main content
Glama
bc-container.ts28.7 kB
/** * Business Central Container Manager * * Provides tools for interacting with BC Docker containers * using BcContainerHelper PowerShell module. */ import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; import { getLogger } from '../utils/logger.js'; const execAsync = promisify(exec); export interface BCContainerInfo { id: string; name: string; image: string; status: string; ports: string[]; created: string; running: boolean; } export interface CompileResult { success: boolean; appFile?: string; errors: string[]; warnings: string[]; duration: number; } export interface PublishResult { success: boolean; message: string; syncMode?: string; } export interface TestResult { success: boolean; testsRun: number; testsPassed: number; testsFailed: number; testsSkipped: number; duration: number; results: TestCaseResult[]; } // Docker container JSON format interface DockerContainerJson { ID: string; Names: string; Image: string; Status: string; Ports: string; CreatedAt: string; State: string; } // App manifest format interface AppManifest { publisher: string; name: string; version: string; } // Test result JSON format interface TestResultJson { name: string; testFunction: string; codeunitId: number; codeunitName: string; result: string; message: string; duration: number; } // Extension JSON format interface ExtensionJson { name: string; publisher: string; version: string; appId: string; scope: string; isPublished: boolean; isInstalled: boolean; } export interface TestCaseResult { name: string; codeunitId: number; codeunitName: string; result: 'Passed' | 'Failed' | 'Skipped'; message?: string; duration: number; } /** * BC Container Manager */ export class BCContainerManager { private logger = getLogger(); private workspaceRoot: string; constructor(workspaceRoot: string) { this.workspaceRoot = workspaceRoot; } /** * List all BC containers */ async listContainers(): Promise<BCContainerInfo[]> { try { const { stdout } = await execAsync( 'docker ps -a --filter "ancestor=mcr.microsoft.com/businesscentral" --format "{{json .}}"' ); const containers: BCContainerInfo[] = []; const lines = stdout.trim().split('\n').filter(l => l); for (const line of lines) { try { const data = JSON.parse(line) as DockerContainerJson; containers.push({ id: data.ID, name: data.Names, image: data.Image, status: data.Status, ports: data.Ports ? data.Ports.split(',').map((p: string) => p.trim()) : [], created: data.CreatedAt, running: data.State === 'running', }); } catch { // Skip malformed lines } } // Also try BcContainerHelper format try { const { stdout: psStdout } = await execAsync( 'powershell -Command "Get-BcContainers | ConvertTo-Json"' ); const bcContainers = JSON.parse(psStdout) as string[]; if (Array.isArray(bcContainers)) { for (const bc of bcContainers) { if (!containers.find(c => c.name === bc)) { containers.push({ id: bc, name: bc, image: 'unknown', status: 'unknown', ports: [], created: 'unknown', running: true, }); } } } } catch { // BcContainerHelper not available, use docker only } return containers; } catch (error) { this.logger.error('Failed to list containers:', error); return []; } } /** * Get a specific container by name */ async getContainer(containerName: string): Promise<BCContainerInfo | null> { const containers = await this.listContainers(); return containers.find(c => c.name === containerName || c.id.startsWith(containerName)) || null; } /** * Compile AL project using BcContainerHelper */ async compile(containerName: string, options?: { appProjectFolder?: string; outputFolder?: string; appSymbolsFolder?: string; }): Promise<CompileResult> { const startTime = Date.now(); const appFolder = options?.appProjectFolder || this.workspaceRoot; const outputFolder = options?.outputFolder || path.join(appFolder, '.output'); // Ensure output folder exists if (!fs.existsSync(outputFolder)) { fs.mkdirSync(outputFolder, { recursive: true }); } // Read app.json to get app info const appJsonPath = path.join(appFolder, 'app.json'); if (!fs.existsSync(appJsonPath)) { return { success: false, errors: ['app.json not found in project folder'], warnings: [], duration: Date.now() - startTime, }; } const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8')) as AppManifest; const expectedAppFile = path.join( outputFolder, `${appJson.publisher}_${appJson.name}_${appJson.version}.app` ); try { // Use BcContainerHelper to compile const escapedAppFolder = appFolder.replace(/\\/g, '\\\\'); const escapedOutputFolder = outputFolder.replace(/\\/g, '\\\\'); const psCommand = [ '$ErrorActionPreference = "Stop"', '$result = Compile-AppInBcContainer \\', ` -containerName '${containerName}' \\`, ` -appProjectFolder '${escapedAppFolder}' \\`, ` -appOutputFolder '${escapedOutputFolder}' \\`, ' -EnableCodeCop \\', ' -EnableAppSourceCop \\', ' -EnableUICop \\', ' -EnablePerTenantExtensionCop \\', ' 2>&1', '$result | ConvertTo-Json -Depth 10', ].join('\n'); const { stdout, stderr } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024 } ); const errors: string[] = []; const warnings: string[] = []; // Parse output for errors and warnings const outputLines = (stdout + stderr).split('\n'); for (const line of outputLines) { if (line.includes('error ') || line.includes('Error:')) { errors.push(line.trim()); } else if (line.includes('warning ') || line.includes('Warning:')) { warnings.push(line.trim()); } } // Check if app file was created const appFileExists = fs.existsSync(expectedAppFile); return { success: errors.length === 0 && appFileExists, appFile: appFileExists ? expectedAppFile : undefined, errors, warnings, duration: Date.now() - startTime, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, errors: [errorMessage], warnings: [], duration: Date.now() - startTime, }; } } /** * Publish app to BC container */ async publish(containerName: string, options?: { appFile?: string; syncMode?: 'Add' | 'Clean' | 'Development' | 'ForceSync'; skipVerification?: boolean; install?: boolean; }): Promise<PublishResult> { const appFolder = this.workspaceRoot; const outputFolder = path.join(appFolder, '.output'); const syncMode = options?.syncMode || 'Development'; // Find the app file let appFile = options?.appFile; if (!appFile) { if (fs.existsSync(outputFolder)) { const files = fs.readdirSync(outputFolder).filter(f => f.endsWith('.app')); if (files.length > 0) { // Get the most recent app file const appFiles = files .map(f => ({ name: f, time: fs.statSync(path.join(outputFolder, f)).mtime.getTime() })) .sort((a, b) => b.time - a.time); appFile = path.join(outputFolder, appFiles[0].name); } } } if (!appFile || !fs.existsSync(appFile)) { return { success: false, message: 'App file not found. Run compile first.', }; } try { const escapedAppFile = appFile.replace(/\\/g, '\\\\'); const psCommandParts = [ '$ErrorActionPreference = "Stop"', 'Publish-BcContainerApp \\', ` -containerName '${containerName}' \\`, ` -appFile '${escapedAppFile}' \\`, ` -syncMode ${syncMode} \\`, options?.skipVerification ? ' -skipVerification \\' : '', options?.install ? ' -install \\' : '', ' -useDevEndpoint', "Write-Output 'SUCCESS'", ].filter(Boolean); const psCommand = psCommandParts.join('\n'); const { stdout, stderr } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024 } ); const success = stdout.includes('SUCCESS'); return { success, message: success ? `Published ${path.basename(appFile)}` : stderr || 'Publish failed', syncMode, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage, }; } } /** * Run tests in BC container */ async runTests(containerName: string, options?: { testCodeunit?: number; testFunction?: string; extensionId?: string; detailed?: boolean; }): Promise<TestResult> { const startTime = Date.now(); try { // Build test parameters let testParams = ''; if (options?.testCodeunit) { testParams += ` -testCodeunit ${options.testCodeunit}`; } if (options?.testFunction) { testParams += ` -testFunction '${options.testFunction}'`; } if (options?.extensionId) { testParams += ` -extensionId '${options.extensionId}'`; } const psCommandParts = [ '$ErrorActionPreference = "Stop"', '$results = Run-TestsInBcContainer \\', ` -containerName '${containerName}' \\`, testParams ? ` ${testParams.trim()} \\` : '', ` -detailed:${options?.detailed ? '$true' : '$false'} \\`, ' -returnTrueIfAllPassed', '$results | ConvertTo-Json -Depth 10', ].filter(Boolean); const psCommand = psCommandParts.join('\n'); const { stdout } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024, timeout: 600000 } ); // Parse results let testsPassed = 0; let testsFailed = 0; let testsSkipped = 0; const results: TestCaseResult[] = []; try { const parsed = JSON.parse(stdout) as boolean | TestResultJson[]; if (typeof parsed === 'boolean') { // Simple result return { success: parsed, testsRun: 1, testsPassed: parsed ? 1 : 0, testsFailed: parsed ? 0 : 1, testsSkipped: 0, duration: Date.now() - startTime, results: [], }; } // Detailed results if (Array.isArray(parsed)) { for (const test of parsed) { const result: TestCaseResult = { name: test.name || test.testFunction || 'Unknown', codeunitId: test.codeunitId || 0, codeunitName: test.codeunitName || 'Unknown', result: test.result === '0' || test.result === 'Passed' ? 'Passed' : test.result === '1' || test.result === 'Failed' ? 'Failed' : 'Skipped', message: test.message, duration: test.duration || 0, }; results.push(result); if (result.result === 'Passed') testsPassed++; else if (result.result === 'Failed') testsFailed++; else testsSkipped++; } } } catch { // Parse error, try to extract from output if (stdout.includes('True')) { testsPassed = 1; } else { testsFailed = 1; } } return { success: testsFailed === 0, testsRun: testsPassed + testsFailed + testsSkipped, testsPassed, testsFailed, testsSkipped, duration: Date.now() - startTime, results, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, testsRun: 0, testsPassed: 0, testsFailed: 1, testsSkipped: 0, duration: Date.now() - startTime, results: [{ name: 'Test Execution', codeunitId: 0, codeunitName: 'N/A', result: 'Failed', message: errorMessage, duration: 0, }], }; } } /** * Get container logs */ async getLogs(containerName: string, options?: { tail?: number; since?: string; }): Promise<string> { try { let dockerCmd = `docker logs ${containerName}`; if (options?.tail) { dockerCmd += ` --tail ${options.tail}`; } if (options?.since) { dockerCmd += ` --since ${options.since}`; } const { stdout, stderr } = await execAsync(dockerCmd, { maxBuffer: 10 * 1024 * 1024 }); return stdout + stderr; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return `Error getting logs: ${errorMessage}`; } } /** * Start a container */ async startContainer(containerName: string): Promise<{ success: boolean; message: string }> { try { await execAsync(`docker start ${containerName}`); return { success: true, message: `Container ${containerName} started` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Stop a container */ async stopContainer(containerName: string): Promise<{ success: boolean; message: string }> { try { await execAsync(`docker stop ${containerName}`); return { success: true, message: `Container ${containerName} stopped` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Restart a container */ async restartContainer(containerName: string): Promise<{ success: boolean; message: string }> { try { await execAsync(`docker restart ${containerName}`); return { success: true, message: `Container ${containerName} restarted` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Create a new BC container */ async createContainer(containerName: string, options: { artifactUrl?: string; version?: string; country?: string; type?: 'OnPrem' | 'Sandbox'; auth?: 'UserPassword' | 'NavUserPassword' | 'Windows' | 'AAD'; credential?: { username: string; password: string }; licenseFile?: string; accept_eula?: boolean; accept_outdated?: boolean; includeTestToolkit?: boolean; includeTestLibrariesOnly?: boolean; includeTestFrameworkOnly?: boolean; enableTaskScheduler?: boolean; assignPremiumPlan?: boolean; multitenant?: boolean; memoryLimit?: string; isolation?: 'hyperv' | 'process'; updateHosts?: boolean; }): Promise<{ success: boolean; message: string; containerName?: string; webClientUrl?: string }> { const startTime = Date.now(); try { // Build the New-BcContainer command const params: string[] = [ '$ErrorActionPreference = "Stop"', ]; // Determine artifact URL let artifactUrl = options.artifactUrl; if (!artifactUrl) { const version = options.version || ''; const country = options.country || 'us'; const type = options.type || 'Sandbox'; params.push(`$artifactUrl = Get-BCArtifactUrl -type ${type} -country ${country}${version ? ` -version '${version}'` : ''} -select Latest`); artifactUrl = '$artifactUrl'; } else { params.push(`$artifactUrl = '${artifactUrl}'`); artifactUrl = '$artifactUrl'; } // Build New-BcContainer parameters const containerParams: string[] = [ `New-BcContainer`, `-containerName '${containerName}'`, `-artifactUrl ${artifactUrl}`, `-accept_eula${options.accept_eula !== false ? '' : ':$false'}`, ]; // Auth if (options.auth) { containerParams.push(`-auth ${options.auth}`); } // Credential if (options.credential) { params.push(`$credential = New-Object System.Management.Automation.PSCredential('${options.credential.username}', (ConvertTo-SecureString '${options.credential.password}' -AsPlainText -Force))`); containerParams.push('-credential $credential'); } // License file if (options.licenseFile) { containerParams.push(`-licenseFile '${options.licenseFile.replace(/\\/g, '\\\\')}'`); } // Test toolkit options if (options.includeTestToolkit) { containerParams.push('-includeTestToolkit'); } if (options.includeTestLibrariesOnly) { containerParams.push('-includeTestLibrariesOnly'); } if (options.includeTestFrameworkOnly) { containerParams.push('-includeTestFrameworkOnly'); } // Other options if (options.accept_outdated) { containerParams.push('-accept_outdated'); } if (options.enableTaskScheduler) { containerParams.push('-enableTaskScheduler'); } if (options.assignPremiumPlan) { containerParams.push('-assignPremiumPlan'); } if (options.multitenant) { containerParams.push('-multitenant'); } if (options.memoryLimit) { containerParams.push(`-memoryLimit '${options.memoryLimit}'`); } if (options.isolation) { containerParams.push(`-isolation ${options.isolation}`); } if (options.updateHosts) { containerParams.push('-updateHosts'); } params.push(containerParams.join(' `\n ')); params.push("Write-Output 'CONTAINER_CREATED_SUCCESS'"); params.push(`Write-Output "http://${containerName}/BC/"`); const psCommand = params.join('\n'); this.logger.info(`Creating container ${containerName}...`); const { stdout, stderr } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 50 * 1024 * 1024, timeout: 1800000 // 30 minutes for container creation } ); const success = stdout.includes('CONTAINER_CREATED_SUCCESS'); const duration = Math.round((Date.now() - startTime) / 1000); if (success) { // Extract web client URL const urlMatch = stdout.match(/http:\/\/[^\s]+/); return { success: true, message: `Container ${containerName} created successfully in ${duration}s`, containerName, webClientUrl: urlMatch ? urlMatch[0] : undefined, }; } else { return { success: false, message: stderr || stdout || 'Container creation failed', }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Remove a container */ async removeContainer(containerName: string, force?: boolean): Promise<{ success: boolean; message: string }> { try { // Try BcContainerHelper first const psCommand = `Remove-BcContainer -containerName '${containerName}'`; await execAsync(`powershell -Command "${psCommand}"`, { timeout: 300000 }); return { success: true, message: `Container ${containerName} removed` }; } catch { // Fall back to docker try { await execAsync(`docker rm ${force ? '-f' : ''} ${containerName}`); return { success: true, message: `Container ${containerName} removed` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } } /** * Get container info including web client URL */ async getContainerUrl(containerName: string): Promise<{ webClientUrl?: string; soapUrl?: string; oDataUrl?: string; }> { try { const psCommand = ` $config = Get-BcContainerServerConfiguration -containerName '${containerName}' @{ webClientUrl = "http://${containerName}/$($config.ServerInstance)/WebClient" soapUrl = "http://${containerName}:7047/$($config.ServerInstance)/WS" oDataUrl = "http://${containerName}:7048/$($config.ServerInstance)/OData" } | ConvertTo-Json `; const { stdout } = await execAsync(`powershell -Command "${psCommand.replace(/"/g, '\\"')}"`); return JSON.parse(stdout) as { webClientUrl: string; soapUrl: string; oDataUrl: string }; } catch { return {} as { webClientUrl: string; soapUrl: string; oDataUrl: string }; } } /** * Get installed extensions/apps from container */ async getExtensions(containerName: string): Promise<{ success: boolean; extensions: Array<{ name: string; publisher: string; version: string; appId: string; scope: string; isPublished: boolean; isInstalled: boolean; }>; message?: string; }> { try { const psCommand = ` $apps = Get-BcContainerAppInfo -containerName '${containerName}' -tenantSpecificProperties $apps | ForEach-Object { @{ name = $_.Name publisher = $_.Publisher version = $_.Version appId = $_.AppId scope = $_.Scope isPublished = $_.IsPublished isInstalled = $_.IsInstalled } } | ConvertTo-Json -Depth 5 `; const { stdout } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024 } ); let extensions: ExtensionJson[] = []; try { const parsed = JSON.parse(stdout) as ExtensionJson | ExtensionJson[]; extensions = Array.isArray(parsed) ? parsed : [parsed]; } catch { // Empty result } return { success: true, extensions, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, extensions: [], message: errorMessage }; } } /** * Uninstall an app from container */ async uninstallApp(containerName: string, options: { name: string; publisher?: string; version?: string; force?: boolean; credential?: { username: string; password: string }; }): Promise<{ success: boolean; message: string }> { try { const params: string[] = [ '$ErrorActionPreference = "Stop"', ]; // Add credential if provided if (options.credential) { params.push(`$credential = New-Object System.Management.Automation.PSCredential('${options.credential.username}', (ConvertTo-SecureString '${options.credential.password}' -AsPlainText -Force))`); } const uninstallParams = [ 'Uninstall-BcContainerApp', `-containerName '${containerName}'`, `-name '${options.name}'`, ]; if (options.publisher) { uninstallParams.push(`-publisher '${options.publisher}'`); } if (options.version) { uninstallParams.push(`-version '${options.version}'`); } if (options.force) { uninstallParams.push('-Force'); } if (options.credential) { uninstallParams.push('-credential $credential'); } params.push(uninstallParams.join(' ')); params.push("Write-Output 'UNINSTALL_SUCCESS'"); const psCommand = params.join('\n'); const { stdout, stderr } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024 } ); const success = stdout.includes('UNINSTALL_SUCCESS'); return { success, message: success ? `Successfully uninstalled ${options.name}` : stderr || stdout || 'Uninstall failed', }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Compile and return only warnings (quick check) */ async compileWarningsOnly(containerName: string, options?: { appProjectFolder?: string; }): Promise<{ success: boolean; warnings: string[]; warningCount: number; message: string; }> { const appFolder = options?.appProjectFolder || this.workspaceRoot; const outputFolder = path.join(appFolder, '.output'); if (!fs.existsSync(outputFolder)) { fs.mkdirSync(outputFolder, { recursive: true }); } try { const escapedAppFolder = appFolder.replace(/\\/g, '\\\\'); const escapedOutputFolder = outputFolder.replace(/\\/g, '\\\\'); const psCommand = [ '$ErrorActionPreference = "Continue"', '$warnings = @()', 'Compile-AppInBcContainer \\', ` -containerName '${containerName}' \\`, ` -appProjectFolder '${escapedAppFolder}' \\`, ` -appOutputFolder '${escapedOutputFolder}' \\`, ' -EnableCodeCop \\', ' -EnableAppSourceCop \\', ' -EnableUICop \\', ' -EnablePerTenantExtensionCop \\', ' 2>&1 | ForEach-Object {', ' if ($_ -match "warning") { $warnings += $_.ToString() }', ' }', '$warnings | ConvertTo-Json', ].join('\n'); const { stdout, stderr } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 10 * 1024 * 1024 } ); const warnings: string[] = []; // Parse warnings from output const outputLines = (stdout + stderr).split('\n'); for (const line of outputLines) { if (line.toLowerCase().includes('warning')) { warnings.push(line.trim()); } } return { success: true, warnings, warningCount: warnings.length, message: warnings.length === 0 ? 'No warnings found' : `Found ${warnings.length} warning(s)`, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, warnings: [], warningCount: 0, message: errorMessage }; } } /** * Download symbols from container */ async downloadSymbols(containerName: string, targetFolder?: string): Promise<{ success: boolean; message: string; files?: string[] }> { const folder = targetFolder || path.join(this.workspaceRoot, '.alpackages'); try { if (!fs.existsSync(folder)) { fs.mkdirSync(folder, { recursive: true }); } const psCommand = ` $ErrorActionPreference = 'Stop' Get-BcContainerAppInfo -containerName '${containerName}' -tenantSpecificProperties -sort DependenciesFirst | ForEach-Object { if ($_.IsPublished) { $appFile = Join-Path '${folder.replace(/\\/g, '\\\\')}' "$($_.Publisher)_$($_.Name)_$($_.Version).app" Get-BcContainerApp -containerName '${containerName}' -appName $_.Name -appVersion $_.Version -appPublisher $_.Publisher > $appFile Write-Output $appFile } } `; const { stdout } = await execAsync( `powershell -Command "${psCommand.replace(/"/g, '\\"')}"`, { maxBuffer: 50 * 1024 * 1024, timeout: 300000 } ); const files = stdout.trim().split('\n').filter(f => f && f.endsWith('.app')); return { success: true, message: `Downloaded ${files.length} symbol files`, files, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } }

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