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,079 lines (1,063 loc) • 47.5 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { logger } from "../console/logger.js";
import IgirException from "../exceptions/igirException.js";
import PatchFactory from "../factories/patchFactory.js";
import Defaults from "../globals/defaults.js";
import Package from "../globals/package.js";
import Internationalization from "../models/dats/internationalization.js";
import { ChecksumBitmask, ChecksumBitmaskInverted } from "../models/files/fileChecksums.js";
import ROMHeader from "../models/files/romHeader.js";
import Options, {
FixExtension,
FixExtensionInverted,
GameSubdirMode,
GameSubdirModeInverted,
InputChecksumArchivesMode,
InputChecksumArchivesModeInverted,
LinkMode,
LinkModeInverted,
MergeMode,
MergeModeInverted,
MoveDeleteDirs,
MoveDeleteDirsInverted,
PlaylistMode,
PlaylistModeInverted,
PreferFiletype,
PreferFiletypeInverted,
PreferRevision,
TrimScanFiles,
TrimScanFilesInverted,
ZipFormat,
ZipFormatInverted
} from "../models/options.js";
import yargs from "../polyfill/yargsShim.js";
import ArrayUtil from "../utils/arrayUtil.js";
import ConsoleUtil from "../utils/consoleUtil.js";
class ArgumentsParser {
static getLastValue(arr) {
if (Array.isArray(arr) && arr.length > 0) {
return arr.at(-1);
}
return arr;
}
static readRegexFile(value) {
const values = Array.isArray(value) ? value : [value];
return values.map((val) => fs.existsSync(val) ? fs.readFileSync(val).toString() : val).join("\n");
}
static getHelpWidth(argv) {
for (let i = 0; i < argv.length; i += 1) {
if (!(argv[i].toLowerCase() === "--help" || argv[i].toLowerCase() === "-h")) {
continue;
}
const helpFlagVal = Math.trunc(Number(argv[i + 1]));
if (!Number.isNaN(helpFlagVal)) {
return helpFlagVal;
}
}
return Math.min(
// Use the terminal width if it has one
ConsoleUtil.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 (applied in order):";
const groupFilePriority = "Input file preferences (applied in order):";
const groupPlaylist = "playlist command options:";
const groupDir2Dat = "dir2dat command options:";
const groupFixdat = "fixdat command options:";
const groupReport = "report command options:";
const groupConcurrency = "Concurrency options:";
const groupDirectory = "Cache & directory options:";
const groupHelpDebug = "Help & debug options:";
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 = []) => {
for (const [command, description] of commands) {
if (previousCommands.includes(command)) {
continue;
}
const incompatibleCommands = previousCommands.flatMap(
(previousCommand) => mutuallyExclusiveCommands.filter((mutuallyExclusive) => mutuallyExclusive.includes(previousCommand)).flat()
);
if (incompatibleCommands.includes(command)) {
continue;
}
yargsObj.command(
command,
description,
(yargsSubObj) => addCommands(yargsSubObj, [...previousCommands, command])
);
}
if (previousCommands.length === 0) {
return yargsObj;
}
return yargsObj.middleware((middlewareArgv) => {
middlewareArgv._ = middlewareArgv._.reduce(ArrayUtil.reduceUnique(), []);
}, true).check((checkArgv) => {
for (const command of ["extract", "zip"]) {
if (checkArgv._.includes(command) && ["copy", "move"].every((write) => !checkArgv._.includes(write))) {
throw new IgirException(
`Command "${command}" also requires the commands copy or move`
);
}
}
for (const command of ["clean"]) {
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
}).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) {
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,
implies: "input"
}).option("input-checksum-quick", {
group: groupRomInput,
description: "Only read checksums from archive headers, don't decompress to calculate",
type: "boolean"
}).check((checkArgv) => {
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"] && checkArgv["input-checksum-max"] !== ChecksumBitmaskInverted[ChecksumBitmask.SHA1].toUpperCase()) {
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.bind(ArgumentsParser),
// 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.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: ChecksumBitmaskInverted[ChecksumBitmask.SHA1].toUpperCase()
}).check((checkArgv) => {
const options = Options.fromObject(checkArgv);
const inputChecksumMin = options.getInputChecksumMin();
const inputChecksumMax = options.getInputChecksumMax();
if (inputChecksumMin !== void 0 && inputChecksumMax !== void 0 && 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.bind(ArgumentsParser),
// 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,
implies: "dat"
}).option("dat-name-regex", {
group: groupDatInput,
description: "Regular expression of DAT names to process",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true
}).option("dat-name-regex-exclude", {
group: groupDatInput,
description: "Regular expression of DAT names to exclude from processing",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true
}).option("dat-description-regex", {
group: groupDatInput,
description: "Regular expression of DAT descriptions to process",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true
}).option("dat-description-regex-exclude", {
group: groupDatInput,
description: "Regular expression of DAT descriptions to exclude from processing",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
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,
implies: "patch"
}).option("patch-only", {
group: groupPatchInput,
description: "Only write patched ROMs to the output directory",
type: "boolean",
implies: "patch"
}).check((checkArgv) => {
const illegalPatchCommands = ["link"].filter((command) => checkArgv._.includes(command));
if (illegalPatchCommands.length > 0) {
const patchOptions = ["patch", "patch-exclude", "patch-only"].filter(
(option) => checkArgv[option]
);
if (patchOptions.length > 0) {
throw new IgirException(
`Argument${patchOptions.length === 1 ? "" : "s"} ${patchOptions.map((opt) => `"${opt}"`).join(", ")} cannot be used with the command${illegalPatchCommands.length === 1 ? "" : "s"} ${illegalPatchCommands.map((cmd) => `"${cmd}"`).join(", ")}`
);
}
}
if (checkArgv.patch && !checkArgv._.includes("extract") && !checkArgv._.includes("zip")) {
logger.warn(
"archived files can't be patched unless the 'extract' or 'zip' command is used"
);
}
return true;
}).option("output", {
group: groupRomOutputPath,
alias: "o",
description: "Path to the ROM output directory (supports replaceable symbols, see below)",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true
}).middleware((middlewareArgv) => {
if (!(middlewareArgv.output && middlewareArgv._.includes("clean") && middlewareArgv.input)) {
return;
}
const outputResolved = path.resolve(middlewareArgv.output);
if (middlewareArgv.input.every(
(inputPath) => !outputResolved.startsWith(path.resolve(inputPath))
)) {
logger.warn(
`'${middlewareArgv.output}' was provided as the output path but not as an input path, the 'clean' command may delete more files than you intended!`
);
}
}, false).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) => {
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 how many ROMs a game has",
choices: Object.keys(GameSubdirMode).map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: GameSubdirModeInverted[GameSubdirMode.MULTIPLE].toLowerCase()
}).option("output-console-tokens", {
group: groupRomOutputPath,
description: "Path to a JSON file of custom console token definitions to use instead of the built-in definitions",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true
}).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.bind(ArgumentsParser),
// 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"]
}).option("write-retry", {
group: groupRomOutput,
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
}).check((checkArgv) => {
const needOutput = ["copy", "move", "link", "extract", "zip", "clean"].filter(
(command) => checkArgv._.includes(command)
);
if (!checkArgv.output && needOutput.length > 0) {
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.bind(ArgumentsParser),
// 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.bind(ArgumentsParser),
// 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] !== void 0
);
if (!checkArgv._.includes("clean") && needClean.length > 0) {
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.bind(ArgumentsParser),
// 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.bind(ArgumentsParser),
// 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] !== void 0
);
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.bind(ArgumentsParser),
// 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] !== void 0);
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.bind(ArgumentsParser),
// 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() === "") {
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 (overriding all options below)",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
implies: "dat"
}).option("trim-scan-files", {
group: groupRomTrimmed,
description: "Detect trimming for uncompressed files",
choices: Object.keys(TrimScanFiles).map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: TrimScanFilesInverted[TrimScanFiles.AUTO].toLowerCase()
}).option("trim-scan-archives", {
group: groupRomTrimmed,
description: "Detect trimming for files within archives (off by default)",
type: "boolean",
implies: "dat"
}).option("trim-add-padding", {
group: groupRomTrimmed,
description: "When writing matched trimmed ROMs, add back their trailing padding so the output matches the DAT checksum",
type: "boolean",
implies: "dat"
}).check((checkArgv) => {
if (!checkArgv["trim-add-padding"]) {
return true;
}
if (checkArgv._.includes("link")) {
throw new IgirException(
'Argument "--trim-add-padding" cannot be used with the command "link"'
);
}
if (["copy", "move"].every((cmd) => !checkArgv._.includes(cmd))) {
throw new IgirException(
"Missing required command for option trim-add-padding: copy or move"
);
}
if (checkArgv["trim-scan-files"] === TrimScanFilesInverted[TrimScanFiles.NEVER].toLowerCase()) {
throw new IgirException(
'Arguments "--trim-add-padding" and "--trim-scan-files never" are mutually exclusive'
);
}
return true;
}).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.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: MergeModeInverted[MergeMode.FULLNONMERGED].toLowerCase()
}).check((checkArgv) => {
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: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true
}).option("filter-regex-exclude", {
group: groupRomFiltering,
alias: "X",
description: "Regular expression of game names to exclude",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
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 !== void 0 && 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 !== void 0 && 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: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true,
implies: "dat"
});
for (const [key, description] of [
["bios", "BIOS files"],
["device", "MAME devies"],
["unlicensed", "unlicensed ROMs"]
]) {
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"
});
for (const [key, description] of [
["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"]
]) {
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 DAT game names to prefer",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true,
implies: "single"
}).option("prefer-rom-regex", {
group: groupRomPriority,
description: "Regular expression of DAT ROM filenames to prefer",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
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 !== void 0 && 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 !== void 0 && 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.bind(ArgumentsParser),
// 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("prefer-filetype", {
group: groupFilePriority,
description: "Prefer input files of a type",
choices: Object.keys(PreferFiletype).map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: PreferFiletypeInverted[PreferFiletype.PLAIN].toLowerCase()
}).option("prefer-filename-regex", {
group: groupFilePriority,
description: "Regular expression of filenames to prefer",
type: "array",
coerce: ArgumentsParser.readRegexFile.bind(ArgumentsParser),
requiresArg: true
}).option("playlist-mode", {
group: groupPlaylist,
description: "Generate playlists depending on how many ROMs a game has",
choices: Object.keys(PlaylistMode).map((mode) => mode.toLowerCase()),
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: PlaylistModeInverted[PlaylistMode.MULTIPLE].toLowerCase()
}).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: ".ccd,.cdi,.chd,.cue,.gdi,.iso,.mdf,.toc"
}).check((checkArgv) => {
if (!checkArgv._.includes("playlist") && checkArgv["playlist-mode"] && checkArgv["playlist-mode"] !== PlaylistModeInverted[PlaylistMode.MULTIPLE].toLowerCase()) {
throw new IgirException(`Missing required command for option playlist-mode: playlist`);
}
if (checkArgv._.includes("playlist") && checkArgv["playlist-extensions"].length === 0) {
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.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true
}).check((checkArgv) => {
const needDir2Dat = ["dir2dat-output"].filter((option) => checkArgv[option] !== void 0);
if (!checkArgv._.includes("dir2dat") && needDir2Dat.length > 0) {
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.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true
}).check((checkArgv) => {
const needFixdat = ["fixdat-output"].filter((option) => checkArgv[option] !== void 0);
if (!checkArgv._.includes("fixdat") && needFixdat.length > 0) {
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 date/time tokens)",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true,
default: `${Package.NAME}_%YYYY-%MM-%DDT%HH:%mm:%ss.csv`
}).option("dat-threads", {
group: groupConcurrency,
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: groupConcurrency,
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: groupConcurrency,
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).options("temp-dir", {
group: groupDirectory,
description: "Path to a directory for temporary files",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: true
}).option("disable-cache", {
group: groupDirectory,
description: "Disable loading or saving the cache file",
type: "boolean"
}).option("cache-path", {
group: groupDirectory,
description: "Location for the cache file",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// 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"
}).option("debug-log", {
group: groupHelpDebug,
description: "Generate a debug log file to attach to bug reports",
type: "string",
coerce: ArgumentsParser.getLastValue.bind(ArgumentsParser),
// don't allow string[] values
requiresArg: false
// explicitly false! we can default this
}).middleware((middlewareArgv) => {
if (middlewareArgv["clean-dry-run"] === true && middlewareArgv.verbose < 1) {
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)) {
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)) {
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()) {
logger.warn(
`--single may leave '${MergeModeInverted[MergeMode.SPLIT].toLowerCase()}' ROM sets in an unplayable state`
);
}
if (checkArgv.single && checkArgv.mergeRoms.toLowerCase() === MergeModeInverted[MergeMode.MERGED].toLowerCase()) {
logger.warn(
`--single doesn't make sense for '${MergeModeInverted[MergeMode.SPLIT].toLowerCase()}' ROM sets, nothing will be filtered out`
);
}
return true;
}).wrap(ArgumentsParser.getHelpWidth(argv)).version(false).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")
{crossmix} The ROM's emulator-specific /Roms/* directory for CrossMix-OS (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")
{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")
{rocknix} The ROM's emulator-specific /roms/* directory for ROCKNIX (e.g. "gb")
{romm} The ROM's manager-specific /roms/* directory for 'RomM' (e.g. "gb")
{spruce} The ROM's emulator-specific /Roms/* directory for SpruceOS (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 "**${path.sep}*.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 "**${path.sep}*.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 ${path.sep}Assets${path.sep}{pocket}${path.sep}common --dir-letter`
).option("help", {
group: groupHelpDebug,
alias: "h",
description: "Show help",
type: "boolean"
}).fail((msg, err, _yargs) => {
if (err) {
throw err;
}
logger.colorizeYargs(`${_yargs.help().toString().trimEnd()}
`);
throw new IgirException(msg);
});
const yargsArgv = yargsParser.strictOptions(true).parse(argv, {}, (_err, _parsedArgv, output) => {
if (output) {
logger.colorizeYargs(`${output.trimEnd()}
`);
}
});
return Options.fromObject(yargsArgv);
}
}
export {
ArgumentsParser as default
};
//# sourceMappingURL=argumentsParser.js.map