src/modules/argumentsParser.ts
import fs from 'node:fs';
import yargs, { Argv } from 'yargs';
import Logger from '../console/logger.js';
import Defaults from '../globals/defaults.js';
import Package from '../globals/package.js';
import ArrayPoly from '../polyfill/arrayPoly.js';
import ConsolePoly from '../polyfill/consolePoly.js';
import ExpectedError from '../types/expectedError.js';
import { ChecksumBitmask } from '../types/files/fileChecksums.js';
import ROMHeader from '../types/files/romHeader.js';
import Internationalization from '../types/internationalization.js';
import Options, {
FixExtension,
GameSubdirMode,
InputChecksumArchivesMode,
MergeMode,
PreferRevision,
} from '../types/options.js';
import PatchFactory from '../types/patches/patchFactory.js';
/**
* Parse a {@link process.argv} (without its first two arguments, the Node.js executable and the
* script name) and return a validated {@link Options} object.
*
* This class will not be run concurrently with any other class.
*/
export default class ArgumentsParser {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
private static getLastValue<T>(arr: T | T[]): T {
if (Array.isArray(arr) && arr.length > 0) {
return arr.at(-1) as T;
}
return arr as T;
}
private static readRegexFile(value: string | string[]): string {
const lastValue = ArgumentsParser.getLastValue(value);
if (fs.existsSync(lastValue)) {
return fs.readFileSync(lastValue).toString();
}
return lastValue;
}
private static getHelpWidth(argv: string[]): number {
// Look for --help/-h with a numerical value
for (let i = 0; i < argv.length; i += 1) {
if (argv[i].toLowerCase() === '--help' || argv[i].toLowerCase() === '-h') {
const helpFlagVal = Number.parseInt(argv[i + 1], 10);
if (!Number.isNaN(helpFlagVal)) {
return Number.parseInt(argv[i + 1], 10);
}
}
}
return Math.min(
// Use the terminal width if it has one
ConsolePoly.consoleWidth(),
// Sane maximum
110,
);
}
/**
* Parse the arguments.
*/
parse(argv: string[]): Options {
const groupRomInput = 'ROM input options:';
const groupDatInput = 'DAT input options:';
const groupPatchInput = 'Patch input options:';
const groupRomOutputPath = 'ROM output path options (processed in order):';
const groupRomOutput = 'ROM writing options:';
const groupRomClean = 'clean command options:';
const groupRomZip = 'zip command options:';
const groupRomLink = 'link command options:';
const groupRomHeader = 'ROM header options:';
const groupRomSet = 'ROM set options (requires DATs):';
const groupRomFiltering = 'ROM filtering options:';
const groupRomPriority = 'One game, one ROM (1G1R) options:';
const groupReport = 'report command options:';
const groupHelpDebug = 'Help & debug options:';
// Add every command to a yargs object, recursively, resulting in the ability to specify
// multiple commands
const commands = [
['copy', 'Copy ROM files from the input to output directory'],
['move', 'Move ROM files from the input to output directory'],
['link', 'Create links in the output directory to ROM files in the input directory'],
['extract', 'Extract ROM files in archives when copying or moving'],
['zip', 'Create zip archives of ROMs when copying or moving'],
['test', 'Test ROMs for accuracy after writing them to the output directory'],
['dir2dat', 'Generate a DAT from all input files'],
['fixdat', 'Generate a fixdat of any missing games for every DAT processed (requires --dat)'],
['clean', 'Recycle unknown files in the output directory'],
[
'report',
'Generate a CSV report on the known & unknown ROM files found in the input directories (requires --dat)',
],
];
const mutuallyExclusiveCommands = [
// Write commands
['copy', 'move', 'link'],
// Archive manipulation commands
['link', 'extract', 'zip'],
// DAT writing commands
['dir2dat', 'fixdat'],
];
const addCommands = (yargsObj: Argv, previousCommands: string[] = []): Argv => {
commands
// Don't allow/show duplicate commands, i.e. don't give `igir copy copy` as an option
.filter(([command]) => !previousCommands.includes(command))
// Don't allow/show conflicting commands, i.e. don't give `igir copy move` as an option
.filter(([command]) => {
const incompatibleCommands = previousCommands.flatMap((previousCommand) =>
mutuallyExclusiveCommands
.filter((mutuallyExclusive) => mutuallyExclusive.includes(previousCommand))
.flat(),
);
return !incompatibleCommands.includes(command);
})
.forEach(([command, description]) => {
yargsObj.command(command, description, (yargsSubObj) =>
addCommands(yargsSubObj, [...previousCommands, command]),
);
});
if (previousCommands.length === 0) {
// Only register the check function once
return yargsObj;
}
return yargsObj
.middleware((middlewareArgv) => {
// Ignore duplicate commands
// eslint-disable-next-line no-param-reassign
middlewareArgv._ = middlewareArgv._.reduce(ArrayPoly.reduceUnique(), []);
}, true)
.check((checkArgv) => {
['extract', 'zip'].forEach((command) => {
if (
checkArgv._.includes(command) &&
['copy', 'move'].every((write) => !checkArgv._.includes(write))
) {
throw new ExpectedError(
`Command "${command}" also requires the commands copy or move`,
);
}
});
['test', 'clean'].forEach((command) => {
if (
checkArgv._.includes(command) &&
['copy', 'move', 'link'].every((write) => !checkArgv._.includes(write))
) {
throw new ExpectedError(
`Command "${command}" requires one of the commands: copy, move, or link`,
);
}
});
return true;
});
};
const yargsParser = yargs([])
.parserConfiguration({
'boolean-negation': false,
})
.locale('en')
.scriptName(Package.NAME)
.usage('Usage: $0 [commands..] [options]')
.updateStrings({
'Commands:': 'Commands (can specify multiple):',
});
addCommands(yargsParser)
.demandCommand(1, 'You must specify at least one command')
.strictCommands(true);
yargsParser
.option('input', {
group: groupRomInput,
alias: 'i',
description: 'Path(s) to ROM files or archives (supports globbing)',
type: 'array',
requiresArg: true,
})
.check((checkArgv) => {
const needInput = [
'copy',
'move',
'link',
'extract',
'zip',
'test',
'dir2dat',
'fixdat',
].filter((command) => checkArgv._.includes(command));
if (!checkArgv.input && needInput.length > 0) {
// TODO(cememr): print help message
throw new ExpectedError(
`Missing required argument for command${needInput.length !== 1 ? 's' : ''} ${needInput.join(', ')}: --input <path>`,
);
}
return true;
})
.option('input-exclude', {
group: groupRomInput,
alias: 'I',
description:
'Path(s) to ROM files or archives to exclude from processing (supports globbing)',
type: 'array',
requiresArg: true,
})
.option('input-checksum-quick', {
group: groupRomInput,
description: "Only read checksums from archive headers, don't decompress to calculate",
type: 'boolean',
})
.check((checkArgv) => {
// Re-implement `conflicts: 'input-checksum-min'`, which isn't possible with a default value
if (
checkArgv['input-checksum-quick'] &&
checkArgv['input-checksum-min'] !== ChecksumBitmask[ChecksumBitmask.CRC32].toUpperCase()
) {
throw new ExpectedError(
'Arguments input-checksum-quick and input-checksum-min are mutually exclusive',
);
}
if (checkArgv['input-checksum-quick'] && checkArgv['input-checksum-max']) {
throw new ExpectedError(
'Arguments input-checksum-quick and input-checksum-max are mutually exclusive',
);
}
return true;
})
.option('input-checksum-min', {
group: groupRomInput,
description: 'The minimum checksum level to calculate and use for matching',
choices: Object.keys(ChecksumBitmask)
.filter((bitmask) => Number.isNaN(Number(bitmask)))
.filter((bitmask) => ChecksumBitmask[bitmask as keyof typeof ChecksumBitmask] > 0)
.map((bitmask) => bitmask.toUpperCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: ChecksumBitmask[ChecksumBitmask.CRC32].toUpperCase(),
})
.option('input-checksum-max', {
group: groupRomInput,
description: 'The maximum checksum level to calculate and use for matching',
choices: Object.keys(ChecksumBitmask)
.filter((bitmask) => Number.isNaN(Number(bitmask)))
.filter((bitmask) => ChecksumBitmask[bitmask as keyof typeof ChecksumBitmask] > 0)
.map((bitmask) => bitmask.toUpperCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.check((checkArgv) => {
const options = Options.fromObject(checkArgv);
const inputChecksumMin = options.getInputChecksumMin();
const inputChecksumMax = options.getInputChecksumMax();
if (
inputChecksumMin !== undefined &&
inputChecksumMax !== undefined &&
inputChecksumMin > inputChecksumMax
) {
throw new ExpectedError(
'Invalid --input-checksum-min & --input-checksum-max, the min must be less than the max',
);
}
return true;
})
.option('input-checksum-archives', {
group: groupRomInput,
description:
'Calculate checksums of archive files themselves, allowing them to match files in DATs',
choices: Object.keys(InputChecksumArchivesMode)
.filter((mode) => Number.isNaN(Number(mode)))
.map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: InputChecksumArchivesMode[InputChecksumArchivesMode.AUTO].toLowerCase(),
})
.option('dat', {
group: groupDatInput,
alias: 'd',
description: 'Path(s) to DAT files or archives (supports globbing)',
type: 'array',
requiresArg: true,
})
.check((checkArgv) => {
if (checkArgv.help) {
return true;
}
if (checkArgv.dat && checkArgv.dat.length > 0 && checkArgv._.includes('dir2dat')) {
throw new ExpectedError('Argument "--dat" cannot be used with the command "dir2dat"');
}
return true;
})
.option('dat-exclude', {
group: groupDatInput,
description:
'Path(s) to DAT files or archives to exclude from processing (supports globbing)',
type: 'array',
requiresArg: true,
})
.option('dat-name-regex', {
group: groupDatInput,
description: 'Regular expression of DAT names to process',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('dat-name-regex-exclude', {
group: groupDatInput,
description: 'Regular expression of DAT names to exclude from processing',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('dat-description-regex', {
group: groupDatInput,
description: 'Regular expression of DAT descriptions to process',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('dat-description-regex-exclude', {
group: groupDatInput,
description: 'Regular expression of DAT descriptions to exclude from processing',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('dat-combine', {
group: groupDatInput,
description: 'Combine every game from every found & filtered DAT into one DAT',
type: 'boolean',
})
.option('dat-ignore-parent-clone', {
group: groupDatInput,
description: 'Ignore any parent/clone information found in DATs',
type: 'boolean',
implies: 'dat',
})
.check((checkArgv) => {
if (checkArgv.help) {
return true;
}
const needDat = ['report'].filter((command) => checkArgv._.includes(command));
if ((!checkArgv.dat || checkArgv.dat.length === 0) && needDat.length > 0) {
throw new ExpectedError(
`Missing required argument for commands ${needDat.join(', ')}: --dat`,
);
}
return true;
})
.option('patch', {
group: groupPatchInput,
alias: 'p',
description: `Path(s) to ROM patch files or archives (supports globbing) (supported: ${PatchFactory.getSupportedExtensions().join(', ')})`,
type: 'array',
requiresArg: true,
})
.option('patch-exclude', {
group: groupPatchInput,
alias: 'P',
description:
'Path(s) to ROM patch files or archives to exclude from processing (supports globbing)',
type: 'array',
requiresArg: true,
})
.option('output', {
group: groupRomOutputPath,
alias: 'o',
description: 'Path to the ROM output directory (supports replaceable symbols, see below)',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.option('dir-mirror', {
group: groupRomOutputPath,
description: 'Use the input subdirectory structure for the output directory',
type: 'boolean',
})
.option('dir-dat-name', {
group: groupRomOutputPath,
alias: 'D',
description: 'Use the DAT name as the output subdirectory',
type: 'boolean',
implies: 'dat',
})
.option('dir-dat-description', {
group: groupRomOutputPath,
description: 'Use the DAT description as the output subdirectory',
type: 'boolean',
implies: 'dat',
})
.option('dir-letter', {
group: groupRomOutputPath,
description:
'Group games in an output subdirectory by the first --dir-letter-count letters in their name',
type: 'boolean',
})
.option('dir-letter-count', {
group: groupRomOutputPath,
description: 'How many game name letters to use for the subdirectory name',
type: 'number',
coerce: (val: number) => Math.max(ArgumentsParser.getLastValue(val), 1),
requiresArg: true,
default: 1,
})
.check((checkArgv) => {
// Re-implement `implies: 'dir-letter'`, which isn't possible with a default value
if (checkArgv['dir-letter-count'] > 1 && !checkArgv['dir-letter']) {
throw new ExpectedError('Missing dependent arguments:\n dir-letter-count -> dir-letter');
}
return true;
})
.option('dir-letter-limit', {
group: groupRomOutputPath,
description:
'Limit the number of games in letter subdirectories, splitting into multiple subdirectories if necessary',
type: 'number',
coerce: (val: number) => Math.max(ArgumentsParser.getLastValue(val), 1),
requiresArg: true,
implies: 'dir-letter',
})
.option('dir-letter-group', {
group: groupRomOutputPath,
description:
'Group letter subdirectories into ranges, combining multiple letters together (requires --dir-letter-limit)',
type: 'boolean',
implies: 'dir-letter-limit',
})
.option('dir-game-subdir', {
group: groupRomOutputPath,
description: 'Append the name of the game as an output subdirectory depending on its ROMs',
choices: Object.keys(GameSubdirMode)
.filter((mode) => Number.isNaN(Number(mode)))
.map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: GameSubdirMode[GameSubdirMode.MULTIPLE].toLowerCase(),
})
.option('fix-extension', {
group: groupRomOutput,
description:
'Read files for known signatures and use the correct extension (also affects dir2dat)',
choices: Object.keys(FixExtension)
.filter((mode) => Number.isNaN(Number(mode)))
.map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: FixExtension[FixExtension.AUTO].toLowerCase(),
})
.option('overwrite', {
group: groupRomOutput,
alias: 'O',
description: 'Overwrite any files in the output directory',
type: 'boolean',
})
.option('overwrite-invalid', {
group: groupRomOutput,
description:
'Overwrite files in the output directory that are the wrong filesize, checksum, or zip contents',
type: 'boolean',
})
.check((checkArgv) => {
const needOutput = ['copy', 'move', 'link', 'extract', 'zip', 'clean'].filter((command) =>
checkArgv._.includes(command),
);
if (!checkArgv.output && needOutput.length > 0) {
// TODO(cememr): print help message
throw new ExpectedError(
`Missing required argument for command${needOutput.length !== 1 ? 's' : ''} ${needOutput.join(', ')}: --output <path>`,
);
}
return true;
})
.option('clean-exclude', {
group: groupRomClean,
alias: 'C',
description: 'Path(s) to files to exclude from cleaning (supports globbing)',
type: 'array',
requiresArg: true,
})
.option('clean-backup', {
group: groupRomClean,
description: 'Move cleaned files to a directory for backup',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.option('clean-dry-run', {
group: groupRomClean,
description: "Don't clean any files and instead only print what files would be cleaned",
type: 'boolean',
})
.check((checkArgv) => {
const needClean = ['clean-exclude', 'clean-backup', 'clean-dry-run'].filter(
(option) => checkArgv[option],
);
if (!checkArgv._.includes('clean') && needClean.length > 0) {
// TODO(cememr): print help message
throw new ExpectedError(
`Missing required command for option${needClean.length !== 1 ? 's' : ''} ${needClean.join(', ')}: clean`,
);
}
return true;
})
.option('zip-exclude', {
group: groupRomZip,
alias: 'Z',
description: 'Glob pattern of ROM filenames to exclude from zipping',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.option('zip-dat-name', {
group: groupRomZip,
description:
'Group all ROMs from the same DAT into the same zip archive, if not excluded from zipping (enforces --dat-threads 1)',
type: 'boolean',
})
.check((checkArgv) => {
const needZip = ['zip-exclude', 'zip-dat-name'].filter((option) => checkArgv[option]);
if (!checkArgv._.includes('zip') && needZip.length > 0) {
throw new ExpectedError(
`Missing required command for option${needZip.length !== 1 ? 's' : ''} ${needZip.join(', ')}: zip`,
);
}
return true;
})
.option('symlink', {
group: groupRomLink,
description: 'Creates symbolic links instead of hard links',
type: 'boolean',
})
.option('symlink-relative', {
group: groupRomLink,
description: 'Create symlinks as relative to the target path, as opposed to absolute',
type: 'boolean',
implies: 'symlink',
})
.check((checkArgv) => {
const needLinkCommand = ['symlink'].filter((option) => checkArgv[option]);
if (!checkArgv._.includes('link') && needLinkCommand.length > 0) {
throw new ExpectedError(
`Missing required command for option${needLinkCommand.length !== 1 ? 's' : ''} ${needLinkCommand.join(', ')}: link`,
);
}
return true;
})
.option('header', {
group: groupRomHeader,
description: 'Glob pattern of input filenames to force header processing for',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.option('remove-headers', {
group: groupRomHeader,
alias: 'H',
description: `Remove known headers from ROMs, optionally limited to a list of comma-separated file extensions (supported: ${ROMHeader.getSupportedExtensions().join(', ')})`,
type: 'string',
coerce: (vals: string) =>
vals.split(',').map((val) => {
if (val === '') {
// Flag was provided without any extensions
return val;
}
return `.${val.replace(/^\.+/, '')}`;
}),
})
.option('merge-roms', {
group: groupRomSet,
description: 'ROM merge/split mode (requires DATs with parent/clone information)',
choices: Object.keys(MergeMode)
.filter((mode) => Number.isNaN(Number(mode)))
.map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: MergeMode[MergeMode.FULLNONMERGED].toLowerCase(),
})
.check((checkArgv) => {
// Re-implement `implies: 'dat'`, which isn't possible with a default value
if (
checkArgv['merge-roms'] !== MergeMode[MergeMode.FULLNONMERGED].toLowerCase() &&
!checkArgv.dat
) {
throw new ExpectedError('Missing dependent arguments:\n merge-roms -> dat');
}
return true;
})
.option('exclude-disks', {
group: groupRomSet,
description: 'Exclude CHD disks in DATs from processing & writing',
type: 'boolean',
implies: 'dat',
})
.option('allow-excess-sets', {
group: groupRomSet,
description: 'Allow writing archives that have excess files when not extracting or zipping',
type: 'boolean',
implies: 'dat',
})
.option('allow-incomplete-sets', {
group: groupRomSet,
description: "Allow writing games that don't have all of their ROMs",
type: 'boolean',
implies: 'dat',
})
.option('filter-regex', {
group: groupRomFiltering,
alias: 'x',
description: 'Regular expression of game names to filter to',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('filter-regex-exclude', {
group: groupRomFiltering,
alias: 'X',
description: 'Regular expression of game names to exclude',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
})
.option('filter-language', {
group: groupRomFiltering,
alias: 'L',
description: `List of comma-separated languages to filter to (supported: ${Internationalization.LANGUAGES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.toUpperCase().split(','),
requiresArg: true,
})
.check((checkArgv) => {
const invalidLangs = checkArgv['filter-language']?.filter(
(lang) => !Internationalization.LANGUAGES.includes(lang),
);
if (invalidLangs !== undefined && invalidLangs.length > 0) {
throw new ExpectedError(
`Invalid --filter-language language${invalidLangs.length !== 1 ? 's' : ''}: ${invalidLangs.join(', ')}`,
);
}
return true;
})
.option('filter-region', {
group: groupRomFiltering,
alias: 'R',
description: `List of comma-separated regions to filter to (supported: ${Internationalization.REGION_CODES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.toUpperCase().split(','),
requiresArg: true,
})
.check((checkArgv) => {
const invalidRegions = checkArgv['filter-region']?.filter(
(lang) => !Internationalization.REGION_CODES.includes(lang),
);
if (invalidRegions !== undefined && invalidRegions.length > 0) {
throw new ExpectedError(
`Invalid --filter-region region${invalidRegions.length !== 1 ? 's' : ''}: ${invalidRegions.join(', ')}`,
);
}
return true;
});
[
['bios', 'BIOS files'],
['device', 'MAME devies'],
['unlicensed', 'unlicensed ROMs'],
].forEach(([key, description]) => {
yargsParser
.option(`no-${key}`, {
group: groupRomFiltering,
description: `Filter out ${description}, opposite of --only-${key}`,
type: 'boolean',
conflicts: [`only-${key}`],
})
.option(`only-${key}`, {
type: 'boolean',
conflicts: [`no-${key}`],
hidden: true,
});
});
yargsParser.option('only-retail', {
group: groupRomFiltering,
description: 'Filter to only retail releases, enabling all the following "no" options',
type: 'boolean',
});
[
['debug', 'debug ROMs'],
['demo', 'demo ROMs'],
['beta', 'beta ROMs'],
['sample', 'sample ROMs'],
['prototype', 'prototype ROMs'],
['program', 'program application ROMs'],
['aftermarket', 'aftermarket ROMs'],
['homebrew', 'homebrew ROMs'],
['unverified', 'unverified ROMs'],
['bad', 'bad ROM dumps'],
].forEach(([key, description]) => {
yargsParser
.option(`no-${key}`, {
group: groupRomFiltering,
description: `Filter out ${description}, opposite of --only-${key}`,
type: 'boolean',
conflicts: [`only-${key}`],
})
.option(`only-${key}`, {
type: 'boolean',
conflicts: [`no-${key}`],
hidden: true,
});
});
yargsParser
.option('single', {
group: groupRomPriority,
alias: 's',
description:
'Output only a single game per parent (1G1R) (required for all options below, requires DATs with parent/clone information)',
type: 'boolean',
})
.option('prefer-game-regex', {
group: groupRomPriority,
description: 'Regular expression of game names to prefer',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
implies: 'single',
})
.option('prefer-rom-regex', {
group: groupRomPriority,
description: 'Regular expression of ROM filenames to prefer',
type: 'string',
coerce: ArgumentsParser.readRegexFile,
requiresArg: true,
implies: 'single',
})
.option('prefer-verified', {
group: groupRomPriority,
description: 'Prefer verified ROM dumps over unverified',
type: 'boolean',
implies: 'single',
})
.option('prefer-good', {
group: groupRomPriority,
description: 'Prefer good ROM dumps over bad',
type: 'boolean',
implies: 'single',
})
.option('prefer-language', {
group: groupRomPriority,
alias: 'l',
description: `List of comma-separated languages in priority order (supported: ${Internationalization.LANGUAGES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.toUpperCase().split(','),
requiresArg: true,
implies: 'single',
})
.check((checkArgv) => {
const invalidLangs = checkArgv['prefer-language']?.filter(
(lang) => !Internationalization.LANGUAGES.includes(lang),
);
if (invalidLangs !== undefined && invalidLangs.length > 0) {
throw new ExpectedError(
`Invalid --prefer-language language${invalidLangs.length !== 1 ? 's' : ''}: ${invalidLangs.join(', ')}`,
);
}
return true;
})
.option('prefer-region', {
group: groupRomPriority,
alias: 'r',
description: `List of comma-separated regions in priority order (supported: ${Internationalization.REGION_CODES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.toUpperCase().split(','),
requiresArg: true,
implies: 'single',
})
.check((checkArgv) => {
const invalidRegions = checkArgv['prefer-region']?.filter(
(lang) => !Internationalization.REGION_CODES.includes(lang),
);
if (invalidRegions !== undefined && invalidRegions.length > 0) {
throw new ExpectedError(
`Invalid --prefer-region region${invalidRegions.length !== 1 ? 's' : ''}: ${invalidRegions.join(', ')}`,
);
}
return true;
})
.option('prefer-revision', {
group: groupRomPriority,
description: 'Prefer older or newer revisions, versions, or ring codes',
choices: Object.keys(PreferRevision)
.filter((mode) => Number.isNaN(Number(mode)))
.map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
implies: 'single',
})
.option('prefer-retail', {
group: groupRomPriority,
description: 'Prefer retail releases (see --only-retail)',
type: 'boolean',
implies: 'single',
})
.option('prefer-parent', {
group: groupRomPriority,
description: 'Prefer parent ROMs over clones',
type: 'boolean',
implies: 'single',
})
.option('report-output', {
group: groupReport,
description: 'Report output location (formatted with moment.js)',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
default: `./${Package.NAME}_%YYYY-%MM-%DDT%HH:%mm:%ss.csv`,
})
.option('dat-threads', {
group: groupHelpDebug,
description: 'Number of DATs to process in parallel',
type: 'number',
coerce: (val: number) => Math.max(val, 1),
requiresArg: true,
default: Defaults.DAT_DEFAULT_THREADS,
})
.option('reader-threads', {
group: groupHelpDebug,
description: 'Maximum number of ROMs to read in parallel per disk',
type: 'number',
coerce: (val: number) => Math.max(val, 1),
requiresArg: true,
default: Defaults.FILE_READER_DEFAULT_THREADS,
})
.option('writer-threads', {
group: groupHelpDebug,
description: 'Maximum number of ROMs to write in parallel',
type: 'number',
coerce: (val: number) => Math.max(val, 1),
requiresArg: true,
default: Defaults.ROM_WRITER_DEFAULT_THREADS,
})
.middleware((middlewareArgv) => {
if (middlewareArgv.zipDatName) {
// eslint-disable-next-line no-param-reassign
middlewareArgv.datThreads = 1;
}
}, true)
.option('write-retry', {
group: groupHelpDebug,
description:
'Number of additional retries to attempt when writing a file has failed (0 disables retries)',
type: 'number',
coerce: (val: number) => Math.max(val, 0),
requiresArg: true,
default: Defaults.ROM_WRITER_ADDITIONAL_RETRIES,
})
.options('temp-dir', {
group: groupHelpDebug,
description: 'Path to a directory for temporary files',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
})
.option('disable-cache', {
group: groupHelpDebug,
description: 'Disable loading or saving the cache file',
type: 'boolean',
})
.option('cache-path', {
group: groupHelpDebug,
description: 'Location for the cache file',
type: 'string',
coerce: ArgumentsParser.getLastValue, // don't allow string[] values
requiresArg: true,
conflicts: ['disable-cache'],
})
.option('verbose', {
group: groupHelpDebug,
alias: 'v',
description: 'Enable verbose logging, can specify up to three times (-vvv)',
type: 'count',
})
.middleware((middlewareArgv) => {
if (middlewareArgv['clean-dry-run'] === true && (middlewareArgv.verbose ?? 0) < 1) {
this.logger.warn(
'--clean-dry-run prints INFO logs for files skipped, enable them with -v',
);
}
})
.check((checkArgv) => {
if (
checkArgv.mergeRoms !== MergeMode[MergeMode.FULLNONMERGED].toLowerCase() &&
(checkArgv.dirMirror || checkArgv.dirLetter)
) {
this.logger.warn(
`at least one --dir-* option was provided, be careful about how you organize non-'${MergeMode[MergeMode.FULLNONMERGED].toLowerCase()}' ROM sets into different subdirectories`,
);
}
if (
checkArgv.mergeRoms !== MergeMode[MergeMode.FULLNONMERGED].toLowerCase() &&
(checkArgv.noBios || checkArgv.noDevice)
) {
this.logger.warn(
`--no-bios and --no-device may leave non-'${MergeMode[MergeMode.FULLNONMERGED].toLowerCase()}' ROM sets in an unplayable state`,
);
}
if (
checkArgv.single &&
!checkArgv.preferParent &&
checkArgv.mergeRoms === MergeMode[MergeMode.SPLIT].toLowerCase()
) {
this.logger.warn(
`--single may leave '${MergeMode[MergeMode.SPLIT].toLowerCase()}' ROM sets in an unplayable state`,
);
}
return true;
})
.wrap(ArgumentsParser.getHelpWidth(argv))
.version(false)
// NOTE(cemmer): the .epilogue() renders after .example() but I want them switched
.epilogue(
`${'-'.repeat(ArgumentsParser.getHelpWidth(argv))}
Advanced usage:
Tokens that are replaced when generating the output (--output) path of a ROM:
{datName} The name of the DAT that contains the ROM (e.g. "Nintendo - Game Boy")
{datDescription} The description of the DAT that contains the ROM
{region} The region of the ROM release (e.g. "USA"), each ROM can have multiple
{language} The language of the ROM release (e.g. "En"), each ROM can have multiple
{type} The type of the game (e.g. "Retail", "Demo", "Prototype")
{genre} The DAT-defined genre of the game
{inputDirname} The input file's dirname
{outputBasename} Equivalent to "{outputName}.{outputExt}"
{outputName} The output file's filename without extension
{outputExt} The output file's extension
{adam} The ROM's emulator-specific /ROMS/* directory for the 'Adam' image (e.g. "GB")
{batocera} The ROM's emulator-specific /roms/* directory for Batocera (e.g. "gb")
{es} The ROM's emulator-specific /roms/* directory for the 'EmulationStation' image (e.g. "gb")
{funkeyos} The ROM's emulator-specific /* directory for FunKey OS (e.g. "Game Boy")
{jelos} The ROM's emulator-specific /roms/* directory for JELOS (e.g. "gb")
{minui} The ROM's emulator-specific /Roms/* directory for MinUI (e.g. "Game Boy (GB)")
{mister} The ROM's core-specific /games/* directory for the MiSTer FPGA (e.g. "Gameboy")
{miyoocfw} The ROM's emulator-specific /roms/* directory for MiyooCFW (e.g. "GB")
{onion} The ROM's emulator-specific /Roms/* directory for OnionOS/GarlicOS (e.g. "GB")
{pocket} The ROM's core-specific /Assets/* directory for the Analogue Pocket (e.g. "gb")
{retrodeck} The ROM's emulator-specific /roms/* directory for the 'RetroDECK' image (e.g. "gb")
{romm} The ROM's manager-specific /roms/* directory for 'RomM' (e.g. "gb")
{twmenu} The ROM's emulator-specific /roms/* directory for TWiLightMenu++ on the DSi/3DS (e.g. "gb")
Example use cases:
Merge new ROMs into an existing ROM collection and delete any unrecognized files:
$0 copy clean --dat "*.dat" --input New-ROMs/ --input ROMs/ --output ROMs/
Organize and zip an existing ROM collection:
$0 move zip --dat "*.dat" --input ROMs/ --output ROMs/
Generate a report on an existing ROM collection, without copying or moving ROMs (read only):
$0 report --dat "*.dat" --input ROMs/
Produce a 1G1R set per console, preferring English ROMs from USA>WORLD>EUR>JPN:
$0 copy --dat "*.dat" --input "**/*.zip" --output 1G1R/ --dir-dat-name --single --prefer-language EN --prefer-region USA,WORLD,EUR,JPN
Copy all Mario, Metroid, and Zelda games to one directory:
$0 copy --input ROMs/ --output Nintendo/ --filter-regex "/(Mario|Metroid|Zelda)/i"
Copy all BIOS files into one directory, extracting if necessary:
$0 copy extract --dat "*.dat" --input "**/*.zip" --output BIOS/ --only-bios
Create patched copies of ROMs in an existing collection, not overwriting existing files:
$0 copy extract --input ROMs/ --patch Patches/ --output ROMs/
Re-build a MAME ROM set for a specific version of MAME:
$0 copy zip --dat "MAME 0.258.dat" --input MAME/ --output MAME-0.258/ --merge-roms split
Copy ROMs to an Analogue Pocket and test they were written correctly:
$0 copy extract test --dat "*.dat" --input ROMs/ --output /Assets/{pocket}/common/ --dir-letter`,
)
// Colorize help output
.option('help', {
group: groupHelpDebug,
alias: 'h',
description: 'Show help',
type: 'boolean',
})
.fail((msg, err, _yargs) => {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (err) {
throw err;
}
this.logger.colorizeYargs(`${_yargs.help().toString().trimEnd()}\n`);
throw new ExpectedError(msg);
});
const yargsArgv = yargsParser.strictOptions(true).parse(argv, {}, (err, parsedArgv, output) => {
if (output) {
this.logger.colorizeYargs(`${output.trimEnd()}\n`);
}
});
return Options.fromObject(yargsArgv);
}
}