UNPKG

v8r

Version:

A command-line JSON, YAML and TOML validator that's on your wavelength

291 lines (261 loc) 9.42 kB
import { createRequire } from "node:module"; // TODO: once JSON modules is stable these requires could become imports // https://nodejs.org/api/esm.html#esm_experimental_json_modules const require = createRequire(import.meta.url); import fs from "node:fs"; import path from "node:path"; import { cosmiconfig } from "cosmiconfig"; import decamelize from "decamelize"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { validateConfigAgainstSchema, validateConfigDocumentParsers, validateConfigOutputFormats, } from "./config-validators.js"; import logger from "./logger.js"; import { loadAllPlugins, resolveUserPlugins } from "./plugins.js"; async function getCosmiConfig(cosmiconfigOptions) { let configFile; if (process.env.V8R_CONFIG_FILE) { if (!fs.existsSync(process.env.V8R_CONFIG_FILE)) { throw new Error(`File ${process.env.V8R_CONFIG_FILE} does not exist.`); } configFile = await cosmiconfig("v8r", cosmiconfigOptions).load( process.env.V8R_CONFIG_FILE, ); } else { cosmiconfigOptions.stopDir = process.cwd(); configFile = (await cosmiconfig("v8r", cosmiconfigOptions).search( process.cwd(), )) || { config: {} }; } if (configFile.filepath) { logger.info(`Loaded config file from ${getRelativeFilePath(configFile)}`); logger.info( `Patterns and relative paths will be resolved relative to current working directory: ${process.cwd()}`, ); } else { logger.info(`No config file found`); } return configFile; } function mergeConfigs(args, config) { const mergedConfig = { ...args }; mergedConfig.cacheName = config?.config?.cacheName; mergedConfig.customCatalog = config?.config?.customCatalog; mergedConfig.configFileRelativePath = undefined; if (config.filepath) { mergedConfig.configFileRelativePath = getRelativeFilePath(config); } // https://github.com/chris48s/v8r/issues/494 delete mergedConfig.format; return mergedConfig; } function getRelativeFilePath(config) { return path.relative(process.cwd(), config.filepath); } function parseArgs(argv, config, documentFormats, outputFormats) { const parser = yargs(hideBin(argv)); let command = "$0 <patterns..>"; const patternsOpts = { describe: "One or more filenames or glob patterns describing local file or files to validate", }; if (Object.keys(config.config).includes("patterns")) { command = "$0 [patterns..]"; patternsOpts.default = config.config.patterns; patternsOpts.defaultDescription = `${JSON.stringify( config.config.patterns, )} (from config file ${getRelativeFilePath(config)})`; } const ignoreFilesOpts = { describe: "A list of files containing glob patterns to ignore", }; let ignoreFilesDefault = [".v8rignore"]; ignoreFilesOpts.defaultDescription = `${JSON.stringify(ignoreFilesDefault)}`; if (Object.keys(config.config).includes("ignorePatternFiles")) { ignoreFilesDefault = config.config.ignorePatternFiles; ignoreFilesOpts.defaultDescription = `${JSON.stringify( ignoreFilesDefault, )} (from config file ${getRelativeFilePath(config)})`; } parser .command( // command command, // description `Validate local ${documentFormats.join("/")} files against schema(s)`, // builder (yargs) => { yargs.positional("patterns", patternsOpts); }, // handler (args) => { /* Yargs doesn't allow .conflicts() with an argument that has a default value (it considers the arg "set" even if we just use the default) so we need to apply the default values here. */ if (args.ignorePatternFiles === undefined) { args.ignorePatternFiles = args["ignore-pattern-files"] = ignoreFilesDefault; } if (args.ignore === false) { args.ignorePatternFiles = args["ignore-pattern-files"] = []; } if (args.ignore === undefined) { args.ignore = true; } // https://github.com/chris48s/v8r/issues/494 if (process.argv.includes("--format")) { logger.warning( "In v8r version 5 the --format argument will be removed. Switch to using --output-format", ); } }, ) .version( // Workaround for https://github.com/yargs/yargs/issues/1934 // TODO: remove once fixed require("../package.json").version, ) .option("verbose", { alias: "v", type: "boolean", description: "Run with verbose logging. Can be stacked e.g: -vv -vvv", }) .count("verbose") .option("schema", { alias: "s", type: "string", describe: "Local path or URL of a schema to validate against. " + "If not supplied, we will attempt to find an appropriate schema on " + "schemastore.org using the filename. If passed with glob pattern(s) " + "matching multiple files, all matching files will be validated " + "against this schema", }) .option("catalogs", { type: "string", alias: "c", array: true, describe: "A list of local paths or URLs of custom catalogs to use prior to schemastore.org", }) .conflicts("schema", "catalogs") .option("ignore-errors", { type: "boolean", default: false, describe: "Exit with code 0 even if an error was encountered. Passing this flag " + "means a non-zero exit code is only issued if validation could be " + "completed successfully and one or more files were invalid", }) .option("ignore-pattern-files", { type: "string", array: true, describe: "A list of files containing glob patterns to ignore", ...ignoreFilesOpts, }) .option("no-ignore", { type: "boolean", describe: "Disable all ignore files", }) .conflicts("ignore-pattern-files", "no-ignore") .option("cache-ttl", { type: "number", default: 600, describe: "Remove cached HTTP responses older than <cache-ttl> seconds old. " + "Passing 0 clears and disables cache completely", }) .option("output-format", { type: "string", choices: outputFormats, default: "text", // https://github.com/chris48s/v8r/issues/494 describe: "Output format for validation results. The '--format' alias is deprecated.", alias: "format", }) .example([ ["$0 file.json", "Validate a single file"], ["$0 file1.json file2.json", "Validate multiple files"], [ "$0 'dir/*.yml' 'dir/*.yaml'", "Specify files to validate with glob patterns", ], ]); for (const [key, value] of Object.entries(config.config)) { if (["cacheTtl", "outputFormat", "ignoreErrors", "verbose"].includes(key)) { parser.default( decamelize(key, { separator: "-" }), value, `${value} (from config file ${getRelativeFilePath(config)})`, ); } } return parser.argv; } function getDocumentFormats(loadedPlugins) { let documentFormats = []; for (const plugin of loadedPlugins) { documentFormats = documentFormats.concat(plugin.registerInputFileParsers()); } return documentFormats; } function getOutputFormats(loadedPlugins) { let outputFormats = []; for (const plugin of loadedPlugins) { outputFormats = outputFormats.concat(plugin.registerOutputFormats()); } return outputFormats; } async function bootstrap(argv, config, cosmiconfigOptions = {}) { if (config) { // special case for unit testing purposes // this allows us to inject an incomplete config and bypass the validation const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } = await loadAllPlugins(config.plugins || []); return { config, allLoadedPlugins, loadedCorePlugins, loadedUserPlugins, }; } // load the config file and validate it against the schema const configFile = await getCosmiConfig(cosmiconfigOptions); validateConfigAgainstSchema(configFile); // https://github.com/chris48s/v8r/issues/494 if (configFile.config.format) { logger.warning( "In v8r version 5 the 'format' config file key will be removed. Switch to using 'outputFormat'", ); configFile.config.outputFormat = configFile.config.format; } // load both core and user plugins let plugins = resolveUserPlugins(configFile.config.plugins || []); const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } = await loadAllPlugins(plugins); const documentFormats = getDocumentFormats(allLoadedPlugins); const outputFormats = getOutputFormats(allLoadedPlugins); // now we have documentFormats and outputFormats // we can finish validating and processing the config validateConfigDocumentParsers(configFile, documentFormats); validateConfigOutputFormats(configFile, outputFormats); // parse command line arguments const args = parseArgs(argv, configFile, documentFormats, outputFormats); // https://github.com/chris48s/v8r/issues/599 logger.warning( "Starting from v8r version 5, v8r will ignore patterns in .gitignore by default.", ); return { config: mergeConfigs(args, configFile), allLoadedPlugins, loadedCorePlugins, loadedUserPlugins, }; } export { bootstrap, getDocumentFormats, getOutputFormats, parseArgs };