UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

1,010 lines (994 loc) • 47.9 kB
import fs from 'node:fs'; import yargs from 'yargs'; 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 IgirException from '../types/exceptions/igirException.js'; import { ChecksumBitmask, ChecksumBitmaskInverted } from '../types/files/fileChecksums.js'; import ROMHeader from '../types/files/romHeader.js'; import Internationalization from '../types/internationalization.js'; import Options, { FixExtension, FixExtensionInverted, GameSubdirMode, GameSubdirModeInverted, InputChecksumArchivesMode, InputChecksumArchivesModeInverted, LinkMode, LinkModeInverted, MergeMode, MergeModeInverted, MoveDeleteDirs, MoveDeleteDirsInverted, PreferRevision, ZipFormat, ZipFormatInverted, } 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 { logger; constructor(logger) { this.logger = logger; } static getLastValue(arr) { if (Array.isArray(arr) && arr.length > 0) { return arr.at(-1); } return arr; } static readRegexFile(value) { const lastValue = ArgumentsParser.getLastValue(value); if (fs.existsSync(lastValue)) { return fs.readFileSync(lastValue).toString(); } return lastValue; } static getHelpWidth(argv) { // 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) { 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 groupRomMove = 'move command options:'; const groupRomClean = 'clean command options:'; const groupRomZip = 'zip command options:'; const groupRomLink = 'link command options:'; const groupRomHeader = 'ROM header options:'; const groupRomTrimmed = 'Trimmed ROM options:'; const groupRomSet = 'ROM set options (requires DATs):'; const groupRomFiltering = 'ROM filtering options:'; const groupRomPriority = 'One game, one ROM (1G1R) options:'; const groupPlaylist = 'playlist command options:'; const groupDir2Dat = 'dir2dat command options:'; const groupFixdat = 'fixdat command 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'], ['playlist', 'Create playlist files for multi-disc games'], ['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, previousCommands = []) => { commands .filter(([command]) => { // Don't allow/show duplicate commands, i.e. don't give `igir copy copy` as an option if (previousCommands.includes(command)) { return false; } // Don't allow/show conflicting commands, i.e. don't give `igir copy move` as an option 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 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 IgirException(`Command "${command}" also requires the commands copy or move`); } }); ['clean'].forEach((command) => { if (checkArgv._.includes(command) && ['copy', 'move', 'link'].every((write) => !checkArgv._.includes(write))) { throw new IgirException(`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'].filter((command) => checkArgv._.includes(command)); if (!checkArgv.input && needInput.length > 0) { // TODO(cememr): print help message throw new IgirException(`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'] !== ChecksumBitmaskInverted[ChecksumBitmask.CRC32].toUpperCase()) { throw new IgirException('Arguments input-checksum-quick and input-checksum-min are mutually exclusive'); } if (checkArgv['input-checksum-quick'] && checkArgv['input-checksum-max']) { throw new IgirException('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.values(ChecksumBitmask) .filter((bitmask) => bitmask !== ChecksumBitmask.NONE) .map((bitmask) => ChecksumBitmaskInverted[bitmask].toUpperCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: ChecksumBitmaskInverted[ChecksumBitmask.CRC32].toUpperCase(), }) .option('input-checksum-max', { group: groupRomInput, description: 'The maximum checksum level to calculate and use for matching', choices: Object.values(ChecksumBitmask) .filter((bitmask) => bitmask !== ChecksumBitmask.NONE) .map((bitmask) => ChecksumBitmaskInverted[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 IgirException('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).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: InputChecksumArchivesModeInverted[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 IgirException('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 IgirException(`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', conflicts: ['dir-dat-mirror'], }) .option('dir-dat-mirror', { group: groupRomOutputPath, description: 'Use the DAT subdirectory structure for the output directory', type: 'boolean', implies: 'dat', conflicts: ['dir-mirror'], }) .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) => 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 IgirException('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) => 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).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: GameSubdirModeInverted[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).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: FixExtensionInverted[FixExtension.AUTO].toLowerCase(), }) .option('overwrite', { group: groupRomOutput, alias: 'O', description: 'Overwrite any files in the output directory', type: 'boolean', conflicts: ['overwrite-invalid'], }) .option('overwrite-invalid', { group: groupRomOutput, description: 'Overwrite files in the output directory that are the wrong filesize, checksum, or zip contents', type: 'boolean', conflicts: ['overwrite'], }) .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 IgirException(`Missing required argument for command${needOutput.length === 1 ? '' : 's'} ${needOutput.join(', ')}: --output <path>`); } return true; }) .option('move-delete-dirs', { group: groupRomMove, description: 'Delete empty subdirectories from the input directories after moving ROMs', choices: Object.keys(MoveDeleteDirs).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: MoveDeleteDirsInverted[MoveDeleteDirs.AUTO].toLowerCase(), }) .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: 'Directory to move cleaned files to (instead of being recycled)', 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] !== undefined); if (!checkArgv._.includes('clean') && needClean.length > 0) { // TODO(cememr): print help message throw new IgirException(`Missing required command for option${needClean.length === 1 ? '' : 's'} ${needClean.join(', ')}: clean`); } return true; }) .option('zip-format', { group: groupRomZip, description: 'The structure format to use for written zip files', choices: Object.keys(ZipFormat).map((format) => format.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: ZipFormatInverted[ZipFormat.TORRENTZIP].toLowerCase(), }) .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] !== undefined); if (!checkArgv._.includes('zip') && needZip.length > 0) { throw new IgirException(`Missing required command for option${needZip.length === 1 ? '' : 's'} ${needZip.join(', ')}: zip`); } return true; }) .option('link-mode', { group: groupRomLink, description: 'File linking mode', choices: Object.keys(LinkMode).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: LinkModeInverted[LinkMode.HARDLINK].toLowerCase(), }) .option('symlink-relative', { group: groupRomLink, description: 'Create symlinks as relative to the target path, as opposed to absolute', type: 'boolean', }) .check((checkArgv) => { const needLinkCommand = ['symlink'].filter((option) => checkArgv[option] !== undefined); if (!checkArgv._.includes('link') && needLinkCommand.length > 0) { throw new IgirException(`Missing required command for option${needLinkCommand.length === 1 ? '' : 's'} ${needLinkCommand.join(', ')}: link`); } if (checkArgv['symlink-relative'] && checkArgv['link-mode'].toLowerCase() !== LinkModeInverted[LinkMode.SYMLINK].toLowerCase()) { throw new IgirException('Missing dependent arguments:\n symlink-relative -> link-mode'); } return true; }) .option('header', { group: groupRomHeader, description: 'Glob pattern of input filenames to force header detection 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) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => { if (val.trim() === '') { // Flag was provided without any extensions return val; } return val.split(',').map((v) => `.${v.trim().replace(/^\.+/, '')}`); }), }) .option('trimmed-glob', { group: groupRomTrimmed, description: 'Glob pattern of input filenames to force trimming detection for', type: 'string', coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, implies: 'dat', }) .option('trim-scan-archives', { group: groupRomTrimmed, description: 'Detect trimming for files within archives (off by default)', type: 'boolean', implies: 'dat', }) .option('merge-roms', { group: groupRomSet, description: 'ROM merge/split mode (requires DATs with parent/clone information)', choices: Object.keys(MergeMode).map((mode) => mode.toLowerCase()), coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, default: MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase(), }) .check((checkArgv) => { // Re-implement `implies: 'dat'`, which isn't possible with a default value if (checkArgv['merge-roms'] !== MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase() && !checkArgv.dat) { throw new IgirException('Missing dependent arguments:\n merge-roms -> dat'); } return true; }) .option('merge-discs', { group: groupRomSet, description: 'Merge multi-disc games into one game', type: 'boolean', }) .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: (vals) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => 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 IgirException(`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: (vals) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => 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 IgirException(`Invalid --filter-region region${invalidRegions.length === 1 ? '' : 's'}: ${invalidRegions.join(', ')}`); } return true; }) .option('filter-category-regex', { group: groupRomFiltering, description: 'Regular expression of categories to filter to', type: 'string', coerce: ArgumentsParser.readRegexFile, requiresArg: true, implies: 'dat', }); [ ['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: (vals) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => 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 IgirException(`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: (vals) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => 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 IgirException(`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).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('playlist-extensions', { group: groupPlaylist, description: 'List of comma-separated file extensions to generate multi-disc playlists for', type: 'string', coerce: (vals) => (Array.isArray(vals) ? vals : [vals]).flatMap((val) => { if (val.trim() === '') { return []; } return val.split(',').map((v) => `.${v.trim().replace(/^\.+/, '')}`); }), requiresArg: true, default: '.cue,.gdi,.mdf,.chd', }) .check((checkArgv) => { if (checkArgv._.includes('playlist') && checkArgv['playlist-extensions'].length === 0) { // TODO(cememr): print help message throw new IgirException(`Missing required argument for command playlist: --playlist-extensions <exts>`); } return true; }) .option('dir2dat-output', { group: groupDir2Dat, description: 'dir2dat output directory', type: 'string', coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, }) .check((checkArgv) => { const needDir2Dat = ['dir2dat-output'].filter((option) => checkArgv[option] !== undefined); if (!checkArgv._.includes('dir2dat') && needDir2Dat.length > 0) { // TODO(cememr): print help message throw new IgirException(`Missing required command for option${needDir2Dat.length === 1 ? '' : 's'} ${needDir2Dat.join(', ')}: dir2dat`); } return true; }) .option('fixdat-output', { group: groupFixdat, description: 'Fixdat output directory', type: 'string', coerce: ArgumentsParser.getLastValue, // don't allow string[] values requiresArg: true, }) .check((checkArgv) => { const needFixdat = ['fixdat-output'].filter((option) => checkArgv[option] !== undefined); if (!checkArgv._.includes('fixdat') && needFixdat.length > 0) { // TODO(cememr): print help message throw new IgirException(`Missing required command for option${needFixdat.length === 1 ? '' : 's'} ${needFixdat.join(', ')}: fixdat`); } return true; }) .option('report-output', { group: groupReport, description: 'Report output file 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) => Math.max(ArgumentsParser.getLastValue(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) => Math.max(ArgumentsParser.getLastValue(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) => Math.max(ArgumentsParser.getLastValue(val), 1), requiresArg: true, default: Defaults.ROM_WRITER_DEFAULT_THREADS, }) .middleware((middlewareArgv) => { if (middlewareArgv.zipDatName) { 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) => Math.max(ArgumentsParser.getLastValue(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 < 1) { this.logger.warn('--clean-dry-run prints INFO logs for files skipped, enable them with -v'); } }) .check((checkArgv) => { if (checkArgv.mergeRoms.toLowerCase() !== MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase() && (checkArgv.dirMirror || checkArgv.dirLetter)) { this.logger.warn(`at least one --dir-* option was provided, be careful about how you organize non-'${MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase()}' ROM sets into different subdirectories`); } if (checkArgv.mergeRoms.toLowerCase() !== MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase() && (checkArgv.noBios || checkArgv.noDevice)) { this.logger.warn(`--no-bios and --no-device may leave non-'${MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase()}' ROM sets in an unplayable state`); } if (checkArgv.single && !checkArgv.preferParent && checkArgv.mergeRoms.toLowerCase() === MergeModeInverted[MergeMode.SPLIT].toLowerCase()) { this.logger.warn(`--single may leave '${MergeModeInverted[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") {category} The DAT-defined category of the game (e.g. "Games", "Demos", "Multimedia") {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 playlist files for all multi-disc games in an existing collection: $0 playlist --input ROMs/ 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,@typescript-eslint/no-unnecessary-condition if (err) { throw err; } this.logger.colorizeYargs(`${_yargs.help().toString().trimEnd()}\n`); throw new IgirException(msg); }); const yargsArgv = yargsParser .strictOptions(true) .parse(argv, {}, (_err, _parsedArgv, output) => { if (output) { this.logger.colorizeYargs(`${output.trimEnd()}\n`); } }); return Options.fromObject(yargsArgv); } }