diff --git a/src/cli.ts b/src/cli.ts
index 59ed5b5..5da82a1 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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 {
@@ -16,6 +18,8 @@ Commands:
detect-tfm nuget-devtools [version|channel] Detect TFM from NuGet DevTools (default: latest)
detect-tfm compiler-path
Detect TFM from a local compiler directory
download --output Download and extract ALCops analyzers
+ devtools extract --source --output Download and extract BC DevTools
+ devtools decompile --input --output Decompile DLLs using ILSpy
Download options:
--output Required. Directory to extract analyzer DLLs into
@@ -25,6 +29,19 @@ Download options:
--detect-from Force detection source (bc-artifact, marketplace,
nuget-devtools, compiler-path)
+Devtools extract options:
+ --source Required. Package source
+ --version Version or channel (default: latest)
+ --tfm Target framework (required for nuget)
+ --output Required. Output directory
+ --include Filter extracted files (e.g. "*.dll")
+
+Devtools decompile options:
+ --input Required. Directory containing DLLs
+ --output Required. Output directory for decompiled projects
+ --assemblies 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
@@ -119,6 +136,68 @@ async function main(): Promise {
);
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 is required for devtools extract\n',
+ );
+ process.exit(1);
+ }
+ if (!outputDir) {
+ process.stderr.write('Error: --output 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 is required for devtools decompile\n');
+ process.exit(1);
+ }
+ if (!outputDir) {
+ process.stderr.write('Error: --output 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();
@@ -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);
diff --git a/src/devtools/decompile-command.ts b/src/devtools/decompile-command.ts
new file mode 100644
index 0000000..f99db50
--- /dev/null
+++ b/src/devtools/decompile-command.ts
@@ -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 {
+ 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.',
+ );
+ }
+}
diff --git a/src/devtools/extract-command.ts b/src/devtools/extract-command.ts
new file mode 100644
index 0000000..5d446f0
--- /dev/null
+++ b/src/devtools/extract-command.ts
@@ -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 {
+ if (options.source === 'nuget') {
+ return extractFromNuGet(options, logger);
+ }
+ return extractFromVsix(options, logger);
+}
+
+async function extractFromNuGet(
+ options: DevToolsExtractOptions,
+ logger: Logger,
+): Promise {
+ 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 {
+ 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)),
+ };
+}
diff --git a/src/devtools/package-extractor.ts b/src/devtools/package-extractor.ts
new file mode 100644
index 0000000..3d97dae
--- /dev/null
+++ b/src/devtools/package-extractor.ts
@@ -0,0 +1,115 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { unzipSync } from 'fflate';
+import { Logger, nullLogger } from '../logger';
+
+export interface ExtractOptions {
+ /** Raw ZIP/nupkg/vsix buffer */
+ zipBuffer: Buffer;
+ /** Path prefix to match inside the archive (e.g. 'tools/net8.0/any/') */
+ pathPrefix: string;
+ /** Output directory to write extracted files */
+ outputDir: string;
+ /** Optional glob pattern to filter filenames (simple *.ext matching) */
+ includePattern?: string;
+ logger?: Logger;
+}
+
+export interface ExtractResult {
+ outputDir: string;
+ files: string[];
+ fileCount: number;
+}
+
+/**
+ * Extract files from an in-memory ZIP buffer that match a given path prefix.
+ * Writes matched files flat into the output directory (preserving subdirectory structure
+ * relative to the prefix).
+ */
+export function extractFromBuffer(options: ExtractOptions): ExtractResult {
+ const { zipBuffer, pathPrefix, outputDir, includePattern, logger = nullLogger } = options;
+
+ const normalized = normalizePrefixPath(pathPrefix);
+ logger.debug(`Extracting files with prefix '${normalized}' to ${outputDir}`);
+
+ const unzipped = unzipSync(new Uint8Array(zipBuffer));
+ const allEntries = Object.keys(unzipped);
+ logger.debug(`Archive contains ${allEntries.length} entries`);
+
+ const matchingEntries = allEntries.filter((entry) => {
+ const normalizedEntry = entry.replace(/\\/g, '/');
+ if (!normalizedEntry.startsWith(normalized)) return false;
+ // Skip directory entries (trailing slash or empty name after prefix)
+ const relativePath = normalizedEntry.slice(normalized.length);
+ if (relativePath.length === 0 || relativePath.endsWith('/')) return false;
+ // Apply include filter if specified
+ if (includePattern) {
+ const fileName = relativePath.split('/').pop()!;
+ if (!matchesGlob(fileName, includePattern)) return false;
+ }
+ return true;
+ });
+
+ if (matchingEntries.length === 0) {
+ const prefixEntries = allEntries
+ .filter((e) => e.replace(/\\/g, '/').startsWith(normalized.split('/')[0] + '/'))
+ .slice(0, 10);
+ logger.debug(`No entries match prefix '${normalized}'. Sample entries under '${normalized.split('/')[0]}/': ${prefixEntries.join(', ')}`);
+ throw new Error(
+ `No files found matching prefix '${pathPrefix}' in archive. ` +
+ `Archive has ${allEntries.length} entries total.`,
+ );
+ }
+
+ logger.info(`Found ${matchingEntries.length} files to extract`);
+
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir, { recursive: true });
+ }
+
+ const files: string[] = [];
+ for (const entry of matchingEntries) {
+ const normalizedEntry = entry.replace(/\\/g, '/');
+ const relativePath = normalizedEntry.slice(normalized.length);
+
+ const destPath = path.join(outputDir, ...relativePath.split('/'));
+ const destDir = path.dirname(destPath);
+ if (!fs.existsSync(destDir)) {
+ fs.mkdirSync(destDir, { recursive: true });
+ }
+
+ fs.writeFileSync(destPath, Buffer.from(unzipped[entry]));
+ files.push(destPath);
+ }
+
+ logger.info(`Extracted ${files.length} files to ${outputDir}`);
+ return { outputDir: path.resolve(outputDir), files, fileCount: files.length };
+}
+
+/**
+ * Normalize a path prefix: forward slashes, ensure trailing slash.
+ */
+function normalizePrefixPath(prefix: string): string {
+ let p = prefix.replace(/\\/g, '/');
+ if (p.length > 0 && !p.endsWith('/')) {
+ p += '/';
+ }
+ return p;
+}
+
+/**
+ * Simple glob matching for filename patterns.
+ * Supports: *.ext, prefix*, *suffix, exact match.
+ * For v1, this covers the common cases without pulling in a dependency.
+ */
+export function matchesGlob(filename: string, pattern: string): boolean {
+ if (pattern === '*') return true;
+
+ // Convert glob to regex: escape special chars, replace * with .*
+ const escaped = pattern
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
+ .replace(/\*/g, '.*')
+ .replace(/\?/g, '.');
+
+ return new RegExp(`^${escaped}$`, 'i').test(filename);
+}
diff --git a/tests/devtools/decompile-command.test.ts b/tests/devtools/decompile-command.test.ts
new file mode 100644
index 0000000..5a6aee5
--- /dev/null
+++ b/tests/devtools/decompile-command.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { DEFAULT_ASSEMBLIES } from '../../src/devtools/decompile-command';
+
+describe('DEFAULT_ASSEMBLIES', () => {
+ it('contains the 8 known BC DevTools assemblies', () => {
+ expect(DEFAULT_ASSEMBLIES).toHaveLength(8);
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeAnalysis');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.AL.Common');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.Analyzers.Common');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.AppSourceCop');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.CodeCop');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.PerTenantExtensionCop');
+ expect(DEFAULT_ASSEMBLIES).toContain('Microsoft.Dynamics.Nav.UICop');
+ });
+
+ it('does not contain .dll extensions in assembly names', () => {
+ for (const assembly of DEFAULT_ASSEMBLIES) {
+ expect(assembly).not.toMatch(/\.dll$/);
+ }
+ });
+});
+
+describe('executeDecompile', () => {
+ it('throws when input directory does not exist', async () => {
+ const { executeDecompile } = await import('../../src/devtools/decompile-command');
+ await expect(
+ executeDecompile({
+ inputDir: '/nonexistent/path',
+ outputDir: '/tmp/out',
+ assemblies: DEFAULT_ASSEMBLIES,
+ keepTool: false,
+ }),
+ ).rejects.toThrow('Input directory not found');
+ });
+});
diff --git a/tests/devtools/extract-command.test.ts b/tests/devtools/extract-command.test.ts
new file mode 100644
index 0000000..fa8fdee
--- /dev/null
+++ b/tests/devtools/extract-command.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock dependencies to avoid network calls
+vi.mock('../../src/detectors/nuget-devtools', () => ({
+ resolveDevToolsVersion: vi.fn(),
+}));
+
+vi.mock('../../src/detectors/marketplace', () => ({
+ resolveExtensionVersion: vi.fn(),
+}));
+
+vi.mock('../../src/http-client', () => ({
+ httpsGetBuffer: vi.fn(),
+}));
+
+vi.mock('../../src/devtools/package-extractor', () => ({
+ extractFromBuffer: vi.fn(),
+}));
+
+import { resolveDevToolsVersion } from '../../src/detectors/nuget-devtools';
+import { resolveExtensionVersion } from '../../src/detectors/marketplace';
+import { httpsGetBuffer } from '../../src/http-client';
+import { extractFromBuffer } from '../../src/devtools/package-extractor';
+import { executeDevToolsExtract } from '../../src/devtools/extract-command';
+
+const mockResolveDevTools = resolveDevToolsVersion as ReturnType;
+const mockResolveExtension = resolveExtensionVersion as ReturnType;
+const mockHttpsGet = httpsGetBuffer as ReturnType;
+const mockExtract = extractFromBuffer as ReturnType;
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe('executeDevToolsExtract', () => {
+ describe('nuget source', () => {
+ it('throws when tfm is not provided', async () => {
+ await expect(
+ executeDevToolsExtract({
+ source: 'nuget',
+ version: 'latest',
+ outputDir: '/tmp/out',
+ }),
+ ).rejects.toThrow('--tfm is required');
+ });
+
+ it('downloads and extracts NuGet package with correct prefix', async () => {
+ mockResolveDevTools.mockResolvedValue('26.1.30873');
+ mockHttpsGet.mockResolvedValue(Buffer.from('fake-zip'));
+ mockExtract.mockReturnValue({
+ outputDir: '/tmp/out',
+ files: ['/tmp/out/foo.dll'],
+ fileCount: 1,
+ });
+
+ const result = await executeDevToolsExtract({
+ source: 'nuget',
+ version: 'latest',
+ tfm: 'net8.0',
+ outputDir: '/tmp/out',
+ });
+
+ expect(mockResolveDevTools).toHaveBeenCalledWith('latest', expect.anything());
+ expect(mockHttpsGet).toHaveBeenCalledWith(
+ expect.stringContaining('microsoft.dynamics.businesscentral.development.tools'),
+ expect.any(String),
+ );
+ expect(mockExtract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ pathPrefix: 'tools/net8.0/any',
+ outputDir: '/tmp/out',
+ }),
+ );
+ expect(result.source).toBe('nuget');
+ expect(result.version).toBe('26.1.30873');
+ expect(result.tfm).toBe('net8.0');
+ });
+
+ it('passes include pattern through to extractor', async () => {
+ mockResolveDevTools.mockResolvedValue('26.1.30873');
+ mockHttpsGet.mockResolvedValue(Buffer.from('fake-zip'));
+ mockExtract.mockReturnValue({
+ outputDir: '/tmp/out',
+ files: [],
+ fileCount: 0,
+ });
+
+ await executeDevToolsExtract({
+ source: 'nuget',
+ version: 'latest',
+ tfm: 'net8.0',
+ outputDir: '/tmp/out',
+ includePattern: '*.dll',
+ });
+
+ expect(mockExtract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includePattern: '*.dll',
+ }),
+ );
+ });
+ });
+
+ describe('vsix source', () => {
+ it('downloads and extracts VSIX with correct prefix', async () => {
+ mockResolveExtension.mockResolvedValue({
+ version: '14.0.1234',
+ vsixUrl: 'https://marketplace.example.com/vsix',
+ isPreRelease: true,
+ });
+ mockHttpsGet.mockResolvedValue(Buffer.from('fake-vsix'));
+ mockExtract.mockReturnValue({
+ outputDir: '/tmp/out',
+ files: ['/tmp/out/analyzer.dll'],
+ fileCount: 1,
+ });
+
+ const result = await executeDevToolsExtract({
+ source: 'vsix',
+ version: 'prerelease',
+ outputDir: '/tmp/out',
+ });
+
+ expect(mockResolveExtension).toHaveBeenCalledWith('prerelease', expect.anything());
+ expect(mockExtract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ pathPrefix: 'extension/bin/Analyzers',
+ }),
+ );
+ expect(result.source).toBe('vsix');
+ expect(result.version).toBe('14.0.1234');
+ });
+
+ it('defaults to prerelease channel when no version specified', async () => {
+ mockResolveExtension.mockResolvedValue({
+ version: '14.0.1234',
+ vsixUrl: 'https://example.com/vsix',
+ isPreRelease: true,
+ });
+ mockHttpsGet.mockResolvedValue(Buffer.from('fake'));
+ mockExtract.mockReturnValue({ outputDir: '/tmp/out', files: [], fileCount: 0 });
+
+ await executeDevToolsExtract({
+ source: 'vsix',
+ version: '',
+ outputDir: '/tmp/out',
+ });
+
+ expect(mockResolveExtension).toHaveBeenCalledWith('prerelease', expect.anything());
+ });
+ });
+});
diff --git a/tests/devtools/package-extractor.test.ts b/tests/devtools/package-extractor.test.ts
new file mode 100644
index 0000000..66a8eb1
--- /dev/null
+++ b/tests/devtools/package-extractor.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect } from 'vitest';
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+import { zipSync } from 'fflate';
+import { extractFromBuffer, matchesGlob } from '../../src/devtools/package-extractor';
+
+function createTestZip(entries: Record): Buffer {
+ const files: Record = {};
+ for (const [name, content] of Object.entries(entries)) {
+ files[name] = typeof content === 'string'
+ ? new TextEncoder().encode(content)
+ : content;
+ }
+ return Buffer.from(zipSync(files));
+}
+
+describe('extractFromBuffer', () => {
+ let tmpDir: string;
+
+ function makeTmpDir(): string {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alcops-test-'));
+ return tmpDir;
+ }
+
+ afterEach(() => {
+ if (tmpDir && fs.existsSync(tmpDir)) {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it('extracts files matching a path prefix', () => {
+ const zip = createTestZip({
+ 'tools/net8.0/any/foo.dll': 'foo-content',
+ 'tools/net8.0/any/bar.dll': 'bar-content',
+ 'tools/net10.0/any/foo.dll': 'foo-net10',
+ 'other/file.txt': 'unrelated',
+ });
+ const outDir = makeTmpDir();
+
+ const result = extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'tools/net8.0/any',
+ outputDir: outDir,
+ });
+
+ expect(result.fileCount).toBe(2);
+ expect(fs.readFileSync(path.join(outDir, 'foo.dll'), 'utf-8')).toBe('foo-content');
+ expect(fs.readFileSync(path.join(outDir, 'bar.dll'), 'utf-8')).toBe('bar-content');
+ });
+
+ it('preserves subdirectory structure relative to prefix', () => {
+ const zip = createTestZip({
+ 'extension/bin/Analyzers/main.dll': 'main',
+ 'extension/bin/Analyzers/sub/nested.dll': 'nested',
+ });
+ const outDir = makeTmpDir();
+
+ const result = extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'extension/bin/Analyzers',
+ outputDir: outDir,
+ });
+
+ expect(result.fileCount).toBe(2);
+ expect(fs.existsSync(path.join(outDir, 'sub', 'nested.dll'))).toBe(true);
+ });
+
+ it('applies include glob filter', () => {
+ const zip = createTestZip({
+ 'tools/net8.0/any/foo.dll': 'dll',
+ 'tools/net8.0/any/foo.pdb': 'pdb',
+ 'tools/net8.0/any/foo.json': 'json',
+ });
+ const outDir = makeTmpDir();
+
+ const result = extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'tools/net8.0/any',
+ outputDir: outDir,
+ includePattern: '*.dll',
+ });
+
+ expect(result.fileCount).toBe(1);
+ expect(fs.existsSync(path.join(outDir, 'foo.dll'))).toBe(true);
+ expect(fs.existsSync(path.join(outDir, 'foo.pdb'))).toBe(false);
+ });
+
+ it('throws when no files match the prefix', () => {
+ const zip = createTestZip({
+ 'other/file.txt': 'content',
+ });
+ const outDir = makeTmpDir();
+
+ expect(() =>
+ extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'tools/net8.0/any',
+ outputDir: outDir,
+ }),
+ ).toThrow(/No files found matching prefix/);
+ });
+
+ it('handles prefix with trailing slash', () => {
+ const zip = createTestZip({
+ 'tools/net8.0/any/foo.dll': 'content',
+ });
+ const outDir = makeTmpDir();
+
+ const result = extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'tools/net8.0/any/',
+ outputDir: outDir,
+ });
+
+ expect(result.fileCount).toBe(1);
+ });
+
+ it('creates output directory if it does not exist', () => {
+ const zip = createTestZip({
+ 'prefix/file.txt': 'content',
+ });
+ const outDir = path.join(makeTmpDir(), 'nested', 'output');
+
+ const result = extractFromBuffer({
+ zipBuffer: zip,
+ pathPrefix: 'prefix',
+ outputDir: outDir,
+ });
+
+ expect(result.fileCount).toBe(1);
+ expect(fs.existsSync(path.join(outDir, 'file.txt'))).toBe(true);
+ });
+});
+
+describe('matchesGlob', () => {
+ it('matches wildcard extension pattern', () => {
+ expect(matchesGlob('foo.dll', '*.dll')).toBe(true);
+ expect(matchesGlob('foo.pdb', '*.dll')).toBe(false);
+ });
+
+ it('matches star-only pattern', () => {
+ expect(matchesGlob('anything', '*')).toBe(true);
+ });
+
+ it('matches prefix pattern', () => {
+ expect(matchesGlob('Microsoft.Dynamics.Nav.dll', 'Microsoft.*')).toBe(true);
+ expect(matchesGlob('Other.dll', 'Microsoft.*')).toBe(false);
+ });
+
+ it('is case-insensitive', () => {
+ expect(matchesGlob('FOO.DLL', '*.dll')).toBe(true);
+ });
+
+ it('matches exact filename', () => {
+ expect(matchesGlob('foo.dll', 'foo.dll')).toBe(true);
+ expect(matchesGlob('bar.dll', 'foo.dll')).toBe(false);
+ });
+
+ it('supports question mark for single character', () => {
+ expect(matchesGlob('foo.dll', 'fo?.dll')).toBe(true);
+ expect(matchesGlob('fooo.dll', 'fo?.dll')).toBe(false);
+ });
+});