From 15c1b4f0edf118ff591b7c72fd94aa3256926195 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 12:21:35 +0200 Subject: [PATCH 1/3] feat: add download command to CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'alcops download' command that performs the full pipeline: - Smart input routing: URL → bc-artifact, path → compiler-path, channel → nuget-devtools, version → API resolution - --detect-from flag to override smart routing - --tfm for explicit TFM (skips detection) - --version for specific ALCops package version - --verbose for debug logging (global, applies to all commands) - Temp .nupkg cleanup via try/finally New modules: - src/resolve-detect-source.ts: API-based version source resolution (NuGet first, marketplace fallback) - src/download/download-command.ts: download pipeline orchestration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 82 +++++++++++- src/cli.ts | 83 ++++++++++-- src/download/download-command.ts | 122 +++++++++++++++++ src/index.ts | 6 + src/resolve-detect-source.ts | 105 +++++++++++++++ tests/download/download-command.test.ts | 168 ++++++++++++++++++++++++ tests/resolve-detect-source.test.ts | 125 ++++++++++++++++++ 7 files changed, 676 insertions(+), 15 deletions(-) create mode 100644 src/download/download-command.ts create mode 100644 src/resolve-detect-source.ts create mode 100644 tests/download/download-command.test.ts create mode 100644 tests/resolve-detect-source.test.ts diff --git a/README.md b/README.md index 40d4e49..d88a38f 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,18 @@ 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 [source] --output Download and extract ALCops analyzers + +Download options: + --output Required. Directory to extract analyzer DLLs into + --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 +82,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 latest --output ./analyzers +``` + +Download analyzers with auto-detected TFM from a BC artifact URL: + +```bash +alcops download "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 ./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 latest --output ./analyzers --version 1.0.0 +``` + +Force a detection source with `--detect-from` (overrides smart routing): + +```bash +alcops download 18.0.2293710 --detect-from marketplace --output ./analyzers +``` + +Enable verbose logging for debugging: + +```bash +alcops download 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 +173,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 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..4905ce4 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,18 @@ 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 [source] --output Download and extract ALCops analyzers + +Download options: + --output Required. Directory to extract analyzer DLLs into + --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 +34,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 +56,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 +65,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 +89,36 @@ 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; + + 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); + } + + // Positional arg is the source (first arg that doesn't start with --) + const source = downloadArgs.find((a) => !a.startsWith('--') && !isFlagValue(downloadArgs, a)); + + const result = await executeDownload( + { source, 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 +127,23 @@ 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]; +} + +function isFlagValue(args: string[], value: string): boolean { + const idx = args.indexOf(value); + return idx > 0 && args[idx - 1].startsWith('--'); +} + +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..cd61c61 --- /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 { + /** Detection source input (URL, path, channel, or version string) */ + source?: 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.source) { + throw new Error( + 'Either --tfm or a detection source argument is required. ' + + 'Run "alcops download --help" for usage.', + ); + } + + const detectSource = options.detectFrom + ? { source: options.detectFrom, input: options.source } + : await resolveDetectSource(options.source, 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..a1de067 --- /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({ + source: '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({ + source: '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({ + source: '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 source is provided', async () => { + const outputDir = path.join(tmpDir, 'output'); + await expect(executeDownload({ outputDir })).rejects.toThrow( + /Either --tfm or a detection source argument 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'); + }); +}); From 89134b8e38cf7eaa52390d218485c89e7dba3798 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 13:26:39 +0200 Subject: [PATCH 2/3] refactor: replace positional source arg with --detect-source flag Remove ambiguous positional [source] argument from the download command. Add --detect-source named flag for TFM detection input (URL, path, channel keyword, or version number). This makes --version unambiguously refer to the ALCops package version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 +++++++++-------- src/cli.ts | 14 ++++---------- src/download/download-command.ts | 12 ++++++------ tests/download/download-command.test.ts | 10 +++++----- 4 files changed, 24 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d88a38f..c8c3ba4 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,11 @@ 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 [source] --output Download and extract ALCops analyzers + download --output Download and extract ALCops analyzers Download options: --output Required. Directory to extract analyzer DLLs into + --detect-source 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, @@ -89,19 +90,19 @@ The `download` command combines TFM detection with analyzer extraction in a sing Download analyzers with auto-detected TFM from the latest NuGet DevTools: ```bash -alcops download latest --output ./analyzers +alcops download --detect-source latest --output ./analyzers ``` Download analyzers with auto-detected TFM from a BC artifact URL: ```bash -alcops download "https://bcartifacts.azureedge.net/sandbox/26.0.12345.0/us" --output ./analyzers +alcops download --detect-source "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 ./path/to/compiler --output ./analyzers +alcops download --detect-source ./path/to/compiler --output ./analyzers ``` Download analyzers with an explicit TFM (skips detection): @@ -113,19 +114,19 @@ alcops download --tfm net8.0 --output ./analyzers Download a specific ALCops version: ```bash -alcops download latest --output ./analyzers --version 1.0.0 +alcops download --detect-source latest --output ./analyzers --version 1.0.0 ``` Force a detection source with `--detect-from` (overrides smart routing): ```bash -alcops download 18.0.2293710 --detect-from marketplace --output ./analyzers +alcops download --detect-source 18.0.2293710 --detect-from marketplace --output ./analyzers ``` Enable verbose logging for debugging: ```bash -alcops download latest --output ./analyzers --verbose +alcops download --detect-source latest --output ./analyzers --verbose ``` #### Download Output @@ -177,7 +178,7 @@ Or use the `download` command for a one-step solution: ```yaml - name: Download ALCops Analyzers - run: npx @alcops/core download latest --output ./analyzers --verbose + run: npx @alcops/core download --detect-source latest --output ./analyzers --verbose ``` ## Programmatic API diff --git a/src/cli.ts b/src/cli.ts index 4905ce4..f944f34 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,10 +15,11 @@ 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 [source] --output Download and extract ALCops analyzers + download --output Download and extract ALCops analyzers Download options: --output Required. Directory to extract analyzer DLLs into + --detect-source 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, @@ -96,6 +97,7 @@ async function main(): Promise { const tfm = getFlagValue(downloadArgs, '--tfm'); const version = getFlagValue(downloadArgs, '--version'); const detectFrom = getFlagValue(downloadArgs, '--detect-from') as DetectSource | undefined; + const detectSource = getFlagValue(downloadArgs, '--detect-source'); if (!outputDir) { process.stderr.write('Error: --output is required for the download command\n'); @@ -111,11 +113,8 @@ async function main(): Promise { process.exit(1); } - // Positional arg is the source (first arg that doesn't start with --) - const source = downloadArgs.find((a) => !a.startsWith('--') && !isFlagValue(downloadArgs, a)); - const result = await executeDownload( - { source, tfm, version, detectFrom, outputDir }, + { detectSource, tfm, version, detectFrom, outputDir }, logger, ); @@ -133,11 +132,6 @@ function getFlagValue(args: string[], flag: string): string | undefined { return args[idx + 1]; } -function isFlagValue(args: string[], value: string): boolean { - const idx = args.indexOf(value); - return idx > 0 && args[idx - 1].startsWith('--'); -} - const VALID_DETECT_SOURCES = new Set(['bc-artifact', 'marketplace', 'nuget-devtools', 'compiler-path']); function isValidDetectSource(value: string): value is DetectSource { diff --git a/src/download/download-command.ts b/src/download/download-command.ts index cd61c61..da0ddc3 100644 --- a/src/download/download-command.ts +++ b/src/download/download-command.ts @@ -11,8 +11,8 @@ import { resolveVersion, downloadPackage } from './nuget-api'; import { extractAnalyzers } from './nuget-extractor'; export interface DownloadOptions { - /** Detection source input (URL, path, channel, or version string) */ - source?: string; + /** 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') */ @@ -82,16 +82,16 @@ async function resolveTfm(options: DownloadOptions, logger: Logger): Promise { const outputDir = path.join(tmpDir, 'output'); const result = await executeDownload({ - source: 'https://bcartifacts/onprem/24.0/us', + detectSource: 'https://bcartifacts/onprem/24.0/us', outputDir, }); @@ -116,7 +116,7 @@ describe('executeDownload', () => { const outputDir = path.join(tmpDir, 'output'); const result = await executeDownload({ - source: 'latest', + detectSource: 'latest', outputDir, }); @@ -131,7 +131,7 @@ describe('executeDownload', () => { const outputDir = path.join(tmpDir, 'output'); const result = await executeDownload({ - source: 'https://bcartifacts/some-url', + detectSource: 'https://bcartifacts/some-url', detectFrom: 'marketplace', outputDir, }); @@ -142,10 +142,10 @@ describe('executeDownload', () => { expect(result.tfm).toBe('net8.0'); }); - it('throws when neither --tfm nor source is provided', async () => { + it('throws when neither --tfm nor --detect-source is provided', async () => { const outputDir = path.join(tmpDir, 'output'); await expect(executeDownload({ outputDir })).rejects.toThrow( - /Either --tfm or a detection source argument is required/, + /Either --tfm or --detect-source is required/, ); }); From b53b5fc93287facc3a6facf6504c1d0641b5cebc Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 29 Apr 2026 13:34:06 +0200 Subject: [PATCH 3/3] refactor: rename --detect-source to --detect-using More self-documenting flag name: --detect-using clearly communicates that the value is the input used for TFM detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 16 ++++++++-------- src/cli.ts | 4 ++-- src/download/download-command.ts | 2 +- tests/download/download-command.test.ts | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c8c3ba4..3c07000 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Commands: Download options: --output Required. Directory to extract analyzer DLLs into - --detect-source TFM detection input (URL, path, channel, or version) + --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, @@ -90,19 +90,19 @@ The `download` command combines TFM detection with analyzer extraction in a sing Download analyzers with auto-detected TFM from the latest NuGet DevTools: ```bash -alcops download --detect-source latest --output ./analyzers +alcops download --detect-using latest --output ./analyzers ``` Download analyzers with auto-detected TFM from a BC artifact URL: ```bash -alcops download --detect-source "https://bcartifacts.azureedge.net/sandbox/26.0.12345.0/us" --output ./analyzers +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-source ./path/to/compiler --output ./analyzers +alcops download --detect-using ./path/to/compiler --output ./analyzers ``` Download analyzers with an explicit TFM (skips detection): @@ -114,19 +114,19 @@ alcops download --tfm net8.0 --output ./analyzers Download a specific ALCops version: ```bash -alcops download --detect-source latest --output ./analyzers --version 1.0.0 +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-source 18.0.2293710 --detect-from marketplace --output ./analyzers +alcops download --detect-using 18.0.2293710 --detect-from marketplace --output ./analyzers ``` Enable verbose logging for debugging: ```bash -alcops download --detect-source latest --output ./analyzers --verbose +alcops download --detect-using latest --output ./analyzers --verbose ``` #### Download Output @@ -178,7 +178,7 @@ Or use the `download` command for a one-step solution: ```yaml - name: Download ALCops Analyzers - run: npx @alcops/core download --detect-source latest --output ./analyzers --verbose + run: npx @alcops/core download --detect-using latest --output ./analyzers --verbose ``` ## Programmatic API diff --git a/src/cli.ts b/src/cli.ts index f944f34..977f076 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ Commands: Download options: --output Required. Directory to extract analyzer DLLs into - --detect-source TFM detection input (URL, path, channel, or version) + --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, @@ -97,7 +97,7 @@ async function main(): Promise { const tfm = getFlagValue(downloadArgs, '--tfm'); const version = getFlagValue(downloadArgs, '--version'); const detectFrom = getFlagValue(downloadArgs, '--detect-from') as DetectSource | undefined; - const detectSource = getFlagValue(downloadArgs, '--detect-source'); + const detectSource = getFlagValue(downloadArgs, '--detect-using'); if (!outputDir) { process.stderr.write('Error: --output is required for the download command\n'); diff --git a/src/download/download-command.ts b/src/download/download-command.ts index da0ddc3..07a6dc3 100644 --- a/src/download/download-command.ts +++ b/src/download/download-command.ts @@ -84,7 +84,7 @@ async function resolveTfm(options: DownloadOptions, logger: Logger): Promise { expect(result.tfm).toBe('net8.0'); }); - it('throws when neither --tfm nor --detect-source is provided', async () => { + 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-source is required/, + /Either --tfm or --detect-using is required/, ); });