diff --git a/README.md b/README.md
index 40d4e49..3c07000 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Automatically detect the Target Framework Moniker (TFM) for Business Central and
- **VS Marketplace** (the AL Language extension)
- **NuGet DevTools** (Microsoft's AL development tools package)
- **Local compiler path** (a directory containing the AL compiler DLLs)
+- **Download and extract** ALCops analyzer DLLs for a detected TFM
- JSON output on stdout, logs on stderr (pipe-friendly)
- Zero configuration required
- Usable as a CLI or as a Node.js library
@@ -41,9 +42,19 @@ Commands:
detect-tfm marketplace [channel] Detect TFM from VS Marketplace (default: current)
detect-tfm nuget-devtools [version] Detect TFM from NuGet DevTools (default: latest)
detect-tfm compiler-path
Detect TFM from a local compiler directory
-
-Options:
- --help Show this help message
+ download --output Download and extract ALCops analyzers
+
+Download options:
+ --output Required. Directory to extract analyzer DLLs into
+ --detect-using TFM detection input (URL, path, channel, or version)
+ --tfm Explicit TFM (skips auto-detection)
+ --version ALCops package version (default: latest)
+ --detect-from Force detection source (bc-artifact, marketplace,
+ nuget-devtools, compiler-path)
+
+Global options:
+ --verbose Enable debug-level logging
+ --help Show this help message
```
### Examples
@@ -72,6 +83,65 @@ Detect from a local compiler directory:
alcops detect-tfm compiler-path ./path/to/compiler
```
+### Download Command
+
+The `download` command combines TFM detection with analyzer extraction in a single step.
+
+Download analyzers with auto-detected TFM from the latest NuGet DevTools:
+
+```bash
+alcops download --detect-using latest --output ./analyzers
+```
+
+Download analyzers with auto-detected TFM from a BC artifact URL:
+
+```bash
+alcops download --detect-using "https://bcartifacts.azureedge.net/sandbox/26.0.12345.0/us" --output ./analyzers
+```
+
+Download analyzers with auto-detected TFM from a local compiler directory:
+
+```bash
+alcops download --detect-using ./path/to/compiler --output ./analyzers
+```
+
+Download analyzers with an explicit TFM (skips detection):
+
+```bash
+alcops download --tfm net8.0 --output ./analyzers
+```
+
+Download a specific ALCops version:
+
+```bash
+alcops download --detect-using latest --output ./analyzers --version 1.0.0
+```
+
+Force a detection source with `--detect-from` (overrides smart routing):
+
+```bash
+alcops download --detect-using 18.0.2293710 --detect-from marketplace --output ./analyzers
+```
+
+Enable verbose logging for debugging:
+
+```bash
+alcops download --detect-using latest --output ./analyzers --verbose
+```
+
+#### Download Output
+
+```json
+{
+ "version": "1.0.0",
+ "tfm": "net8.0",
+ "outputDir": "/absolute/path/to/analyzers",
+ "files": [
+ "/absolute/path/to/analyzers/ALCops.Analyzers.dll"
+ ]
+}
+```
+
### Output
All commands write JSON to stdout:
@@ -104,6 +174,13 @@ echo "Building with TFM: $TFM"
run: echo "Target framework is ${{ steps.tfm.outputs.tfm }}"
```
+Or use the `download` command for a one-step solution:
+
+```yaml
+- name: Download ALCops Analyzers
+ run: npx @alcops/core download --detect-using latest --output ./analyzers --verbose
+```
+
## Programmatic API
The package exports all detection functions for use as a library:
diff --git a/src/cli.ts b/src/cli.ts
index 1639dbd..977f076 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,8 +3,8 @@ import { detectFromBCArtifact } from './detectors/bc-artifact';
import { detectFromMarketplace } from './detectors/marketplace';
import { detectFromNuGetDevTools } from './detectors/nuget-devtools';
import { detectFromCompilerPath } from './detectors/compiler-path';
-
-const logger = createConsoleLogger();
+import { executeDownload } from './download/download-command';
+import type { DetectSource } from './resolve-detect-source';
function usage(): void {
process.stderr.write(`
@@ -15,9 +15,19 @@ Commands:
detect-tfm marketplace [channel] Detect TFM from VS Marketplace (default: current)
detect-tfm nuget-devtools [version] 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
+
+Download options:
+ --output Required. Directory to extract analyzer DLLs into
+ --detect-using TFM detection input (URL, path, channel, or version)
+ --tfm Explicit TFM (skips auto-detection)
+ --version ALCops package version (default: latest)
+ --detect-from Force detection source (bc-artifact, marketplace,
+ nuget-devtools, compiler-path)
-Options:
- --help Show this help message
+Global options:
+ --verbose Enable debug-level logging
+ --help Show this help message
Output: JSON on stdout, logs on stderr.
`);
@@ -25,16 +35,19 @@ Output: JSON on stdout, logs on stderr.
async function main(): Promise {
const args = process.argv.slice(2);
+ const verbose = args.includes('--verbose');
+ const filteredArgs = args.filter((a) => a !== '--verbose');
+ const logger = createConsoleLogger(verbose);
- if (args.length === 0 || args.includes('--help')) {
+ if (filteredArgs.length === 0 || filteredArgs.includes('--help')) {
usage();
- process.exit(args.includes('--help') ? 0 : 1);
+ process.exit(filteredArgs.includes('--help') ? 0 : 1);
}
- const command = args[0];
+ const command = filteredArgs[0];
if (command === 'detect-tfm') {
- const subcommand = args[1];
+ const subcommand = filteredArgs[1];
if (!subcommand) {
process.stderr.write('Error: detect-tfm requires a subcommand\n');
usage();
@@ -44,7 +57,7 @@ async function main(): Promise {
let result;
switch (subcommand) {
case 'bc-artifact': {
- const url = args[2];
+ const url = filteredArgs[2];
if (!url) {
process.stderr.write('Error: bc-artifact requires a URL argument\n');
process.exit(1);
@@ -53,17 +66,17 @@ async function main(): Promise {
break;
}
case 'marketplace': {
- const channel = args[2] || 'current';
+ const channel = filteredArgs[2] || 'current';
result = await detectFromMarketplace(channel, logger);
break;
}
case 'nuget-devtools': {
- const version = args[2] || 'latest';
+ const version = filteredArgs[2] || 'latest';
result = await detectFromNuGetDevTools(version, logger);
break;
}
case 'compiler-path': {
- const dir = args[2];
+ const dir = filteredArgs[2];
if (!dir) {
process.stderr.write('Error: compiler-path requires a directory argument\n');
process.exit(1);
@@ -77,6 +90,34 @@ async function main(): Promise {
process.exit(1);
}
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
+ } else if (command === 'download') {
+ const downloadArgs = filteredArgs.slice(1);
+ const outputDir = getFlagValue(downloadArgs, '--output');
+ const tfm = getFlagValue(downloadArgs, '--tfm');
+ const version = getFlagValue(downloadArgs, '--version');
+ const detectFrom = getFlagValue(downloadArgs, '--detect-from') as DetectSource | undefined;
+ const detectSource = getFlagValue(downloadArgs, '--detect-using');
+
+ if (!outputDir) {
+ process.stderr.write('Error: --output is required for the download command\n');
+ usage();
+ process.exit(1);
+ }
+
+ if (detectFrom && !isValidDetectSource(detectFrom)) {
+ process.stderr.write(
+ `Error: Invalid --detect-from value: ${detectFrom}. ` +
+ `Valid values: bc-artifact, marketplace, nuget-devtools, compiler-path\n`,
+ );
+ process.exit(1);
+ }
+
+ const result = await executeDownload(
+ { detectSource, tfm, version, detectFrom, outputDir },
+ logger,
+ );
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
process.stderr.write(`Error: Unknown command: ${command}\n`);
@@ -85,6 +126,18 @@ async function main(): Promise {
}
}
+function getFlagValue(args: string[], flag: string): string | undefined {
+ const idx = args.indexOf(flag);
+ if (idx === -1 || idx + 1 >= args.length) return undefined;
+ return args[idx + 1];
+}
+
+const VALID_DETECT_SOURCES = new Set(['bc-artifact', 'marketplace', 'nuget-devtools', 'compiler-path']);
+
+function isValidDetectSource(value: string): value is DetectSource {
+ return VALID_DETECT_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/download/download-command.ts b/src/download/download-command.ts
new file mode 100644
index 0000000..07a6dc3
--- /dev/null
+++ b/src/download/download-command.ts
@@ -0,0 +1,122 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { Logger, nullLogger } from '../logger';
+import { resolveDetectSource, DetectSource } from '../resolve-detect-source';
+import { detectFromBCArtifact } from '../detectors/bc-artifact';
+import { detectFromMarketplace } from '../detectors/marketplace';
+import { detectFromNuGetDevTools } from '../detectors/nuget-devtools';
+import { detectFromCompilerPath } from '../detectors/compiler-path';
+import { resolveVersion, downloadPackage } from './nuget-api';
+import { extractAnalyzers } from './nuget-extractor';
+
+export interface DownloadOptions {
+ /** TFM detection input (URL, path, channel keyword, or version number) */
+ detectSource?: string;
+ /** Explicit TFM, skips detection */
+ tfm?: string;
+ /** ALCops package version to download (default: 'latest') */
+ version?: string;
+ /** Force a specific detection source, overrides smart routing */
+ detectFrom?: DetectSource;
+ /** Output directory for extracted analyzer DLLs */
+ outputDir: string;
+}
+
+export interface DownloadResult {
+ version: string;
+ tfm: string;
+ outputDir: string;
+ files: string[];
+}
+
+/**
+ * Full download pipeline: detect TFM → resolve ALCops version → download → extract → cleanup.
+ */
+export async function executeDownload(
+ options: DownloadOptions,
+ logger: Logger = nullLogger,
+): Promise {
+ const tfm = await resolveTfm(options, logger);
+ const alcopsVersion = options.version ?? 'latest';
+
+ logger.info(`Target TFM: ${tfm}`);
+ logger.info(`ALCops version: ${alcopsVersion}`);
+
+ const resolved = await resolveVersion(alcopsVersion, logger);
+ logger.info(`Resolved ALCops version: ${resolved.version}`);
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alcops-'));
+ try {
+ const nupkgPath = await downloadPackage(
+ resolved.version,
+ tmpDir,
+ logger,
+ resolved.packageContentUrl,
+ );
+
+ const { files, actualTfm } = await extractAnalyzers(
+ nupkgPath,
+ tfm,
+ options.outputDir,
+ logger,
+ );
+
+ logger.info(`Download complete. ${files.length} analyzer(s) extracted.`);
+
+ return {
+ version: resolved.version,
+ tfm: actualTfm,
+ outputDir: path.resolve(options.outputDir),
+ files: files.map((f) => path.resolve(f)),
+ };
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ logger.debug(`Cleaned up temp directory: ${tmpDir}`);
+ }
+}
+
+async function resolveTfm(options: DownloadOptions, logger: Logger): Promise {
+ if (options.tfm) {
+ logger.info(`Using explicit TFM: ${options.tfm}`);
+ return options.tfm;
+ }
+
+ if (!options.detectSource) {
+ throw new Error(
+ 'Either --tfm or --detect-using is required. ' +
+ 'Run "alcops download --help" for usage.',
+ );
+ }
+
+ const detectSource = options.detectFrom
+ ? { source: options.detectFrom, input: options.detectSource }
+ : await resolveDetectSource(options.detectSource, logger);
+
+ logger.info(`Detection source: ${detectSource.source} (input: ${detectSource.input})`);
+
+ return detectTfm(detectSource.source, detectSource.input, logger);
+}
+
+async function detectTfm(source: DetectSource, input: string, logger: Logger): Promise {
+ switch (source) {
+ case 'bc-artifact': {
+ const result = await detectFromBCArtifact(input, logger);
+ return result.tfm;
+ }
+ case 'marketplace': {
+ const result = await detectFromMarketplace(input, logger);
+ return result.tfm;
+ }
+ case 'nuget-devtools': {
+ const result = await detectFromNuGetDevTools(input, logger);
+ return result.tfm;
+ }
+ case 'compiler-path': {
+ const result = await detectFromCompilerPath(input, logger);
+ return result.tfm;
+ }
+ default:
+ throw new Error(`Unknown detection source: ${source as string}`);
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index e3e2c7e..43f6abc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,12 @@ export { detectFromCompilerPath, findDllFiles } from './detectors/compiler-path'
// Download
export { resolveVersion, downloadPackage, getDownloadUrl } from './download/nuget-api';
export { extractAnalyzers, findMatchingTfmFolder } from './download/nuget-extractor';
+export { executeDownload } from './download/download-command';
+export type { DownloadOptions, DownloadResult } from './download/download-command';
+
+// Detection source resolution
+export { resolveDetectSource, resolveVersionSource } from './resolve-detect-source';
+export type { DetectSource, ResolvedDetectSource } from './resolve-detect-source';
// Shared utilities
export { getUserAgent } from './user-agent';
diff --git a/src/resolve-detect-source.ts b/src/resolve-detect-source.ts
new file mode 100644
index 0000000..1a8a80c
--- /dev/null
+++ b/src/resolve-detect-source.ts
@@ -0,0 +1,105 @@
+import { Logger, nullLogger } from './logger';
+import { queryNuGetRegistration } from './nuget-registration';
+import { queryMarketplace } from './detectors/marketplace';
+import { getUserAgent } from './user-agent';
+import packageJson from '../package.json';
+
+const DEFAULT_USER_AGENT = getUserAgent('ALCops', packageJson.version);
+
+export type DetectSource = 'bc-artifact' | 'compiler-path' | 'nuget-devtools' | 'marketplace';
+
+export interface ResolvedDetectSource {
+ source: DetectSource;
+ input: string;
+}
+
+/**
+ * Classify a raw CLI input string into a detection source using heuristics.
+ *
+ * - Starts with http:// or https:// → bc-artifact
+ * - Exists as a local filesystem path → compiler-path
+ * - Channel keyword (latest, prerelease, current) → nuget-devtools (default)
+ * - Specific version string → resolved via API (NuGet first, marketplace fallback)
+ */
+export async function resolveDetectSource(
+ input: string,
+ logger: Logger = nullLogger,
+ userAgent?: string,
+): Promise {
+ const ua = userAgent ?? DEFAULT_USER_AGENT;
+
+ if (isUrl(input)) {
+ logger.debug(`Input classified as URL → bc-artifact`);
+ return { source: 'bc-artifact', input };
+ }
+
+ if (isChannelKeyword(input)) {
+ logger.debug(`Input classified as channel keyword → nuget-devtools`);
+ return { source: 'nuget-devtools', input };
+ }
+
+ // For version strings, resolve via API calls
+ return resolveVersionSource(input, logger, ua);
+}
+
+/**
+ * Determine whether a version string belongs to NuGet DevTools or VS Marketplace.
+ * Queries NuGet registry first (faster), then marketplace as fallback.
+ */
+export async function resolveVersionSource(
+ version: string,
+ logger: Logger = nullLogger,
+ userAgent?: string,
+): Promise {
+ const ua = userAgent ?? DEFAULT_USER_AGENT;
+
+ logger.info(`Resolving detection source for version: ${version}`);
+
+ // Try NuGet DevTools first (faster API)
+ logger.debug('Checking NuGet DevTools registry...');
+ try {
+ const devToolsPackage = 'microsoft.dynamics.businesscentral.development.tools';
+ const nugetVersions = await queryNuGetRegistration(devToolsPackage, ua, logger);
+ const found = nugetVersions.some(
+ (v) => v.version.toLowerCase() === version.toLowerCase(),
+ );
+ if (found) {
+ logger.info(`Version '${version}' found in NuGet DevTools`);
+ return { source: 'nuget-devtools', input: version };
+ }
+ logger.debug(`Version '${version}' not found in NuGet DevTools`);
+ } catch (err) {
+ logger.warn(`NuGet DevTools lookup failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ // Fallback to VS Marketplace
+ logger.debug('Checking VS Marketplace...');
+ try {
+ const marketplaceVersions = await queryMarketplace(logger);
+ const found = marketplaceVersions.some(
+ (v) => v.version === version,
+ );
+ if (found) {
+ logger.info(`Version '${version}' found in VS Marketplace`);
+ return { source: 'marketplace', input: version };
+ }
+ logger.debug(`Version '${version}' not found in VS Marketplace`);
+ } catch (err) {
+ logger.warn(`VS Marketplace lookup failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ throw new Error(
+ `Version '${version}' not found in NuGet DevTools or VS Marketplace. ` +
+ `Use --detect-from to specify the source explicitly, or use --tfm to skip detection.`,
+ );
+}
+
+function isUrl(input: string): boolean {
+ return input.startsWith('http://') || input.startsWith('https://');
+}
+
+const CHANNEL_KEYWORDS = new Set(['latest', 'prerelease', 'current']);
+
+function isChannelKeyword(input: string): boolean {
+ return CHANNEL_KEYWORDS.has(input.toLowerCase());
+}
diff --git a/tests/download/download-command.test.ts b/tests/download/download-command.test.ts
new file mode 100644
index 0000000..e86492c
--- /dev/null
+++ b/tests/download/download-command.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { zipSync } from 'fflate';
+
+// Mock all external network dependencies
+vi.mock('../../src/nuget-registration', () => ({
+ queryNuGetRegistration: vi.fn(),
+}));
+
+vi.mock('../../src/detectors/marketplace', () => ({
+ queryMarketplace: vi.fn(),
+ detectFromMarketplace: vi.fn(),
+ resolveExtensionVersion: vi.fn(),
+}));
+
+vi.mock('../../src/detectors/bc-artifact', () => ({
+ detectFromBCArtifact: vi.fn(),
+}));
+
+vi.mock('../../src/detectors/nuget-devtools', () => ({
+ detectFromNuGetDevTools: vi.fn(),
+}));
+
+vi.mock('../../src/detectors/compiler-path', () => ({
+ detectFromCompilerPath: vi.fn(),
+}));
+
+vi.mock('../../src/http-client', () => ({
+ httpsGetBuffer: vi.fn(),
+ httpsGetJson: vi.fn(),
+}));
+
+import { queryNuGetRegistration } from '../../src/nuget-registration';
+import { detectFromBCArtifact } from '../../src/detectors/bc-artifact';
+import { detectFromMarketplace } from '../../src/detectors/marketplace';
+import { detectFromNuGetDevTools } from '../../src/detectors/nuget-devtools';
+import { detectFromCompilerPath } from '../../src/detectors/compiler-path';
+import { httpsGetBuffer } from '../../src/http-client';
+import { executeDownload } from '../../src/download/download-command';
+import type { RegistrationVersion } from '../../src/types';
+
+const mockDetectBCArtifact = detectFromBCArtifact as ReturnType;
+const mockDetectMarketplace = detectFromMarketplace as ReturnType;
+const mockDetectNuGetDevTools = detectFromNuGetDevTools as ReturnType;
+const mockDetectCompilerPath = detectFromCompilerPath as ReturnType;
+const mockQueryRegistration = queryNuGetRegistration as ReturnType;
+const mockHttpsGetBuffer = httpsGetBuffer as ReturnType;
+
+let tmpDir: string;
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'download-cmd-test-'));
+
+ // Default: resolve ALCops version as 'latest' with a single stable version
+ mockQueryRegistration.mockResolvedValue([
+ { version: '1.0.0', listed: true, packageContent: 'https://example.com/pkg.nupkg' },
+ ] satisfies RegistrationVersion[]);
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function createFakeNupkgBuffer(tfm: string): Buffer {
+ const fakeDll = new Uint8Array([0x4d, 0x5a, 0x90, 0x00]);
+ const entries: Record = {
+ [`lib/${tfm}/TestAnalyzer.dll`]: fakeDll,
+ };
+ const zipped = zipSync(entries);
+ return Buffer.from(zipped);
+}
+
+describe('executeDownload', () => {
+ it('downloads and extracts with explicit --tfm', async () => {
+ const nupkgBuffer = createFakeNupkgBuffer('net8.0');
+ mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer);
+
+ const outputDir = path.join(tmpDir, 'output');
+ const result = await executeDownload({
+ tfm: 'net8.0',
+ outputDir,
+ });
+
+ expect(result.version).toBe('1.0.0');
+ expect(result.tfm).toBe('net8.0');
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0]).toContain('TestAnalyzer.dll');
+ expect(fs.existsSync(result.files[0])).toBe(true);
+ });
+
+ it('auto-detects TFM from URL (bc-artifact)', async () => {
+ mockDetectBCArtifact.mockResolvedValue({ tfm: 'net8.0', source: 'bc-artifact' });
+ const nupkgBuffer = createFakeNupkgBuffer('net8.0');
+ mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer);
+
+ const outputDir = path.join(tmpDir, 'output');
+ const result = await executeDownload({
+ detectSource: 'https://bcartifacts/onprem/24.0/us',
+ outputDir,
+ });
+
+ expect(mockDetectBCArtifact).toHaveBeenCalledWith(
+ 'https://bcartifacts/onprem/24.0/us',
+ expect.anything(),
+ );
+ expect(result.tfm).toBe('net8.0');
+ });
+
+ it('auto-detects TFM from channel keyword (nuget-devtools)', async () => {
+ mockDetectNuGetDevTools.mockResolvedValue({ tfm: 'net8.0', source: 'nuget-devtools' });
+ const nupkgBuffer = createFakeNupkgBuffer('net8.0');
+ mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer);
+
+ const outputDir = path.join(tmpDir, 'output');
+ const result = await executeDownload({
+ detectSource: 'latest',
+ outputDir,
+ });
+
+ expect(mockDetectNuGetDevTools).toHaveBeenCalledWith('latest', expect.anything());
+ expect(result.tfm).toBe('net8.0');
+ });
+
+ it('respects --detect-from override', async () => {
+ mockDetectMarketplace.mockResolvedValue({ tfm: 'net8.0', source: 'marketplace' });
+ const nupkgBuffer = createFakeNupkgBuffer('net8.0');
+ mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer);
+
+ const outputDir = path.join(tmpDir, 'output');
+ const result = await executeDownload({
+ detectSource: 'https://bcartifacts/some-url',
+ detectFrom: 'marketplace',
+ outputDir,
+ });
+
+ // Should use marketplace despite URL input
+ expect(mockDetectMarketplace).toHaveBeenCalled();
+ expect(mockDetectBCArtifact).not.toHaveBeenCalled();
+ expect(result.tfm).toBe('net8.0');
+ });
+
+ it('throws when neither --tfm nor --detect-using is provided', async () => {
+ const outputDir = path.join(tmpDir, 'output');
+ await expect(executeDownload({ outputDir })).rejects.toThrow(
+ /Either --tfm or --detect-using is required/,
+ );
+ });
+
+ it('cleans up temp directory even on extraction error', async () => {
+ const nupkgBuffer = createFakeNupkgBuffer('net6.0');
+ mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer);
+
+ // Count alcops temp dirs before
+ const before = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('alcops-')).length;
+
+ const outputDir = path.join(tmpDir, 'output');
+ await expect(
+ executeDownload({ tfm: 'netstandard1.0', outputDir }),
+ ).rejects.toThrow();
+
+ // No new temp dirs should remain
+ const after = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('alcops-')).length;
+ expect(after).toBe(before);
+ });
+});
diff --git a/tests/resolve-detect-source.test.ts b/tests/resolve-detect-source.test.ts
new file mode 100644
index 0000000..bc8113f
--- /dev/null
+++ b/tests/resolve-detect-source.test.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock nuget-registration module
+vi.mock('../src/nuget-registration', () => ({
+ queryNuGetRegistration: vi.fn(),
+}));
+
+// Mock marketplace module
+vi.mock('../src/detectors/marketplace', () => ({
+ queryMarketplace: vi.fn(),
+}));
+
+import { queryNuGetRegistration } from '../src/nuget-registration';
+import { queryMarketplace } from '../src/detectors/marketplace';
+import {
+ resolveDetectSource,
+ resolveVersionSource,
+} from '../src/resolve-detect-source';
+import type { RegistrationVersion } from '../src/types';
+
+const mockQueryRegistration = queryNuGetRegistration as ReturnType;
+const mockQueryMarketplace = queryMarketplace as ReturnType;
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe('resolveDetectSource', () => {
+ it('classifies HTTP URLs as bc-artifact', async () => {
+ const result = await resolveDetectSource('https://bcartifacts/onprem/24.0/us');
+ expect(result).toEqual({
+ source: 'bc-artifact',
+ input: 'https://bcartifacts/onprem/24.0/us',
+ });
+ });
+
+ it('classifies HTTPS URLs as bc-artifact', async () => {
+ const result = await resolveDetectSource('http://example.com/artifact');
+ expect(result).toEqual({
+ source: 'bc-artifact',
+ input: 'http://example.com/artifact',
+ });
+ });
+
+ it('classifies "latest" as nuget-devtools channel', async () => {
+ const result = await resolveDetectSource('latest');
+ expect(result).toEqual({ source: 'nuget-devtools', input: 'latest' });
+ });
+
+ it('classifies "prerelease" as nuget-devtools channel', async () => {
+ const result = await resolveDetectSource('prerelease');
+ expect(result).toEqual({ source: 'nuget-devtools', input: 'prerelease' });
+ });
+
+ it('classifies "current" as nuget-devtools channel', async () => {
+ const result = await resolveDetectSource('current');
+ expect(result).toEqual({ source: 'nuget-devtools', input: 'current' });
+ });
+
+ it('resolves a NuGet DevTools version via API', async () => {
+ mockQueryRegistration.mockResolvedValue([
+ { version: '18.0.35.14686', listed: true, packageContent: '' },
+ ] satisfies RegistrationVersion[]);
+
+ const result = await resolveDetectSource('18.0.35.14686');
+ expect(result).toEqual({ source: 'nuget-devtools', input: '18.0.35.14686' });
+ expect(mockQueryRegistration).toHaveBeenCalled();
+ expect(mockQueryMarketplace).not.toHaveBeenCalled();
+ });
+
+ it('falls back to marketplace when version not in NuGet', async () => {
+ mockQueryRegistration.mockResolvedValue([
+ { version: '17.0.34.45391', listed: true, packageContent: '' },
+ ] satisfies RegistrationVersion[]);
+
+ mockQueryMarketplace.mockResolvedValue([
+ { version: '18.0.2293710', vsixUrl: 'https://example.com', isPreRelease: false },
+ ]);
+
+ const result = await resolveDetectSource('18.0.2293710');
+ expect(result).toEqual({ source: 'marketplace', input: '18.0.2293710' });
+ expect(mockQueryRegistration).toHaveBeenCalled();
+ expect(mockQueryMarketplace).toHaveBeenCalled();
+ });
+
+ it('throws when version not found in either source', async () => {
+ mockQueryRegistration.mockResolvedValue([]);
+ mockQueryMarketplace.mockResolvedValue([]);
+
+ await expect(resolveDetectSource('99.99.99')).rejects.toThrow(
+ /not found in NuGet DevTools or VS Marketplace/,
+ );
+ });
+});
+
+describe('resolveVersionSource', () => {
+ it('finds version in NuGet DevTools without checking marketplace', async () => {
+ mockQueryRegistration.mockResolvedValue([
+ { version: '17.0.34.45391', listed: true, packageContent: '' },
+ ] satisfies RegistrationVersion[]);
+
+ const result = await resolveVersionSource('17.0.34.45391');
+ expect(result.source).toBe('nuget-devtools');
+ expect(mockQueryMarketplace).not.toHaveBeenCalled();
+ });
+
+ it('checks marketplace when NuGet API fails', async () => {
+ mockQueryRegistration.mockRejectedValue(new Error('Network error'));
+ mockQueryMarketplace.mockResolvedValue([
+ { version: '18.0.2293710', vsixUrl: 'https://example.com', isPreRelease: false },
+ ]);
+
+ const result = await resolveVersionSource('18.0.2293710');
+ expect(result.source).toBe('marketplace');
+ });
+
+ it('performs case-insensitive match for NuGet versions', async () => {
+ mockQueryRegistration.mockResolvedValue([
+ { version: '18.0.35.14686-Beta', listed: true, packageContent: '' },
+ ] satisfies RegistrationVersion[]);
+
+ const result = await resolveVersionSource('18.0.35.14686-beta');
+ expect(result.source).toBe('nuget-devtools');
+ });
+});