Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { detectFromMarketplace } from './detectors/marketplace';
import { detectFromNuGetDevTools } from './detectors/nuget-devtools';
import { detectFromCompilerPath } from './detectors/compiler-path';
import { executeDownload } from './download/download-command';
import { executeDevToolsExtract, ExtractSource } from './devtools/extract-command';
import { executeDecompile, DEFAULT_ASSEMBLIES } from './devtools/decompile-command';
import type { DetectSource } from './resolve-detect-source';

function usage(): void {
Expand All @@ -16,6 +18,8 @@ Commands:
detect-tfm nuget-devtools [version|channel] Detect TFM from NuGet DevTools (default: latest)
detect-tfm compiler-path <dir> Detect TFM from a local compiler directory
download --output <dir> Download and extract ALCops analyzers
devtools extract --source <src> --output <dir> Download and extract BC DevTools
devtools decompile --input <dir> --output <dir> Decompile DLLs using ILSpy

Download options:
--output <dir> Required. Directory to extract analyzer DLLs into
Expand All @@ -25,6 +29,19 @@ Download options:
--detect-from <source> Force detection source (bc-artifact, marketplace,
nuget-devtools, compiler-path)

Devtools extract options:
--source <nuget|vsix> Required. Package source
--version <ver|channel> Version or channel (default: latest)
--tfm <tfm> Target framework (required for nuget)
--output <dir> Required. Output directory
--include <glob> Filter extracted files (e.g. "*.dll")

Devtools decompile options:
--input <dir> Required. Directory containing DLLs
--output <dir> Required. Output directory for decompiled projects
--assemblies <list> Comma-separated assembly names (has defaults)
--keep-tool Don't clean up ilspycmd after decompilation

Global options:
--verbose Enable debug-level logging
--help Show this help message
Expand Down Expand Up @@ -119,6 +136,68 @@ async function main(): Promise<void> {
);

process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else if (command === 'devtools') {
const subcommand = filteredArgs[1];
if (!subcommand) {
process.stderr.write('Error: devtools requires a subcommand (extract, decompile)\n');
usage();
process.exit(1);
}

const subArgs = filteredArgs.slice(2);

if (subcommand === 'extract') {
const source = getFlagValue(subArgs, '--source') as ExtractSource | undefined;
const version = getFlagValue(subArgs, '--version') ?? 'latest';
const tfm = getFlagValue(subArgs, '--tfm');
const outputDir = getFlagValue(subArgs, '--output');
const includePattern = getFlagValue(subArgs, '--include');

if (!source || !isValidExtractSource(source)) {
process.stderr.write(
'Error: --source <nuget|vsix> is required for devtools extract\n',
);
process.exit(1);
}
if (!outputDir) {
process.stderr.write('Error: --output <dir> is required for devtools extract\n');
process.exit(1);
}

const result = await executeDevToolsExtract(
{ source, version, tfm, outputDir, includePattern },
logger,
);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else if (subcommand === 'decompile') {
const inputDir = getFlagValue(subArgs, '--input');
const outputDir = getFlagValue(subArgs, '--output');
const assembliesStr = getFlagValue(subArgs, '--assemblies');
const keepTool = subArgs.includes('--keep-tool');

if (!inputDir) {
process.stderr.write('Error: --input <dir> is required for devtools decompile\n');
process.exit(1);
}
if (!outputDir) {
process.stderr.write('Error: --output <dir> is required for devtools decompile\n');
process.exit(1);
}

const assemblies = assembliesStr
? assembliesStr.split(',').map((a) => a.trim())
: DEFAULT_ASSEMBLIES;

const result = await executeDecompile(
{ inputDir, outputDir, assemblies, keepTool },
logger,
);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
process.stderr.write(`Error: Unknown devtools subcommand: ${subcommand}\n`);
usage();
process.exit(1);
}
} else {
process.stderr.write(`Error: Unknown command: ${command}\n`);
usage();
Expand All @@ -138,6 +217,12 @@ function isValidDetectSource(value: string): value is DetectSource {
return VALID_DETECT_SOURCES.has(value);
}

const VALID_EXTRACT_SOURCES = new Set(['nuget', 'vsix']);

function isValidExtractSource(value: string): value is ExtractSource {
return VALID_EXTRACT_SOURCES.has(value);
}

main().catch((err) => {
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
Expand Down
166 changes: 166 additions & 0 deletions src/devtools/decompile-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import { Logger, nullLogger } from '../logger';

export const DEFAULT_ASSEMBLIES = [
'Microsoft.Dynamics.Nav.AL.Common',
'Microsoft.Dynamics.Nav.Analyzers.Common',
'Microsoft.Dynamics.Nav.AppSourceCop',
'Microsoft.Dynamics.Nav.CodeAnalysis',
'Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces',
'Microsoft.Dynamics.Nav.CodeCop',
'Microsoft.Dynamics.Nav.PerTenantExtensionCop',
'Microsoft.Dynamics.Nav.UICop',
];

export interface DecompileOptions {
inputDir: string;
outputDir: string;
assemblies: string[];
keepTool: boolean;
}

export interface AssemblyResult {
name: string;
status: 'success' | 'failed';
outputDir?: string;
error?: string;
}

export interface DecompileResult {
succeeded: number;
failed: number;
total: number;
outputDir: string;
assemblies: AssemblyResult[];
}

/**
* Decompile .NET assemblies using ILSpy CLI (ilspycmd).
* Requires the .NET SDK to be installed.
*/
export async function executeDecompile(
options: DecompileOptions,
logger: Logger = nullLogger,
): Promise<DecompileResult> {
const { inputDir, outputDir, assemblies, keepTool } = options;

if (!fs.existsSync(inputDir)) {
throw new Error(`Input directory not found: ${inputDir}`);
}

const dotnetPath = findDotnet();
logger.info(`Found dotnet: ${dotnetPath}`);

const toolPath = path.join(os.tmpdir(), `ilspycmd-${Date.now()}`);
const ilspycmd = path.join(toolPath, process.platform === 'win32' ? 'ilspycmd.exe' : 'ilspycmd');

try {
installIlSpy(dotnetPath, toolPath, logger);
verifyIlSpy(ilspycmd, logger);

if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}

const results: AssemblyResult[] = [];
let succeeded = 0;
let failed = 0;

for (const assembly of assemblies) {
const dllPath = path.join(inputDir, `${assembly}.dll`);

if (!fs.existsSync(dllPath)) {
logger.warn(`DLL not found, skipping: ${dllPath}`);
results.push({ name: assembly, status: 'failed', error: 'DLL not found' });
failed++;
continue;
}

const assemblyOutputDir = path.join(outputDir, assembly);

// Clean previous output for idempotency
if (fs.existsSync(assemblyOutputDir)) {
fs.rmSync(assemblyOutputDir, { recursive: true, force: true });
}

logger.info(`Decompiling ${assembly}...`);

try {
execFileSync(ilspycmd, [dllPath, '-p', '-o', assemblyOutputDir, '--nested-directories'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
});
logger.info(` -> ${assemblyOutputDir}`);
results.push({
name: assembly,
status: 'success',
outputDir: path.resolve(assemblyOutputDir),
});
succeeded++;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`Failed to decompile ${assembly}: ${msg}`);
results.push({ name: assembly, status: 'failed', error: msg });
failed++;
}
}

return {
succeeded,
failed,
total: assemblies.length,
outputDir: path.resolve(outputDir),
assemblies: results,
};
} finally {
if (!keepTool && fs.existsSync(toolPath)) {
logger.debug(`Cleaning up ilspycmd from ${toolPath}`);
fs.rmSync(toolPath, { recursive: true, force: true });
}
}
}

function findDotnet(): string {
const dotnetCmd = process.platform === 'win32' ? 'dotnet.exe' : 'dotnet';
try {
execFileSync(dotnetCmd, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
return dotnetCmd;
} catch {
throw new Error(
'dotnet SDK not found in PATH. Install the .NET SDK (https://dot.net/download) ' +
'to use the decompile command.',
);
}
}

function installIlSpy(dotnetPath: string, toolPath: string, logger: Logger): void {
logger.info(`Installing ilspycmd to ${toolPath}...`);
try {
execFileSync(
dotnetPath,
['tool', 'install', 'ilspycmd', '--tool-path', toolPath],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
);
logger.info('ilspycmd installed.');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to install ilspycmd: ${msg}`);
}
}

function verifyIlSpy(ilspycmdPath: string, logger: Logger): void {
try {
const output = execFileSync(ilspycmdPath, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
logger.debug(`ilspycmd version: ${output.toString().trim()}`);
} catch {
throw new Error(
`ilspycmd is not functional at ${ilspycmdPath}. ` +
'Try running the command again.',
);
}
}
110 changes: 110 additions & 0 deletions src/devtools/extract-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as path from 'path';
import { Logger, nullLogger } from '../logger';
import { resolveDevToolsVersion } from '../detectors/nuget-devtools';
import { resolveExtensionVersion } from '../detectors/marketplace';
import { httpsGetBuffer } from '../http-client';
import { getUserAgent } from '../user-agent';
import { NUGET_FLAT_CONTAINER } from '../types';
import { extractFromBuffer } from './package-extractor';
import packageJson from '../../package.json';

const DEVTOOLS_PACKAGE = 'microsoft.dynamics.businesscentral.development.tools';
const DEFAULT_USER_AGENT = getUserAgent('ALCops', packageJson.version);

export type ExtractSource = 'nuget' | 'vsix';

export interface DevToolsExtractOptions {
source: ExtractSource;
version: string;
tfm?: string;
outputDir: string;
includePattern?: string;
}

export interface DevToolsExtractResult {
version: string;
source: ExtractSource;
tfm?: string;
outputDir: string;
fileCount: number;
files: string[];
}

/**
* Download and extract BC DevTools files from NuGet or VS Marketplace VSIX.
*/
export async function executeDevToolsExtract(
options: DevToolsExtractOptions,
logger: Logger = nullLogger,
): Promise<DevToolsExtractResult> {
if (options.source === 'nuget') {
return extractFromNuGet(options, logger);
}
return extractFromVsix(options, logger);
}

async function extractFromNuGet(
options: DevToolsExtractOptions,
logger: Logger,
): Promise<DevToolsExtractResult> {
if (!options.tfm) {
throw new Error('--tfm is required when source is "nuget" (e.g. net8.0, net10.0)');
}

const version = await resolveDevToolsVersion(options.version, logger);
logger.info(`Resolved DevTools version: ${version}`);

const nupkgUrl = `${NUGET_FLAT_CONTAINER}/${DEVTOOLS_PACKAGE}/${version}/${DEVTOOLS_PACKAGE}.${version}.nupkg`;
logger.info(`Downloading NuGet package: ${nupkgUrl}`);

const buffer = await httpsGetBuffer(nupkgUrl, DEFAULT_USER_AGENT);
logger.info(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)} MB`);

const pathPrefix = `tools/${options.tfm}/any`;
const result = extractFromBuffer({
zipBuffer: buffer,
pathPrefix,
outputDir: options.outputDir,
includePattern: options.includePattern,
logger,
});

return {
version,
source: 'nuget',
tfm: options.tfm,
outputDir: result.outputDir,
fileCount: result.fileCount,
files: result.files.map((f) => path.resolve(f)),
};
}

async function extractFromVsix(
options: DevToolsExtractOptions,
logger: Logger,
): Promise<DevToolsExtractResult> {
const channel = options.version || 'prerelease';
const resolved = await resolveExtensionVersion(channel, logger);
logger.info(`Resolved extension version: ${resolved.version}`);

logger.info(`Downloading VSIX: ${resolved.vsixUrl}`);
const buffer = await httpsGetBuffer(resolved.vsixUrl, DEFAULT_USER_AGENT);
logger.info(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)} MB`);

const pathPrefix = 'extension/bin/Analyzers';
const result = extractFromBuffer({
zipBuffer: buffer,
pathPrefix,
outputDir: options.outputDir,
includePattern: options.includePattern,
logger,
});

return {
version: resolved.version,
source: 'vsix',
outputDir: result.outputDir,
fileCount: result.fileCount,
files: result.files.map((f) => path.resolve(f)),
};
}
Loading