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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 80 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <dir> Detect TFM from a local compiler directory

Options:
--help Show this help message
download --output <dir> Download and extract ALCops analyzers

Download options:
--output <dir> Required. Directory to extract analyzer DLLs into
--detect-using <input> TFM detection input (URL, path, channel, or version)
--tfm <tfm> Explicit TFM (skips auto-detection)
--version <ver> ALCops package version (default: latest)
--detect-from <source> Force detection source (bc-artifact, marketplace,
nuget-devtools, compiler-path)

Global options:
--verbose Enable debug-level logging
--help Show this help message
```

### Examples
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
77 changes: 65 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand All @@ -15,26 +15,39 @@ 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 <dir> Detect TFM from a local compiler directory
download --output <dir> Download and extract ALCops analyzers

Download options:
--output <dir> Required. Directory to extract analyzer DLLs into
--detect-using <input> TFM detection input (URL, path, channel, or version)
--tfm <tfm> Explicit TFM (skips auto-detection)
--version <ver> ALCops package version (default: latest)
--detect-from <source> 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.
`);
}

async function main(): Promise<void> {
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();
Expand All @@ -44,7 +57,7 @@ async function main(): Promise<void> {
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);
Expand All @@ -53,17 +66,17 @@ async function main(): Promise<void> {
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);
Expand All @@ -77,6 +90,34 @@ async function main(): Promise<void> {
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 <dir> 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`);
Expand All @@ -85,6 +126,18 @@ async function main(): Promise<void> {
}
}

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);
Expand Down
122 changes: 122 additions & 0 deletions src/download/download-command.ts
Original file line number Diff line number Diff line change
@@ -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<DownloadResult> {
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<string> {
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<string> {
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}`);
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading