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