v8r 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 📦 [0.13.0](https://www.npmjs.com/package/v8r/v/0.13.0) - 2022-06-11
4
+
5
+ * Overhaul of CLI output/machine-readable output. Validation results are sent to stdout. Log messages are now sent to stderr only. Pass `--format [text|json] (default: text)` to specify what is sent to stdout.
6
+
7
+ ## 📦 [0.12.0](https://www.npmjs.com/package/v8r/v/0.12.0) - 2022-05-07
8
+
9
+ * Add config file. See https://github.com/chris48s/v8r/blob/main/README.md#configuration for more details.
10
+
11
+ ## 📦 [0.11.1](https://www.npmjs.com/package/v8r/v/0.11.1) - 2022-03-03
12
+
13
+ * Fix: call minimatch with `{dot: true}`, fixes [#174](https://github.com/chris48s/v8r/issues/174)
14
+
3
15
  ## 📦 [0.11.0](https://www.npmjs.com/package/v8r/v/0.11.0) - 2022-02-27
4
16
 
5
17
  * Drop compatibility with node 12, now requires node `^14.13.1 || >=15.0.0`
package/README.md CHANGED
@@ -77,6 +77,83 @@ $ v8r feature.geojson -c my-catalog.json
77
77
 
78
78
  This can be used to specify different custom schemas for multiple file patterns.
79
79
 
80
+ ## Configuration
81
+
82
+ v8r uses CosmiConfig to search for a configuration. This means you can specify your configuration in any of the following places:
83
+
84
+ - `package.json`
85
+ - `.v8rrc`
86
+ - `.v8rrc.json`
87
+ - `.v8rrc.yaml`
88
+ - `.v8rrc.yml`
89
+ - `.v8rrc.js`
90
+ - `.v8rrc.cjs`
91
+ - `v8r.config.js`
92
+ - `v8r.config.cjs`
93
+
94
+ v8r only searches for a config file in the current working directory.
95
+
96
+ Example yaml config file:
97
+
98
+ ```yaml
99
+ # - One or more filenames or glob patterns describing local file or files to validate
100
+ # - overridden by passing one or more positional arguments
101
+ patterns: ['*json']
102
+
103
+ # - Level of verbose logging. 0 is standard, higher numbers are more verbose
104
+ # - overridden by passing --verbose / -v
105
+ # - default = 0
106
+ verbose: 2
107
+
108
+ # - Exit with code 0 even if an error was encountered. True means a non-zero exit
109
+ # code is only issued if validation could be completed successfully and one or
110
+ # more files were invalid
111
+ # - overridden by passing --ignore-errors
112
+ # - default = false
113
+ ignoreErrors: true
114
+
115
+ # - Remove cached HTTP responses older than cacheTtl seconds old.
116
+ # Specifying 0 clears and disables cache completely
117
+ # - overridden by passing --cache-ttl
118
+ # - default = 600
119
+ cacheTtl: 86400
120
+
121
+ # - Output format for validation results
122
+ # - overridden by passing --format
123
+ # - default = text
124
+ format: "json"
125
+
126
+ # - A custom schema catalog.
127
+ # This catalog will be searched ahead of any custom catalogs passed using
128
+ # --catalogs or SchemaStore.org
129
+ # The format of this is subtly different to the format of a catalog
130
+ # passed via --catalogs (which matches the SchemaStore.org format)
131
+ customCatalog:
132
+ schemas:
133
+ - name: Custom Schema # The name of the schema (required)
134
+ description: Custom Schema # A description of the schema (optional)
135
+
136
+ # A Minimatch glob expression for matching up file names with a schema (required)
137
+ fileMatch: ["*.geojson"]
138
+
139
+ # A URL or local file path for the schema location (required)
140
+ # Unlike the SchemaStore.org format, which has a `url` key,
141
+ # custom catalogs defined in v8r config files have a `location` key
142
+ # which can refer to either a URL or local file.
143
+ # Relative paths are interpreted as relative to the config file location.
144
+ location: foo/bar/geojson-schema.json
145
+
146
+ # A custom parser to use for files matching fileMatch
147
+ # instead of trying to infer the correct parser from the filename (optional)
148
+ # This property is specific to custom catalogs defined in v8r config files
149
+ parser: json5
150
+ ```
151
+
152
+ The config file format is specified more formally in a JSON Schema:
153
+
154
+ - [machine-readable JSON](config-schema.json)
155
+ - [human-readable HTML](https://json-schema-viewer.vercel.app/view?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchris48s%2Fv8r%2Fmain%2Fconfig-schema.json&show_breadcrumbs=on&template_name=flat)
156
+
80
157
  ## Exit codes
81
158
 
82
159
  * v8r always exits with code `0` when:
@@ -91,6 +168,10 @@ This can be used to specify different custom schemas for multiple file patterns.
91
168
 
92
169
  This behaviour can be modified using the `--ignore-errors` flag. When invoked with `--ignore-errors` v8r will exit with code `0` even if one of these errors was encountered while attempting validation. A non-zero exit code will only be issued if validation could be completed successfully and the file was invalid.
93
170
 
171
+ * v8r always exits with code `97` when:
172
+ * There was an error loading a config file
173
+ * A config file was loaded but failed validation
174
+
94
175
  * v8r always exits with code `98` when:
95
176
  * An input glob pattern was invalid
96
177
  * An input glob pattern was valid but did not match any files
@@ -127,4 +208,4 @@ This can be used to specify different custom schemas for multiple file patterns.
127
208
 
128
209
  ### ❓ Can `v8r` validate against a local schema?
129
210
 
130
- 💡 Yes. The `--schema` flag can be either a path to a local file or a URL.
211
+ 💡 Yes. The `--schema` flag can be either a path to a local file or a URL. You can also use a [config file](#configuration) to include local schemas in a custom catalog.
@@ -0,0 +1,92 @@
1
+ {
2
+ "title": "JSON schema for v8r config files",
3
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "cacheTtl": {
8
+ "description": "Remove cached HTTP responses older than cacheTtl seconds old. Specifying 0 clears and disables cache completely",
9
+ "type": "integer",
10
+ "minimum": 0
11
+ },
12
+ "customCatalog": {
13
+ "type": "object",
14
+ "description": "A custom schema catalog. This catalog will be searched ahead of any custom catalogs passed using --catalogs or SchemaStore.org",
15
+ "required": [
16
+ "schemas"
17
+ ],
18
+ "properties": {
19
+ "schemas": {
20
+ "type": "array",
21
+ "description": "A list of JSON schema references.",
22
+ "items": {
23
+ "type": "object",
24
+ "required": [
25
+ "name",
26
+ "fileMatch",
27
+ "location"
28
+ ],
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "description": {
32
+ "description": "A description of the schema",
33
+ "type": "string"
34
+ },
35
+ "fileMatch": {
36
+ "description": "A Minimatch glob expression for matching up file names with a schema.",
37
+ "uniqueItems": true,
38
+ "type": "array",
39
+ "items": {
40
+ "type": "string"
41
+ }
42
+ },
43
+ "location": {
44
+ "description": "A URL or local file path for the schema location",
45
+ "type": "string"
46
+ },
47
+ "name": {
48
+ "description": "The name of the schema",
49
+ "type": "string"
50
+ },
51
+ "parser": {
52
+ "description": "A custom parser to use for files matching fileMatch instead of trying to infer the correct parser from the filename",
53
+ "type": "string",
54
+ "enum": [
55
+ "json",
56
+ "yaml",
57
+ "json5"
58
+ ]
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ },
65
+ "format": {
66
+ "description": "Output format for validation results",
67
+ "type": "string",
68
+ "enum": [
69
+ "text",
70
+ "json"
71
+ ]
72
+ },
73
+ "ignoreErrors": {
74
+ "description": "Exit with code 0 even if an error was encountered. True means a non-zero exit code is only issued if validation could be completed successfully and one or more files were invalid",
75
+ "type": "boolean"
76
+ },
77
+ "patterns": {
78
+ "type": "array",
79
+ "description": "One or more filenames or glob patterns describing local file or files to validate",
80
+ "minItems": 1,
81
+ "uniqueItems": true,
82
+ "items": {
83
+ "type": "string"
84
+ }
85
+ },
86
+ "verbose": {
87
+ "description": "Level of verbose logging. 0 is standard, higher numbers are more verbose",
88
+ "type": "integer",
89
+ "minimum": 0
90
+ }
91
+ }
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v8r",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A command-line JSON and YAML validator that's on your wavelength",
5
5
  "scripts": {
6
6
  "test": "V8R_CACHE_NAME=v8r-test c8 --reporter=text mocha \"src/**/*.spec.js\"",
@@ -27,8 +27,10 @@
27
27
  "ajv-draft-04": "^1.0.0",
28
28
  "ajv-formats": "^2.1.1",
29
29
  "chalk": "^5.0.0",
30
+ "cosmiconfig": "^7.0.1",
31
+ "decamelize": "^6.0.0",
30
32
  "flat-cache": "^3.0.4",
31
- "glob": "^7.2.0",
33
+ "glob": "^8.0.1",
32
34
  "got": "^12.0.1",
33
35
  "is-url": "^1.2.4",
34
36
  "js-yaml": "^4.0.0",
@@ -44,12 +46,13 @@
44
46
  "eslint-config-prettier": "^8.1.0",
45
47
  "eslint-plugin-mocha": "^10.0.3",
46
48
  "eslint-plugin-prettier": "^4.0.0",
47
- "mocha": "^9.0.0",
49
+ "mocha": "^10.0.0",
50
+ "mock-cwd": "^1.0.0",
48
51
  "nock": "^13.0.4",
49
52
  "prettier": "^2.1.2"
50
53
  },
51
54
  "engines": {
52
- "node": "^14.13.1 || >=15.0.0"
55
+ "node": ">=14.13.1"
53
56
  },
54
57
  "type": "module",
55
58
  "keywords": [
package/src/ajv.js CHANGED
@@ -9,9 +9,9 @@ import Ajv2019 from "ajv/dist/2019.js";
9
9
  import Ajv2020 from "ajv/dist/2020.js";
10
10
  import addFormats from "ajv-formats";
11
11
 
12
- function _ajvFactory(schema, cache) {
12
+ function _ajvFactory(schema, strictMode, cache) {
13
13
  const resolver = (url) => cache.fetch(url);
14
- const opts = { allErrors: true, loadSchema: resolver, strict: "log" };
14
+ const opts = { allErrors: true, loadSchema: resolver, strict: strictMode };
15
15
 
16
16
  if (
17
17
  typeof schema["$schema"] === "string" ||
@@ -53,17 +53,12 @@ function _ajvFactory(schema, cache) {
53
53
  */
54
54
  }
55
55
 
56
- async function validate(data, schema, cache) {
57
- const ajv = _ajvFactory(schema, cache);
56
+ async function validate(data, schema, strictMode, cache) {
57
+ const ajv = _ajvFactory(schema, strictMode, cache);
58
58
  addFormats(ajv);
59
59
  const validateFn = await ajv.compileAsync(schema);
60
60
  const valid = validateFn(data);
61
- if (!valid) {
62
- console.log("\nErrors:");
63
- console.log(validateFn.errors);
64
- console.log("");
65
- }
66
- return valid;
61
+ return { valid, errors: validateFn.errors ? validateFn.errors : [] };
67
62
  }
68
63
 
69
64
  export { _ajvFactory, validate };
package/src/cache.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import got from "got";
2
- import logging from "./logging.js";
2
+ import logger from "./logger.js";
3
3
 
4
4
  class Cache {
5
5
  constructor(flatCache, ttl) {
@@ -13,10 +13,10 @@ class Cache {
13
13
  Object.entries(this.cache.all()).forEach(
14
14
  function ([url, cachedResponse]) {
15
15
  if (!("timestamp" in cachedResponse) || !("body" in cachedResponse)) {
16
- logging.debug(`Cache error: deleting malformed response`);
16
+ logger.debug(`Cache error: deleting malformed response`);
17
17
  this.cache.removeKey(url);
18
18
  } else if (Date.now() > cachedResponse.timestamp + this.ttl) {
19
- logging.debug(`Cache stale: deleting cached response from ${url}`);
19
+ logger.debug(`Cache stale: deleting cached response from ${url}`);
20
20
  this.cache.removeKey(url);
21
21
  }
22
22
  this.cache.save(true);
@@ -53,12 +53,12 @@ class Cache {
53
53
  this.expire();
54
54
  const cachedResponse = this.cache.getKey(url);
55
55
  if (cachedResponse !== undefined) {
56
- logging.debug(`Cache hit: using cached response from ${url}`);
56
+ logger.debug(`Cache hit: using cached response from ${url}`);
57
57
  return cachedResponse.body;
58
58
  }
59
59
 
60
60
  try {
61
- logging.debug(`Cache miss: calling ${url}`);
61
+ logger.debug(`Cache miss: calling ${url}`);
62
62
  const resp = await got(url);
63
63
  const parsedBody = JSON.parse(resp.body);
64
64
  if (this.ttl > 0) {
@@ -0,0 +1,119 @@
1
+ import minimatch from "minimatch";
2
+ import path from "path";
3
+ import { validate } from "./ajv.js";
4
+ import { getFromUrlOrFile } from "./io.js";
5
+ import logger from "./logger.js";
6
+
7
+ const SCHEMASTORE_CATALOG_URL =
8
+ "https://www.schemastore.org/api/json/catalog.json";
9
+ const SCHEMASTORE_CATALOG_SCHEMA_URL =
10
+ "https://json.schemastore.org/schema-catalog.json";
11
+
12
+ function coerceMatch(inMatch) {
13
+ const outMatch = {};
14
+ outMatch.location = inMatch.url || inMatch.location;
15
+ for (const [key, value] of Object.entries(inMatch)) {
16
+ if (!["location", "url"].includes(key)) {
17
+ outMatch[key] = value;
18
+ }
19
+ }
20
+ return outMatch;
21
+ }
22
+
23
+ function getCatalogs(config) {
24
+ let catalogs = [];
25
+ if (config.customCatalog) {
26
+ catalogs.push({
27
+ location: config.configFileRelativePath,
28
+ catalog: config.customCatalog,
29
+ });
30
+ }
31
+ if (config.catalogs) {
32
+ catalogs = catalogs.concat(
33
+ config.catalogs.map(function (loc) {
34
+ return { location: loc };
35
+ })
36
+ );
37
+ }
38
+ catalogs.push({ location: SCHEMASTORE_CATALOG_URL });
39
+ return catalogs;
40
+ }
41
+
42
+ async function getMatchForFilename(catalogs, filename, cache) {
43
+ for (const [i, rec] of catalogs.entries()) {
44
+ const catalogLocation = rec.location;
45
+ const catalog =
46
+ rec.catalog || (await getFromUrlOrFile(catalogLocation, cache));
47
+
48
+ if (!rec.catalog) {
49
+ const catalogSchema = await getFromUrlOrFile(
50
+ SCHEMASTORE_CATALOG_SCHEMA_URL,
51
+ cache
52
+ );
53
+
54
+ // Validate the catalog
55
+ const strictMode = false;
56
+ const { valid } = await validate(
57
+ catalog,
58
+ catalogSchema,
59
+ strictMode,
60
+ cache
61
+ );
62
+ if (!valid || catalog.schemas === undefined) {
63
+ throw new Error(`Malformed catalog at ${catalogLocation}`);
64
+ }
65
+ }
66
+
67
+ const { schemas } = catalog;
68
+ const matches = getSchemaMatchesForFilename(schemas, filename);
69
+ logger.debug(`Searching for schema in ${catalogLocation} ...`);
70
+ if (matches.length === 1) {
71
+ logger.info(`Found schema in ${catalogLocation} ...`);
72
+ return coerceMatch(matches[0]); // Match found. We're done.
73
+ }
74
+ if (matches.length === 0 && i < catalogs.length - 1) {
75
+ continue; // No match found. Try the next catalog in the array.
76
+ }
77
+ if (matches.length > 1) {
78
+ // We found >1 matches in the same catalog. This is always a hard error.
79
+ const matchesLog = matches
80
+ .map(function (match) {
81
+ let outStr = "";
82
+ outStr += ` ${match.name}\n`;
83
+ if (match.description) {
84
+ outStr += ` ${match.description}\n`;
85
+ }
86
+ outStr += ` ${match.url || match.location}\n`;
87
+ return outStr;
88
+ })
89
+ .join("\n");
90
+ logger.info(
91
+ `Found multiple possible schemas for ${filename}. Possible matches:\n${matchesLog}`
92
+ );
93
+ }
94
+ // Either we found >1 matches in the same catalog or we found 0 matches
95
+ // in the last catalog and there are no more catalogs left to try.
96
+ throw new Error(`Could not find a schema to validate ${filename}`);
97
+ }
98
+ }
99
+
100
+ function getSchemaMatchesForFilename(schemas, filename) {
101
+ const matches = [];
102
+ schemas.forEach(function (schema) {
103
+ if ("fileMatch" in schema) {
104
+ if (schema.fileMatch.includes(path.basename(filename))) {
105
+ matches.push(schema);
106
+ return;
107
+ }
108
+ for (const glob of schema.fileMatch) {
109
+ if (minimatch(path.normalize(filename), glob, { dot: true })) {
110
+ matches.push(schema);
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ });
116
+ return matches;
117
+ }
118
+
119
+ export { getCatalogs, getMatchForFilename, getSchemaMatchesForFilename };
package/src/cli.js CHANGED
@@ -1,97 +1,27 @@
1
1
  import flatCache from "flat-cache";
2
2
  import fs from "fs";
3
- import isUrl from "is-url";
4
- import minimatch from "minimatch";
5
- import { createRequire } from "module";
6
3
  import os from "os";
7
4
  import path from "path";
8
- import yargs from "yargs";
9
- import { hideBin } from "yargs/helpers";
10
5
  import { validate } from "./ajv.js";
11
6
  import { Cache } from "./cache.js";
7
+ import { getCatalogs, getMatchForFilename } from "./catalogs.js";
8
+ import { getConfig } from "./config.js";
12
9
  import { getFiles } from "./glob.js";
13
- import logging from "./logging.js";
10
+ import { getFromUrlOrFile } from "./io.js";
11
+ import logger from "./logger.js";
12
+ import { logErrors, resultsToJson } from "./output-formatters.js";
14
13
  import { parseFile } from "./parser.js";
15
14
 
16
- const SCHEMASTORE_CATALOG_URL =
17
- "https://www.schemastore.org/api/json/catalog.json";
18
-
19
- const SCHEMASTORE_CATALOG_SCHEMA_URL =
20
- "https://json.schemastore.org/schema-catalog.json";
21
-
22
15
  const EXIT = {
23
16
  VALID: 0,
24
17
  ERROR: 1,
25
- NOTFOUND: 98,
18
+ INVALID_CONFIG: 97,
19
+ NOT_FOUND: 98,
26
20
  INVALID: 99,
27
21
  };
28
22
 
29
23
  const CACHE_DIR = path.join(os.tmpdir(), "flat-cache");
30
24
 
31
- async function getFromUrlOrFile(location, cache) {
32
- return isUrl(location)
33
- ? await cache.fetch(location)
34
- : JSON.parse(await fs.promises.readFile(location, "utf8"));
35
- }
36
-
37
- async function getSchemaUrlForFilename(catalogs, filename, cache) {
38
- for (const [i, catalogLocation] of catalogs.entries()) {
39
- const catalog = await getFromUrlOrFile(catalogLocation, cache);
40
- const catalogSchema = await getFromUrlOrFile(
41
- SCHEMASTORE_CATALOG_SCHEMA_URL,
42
- cache
43
- );
44
-
45
- // Validate the catalog
46
- const valid = await validate(catalog, catalogSchema, cache);
47
- if (!valid || catalog.schemas === undefined) {
48
- throw new Error(`Malformed catalog at ${catalogLocation}`);
49
- }
50
-
51
- const { schemas } = catalog;
52
- const matches = getSchemaMatchesForFilename(schemas, filename);
53
- logging.debug(`Searching for schema in ${catalogLocation} ...`);
54
- if (matches.length === 1) {
55
- logging.info(`Found schema in ${catalogLocation} ...`);
56
- return matches[0].url; // Match found. We're done.
57
- }
58
- if (matches.length === 0 && i < catalogs.length - 1) {
59
- continue; // No match found. Try the next catalog in the array.
60
- }
61
- if (matches.length > 1) {
62
- // We found >1 matches in the same catalog. This is always a hard error.
63
- const matchesLog = matches
64
- .map((match) => ` ${match.description}: ${match.url}`)
65
- .join("\n");
66
- logging.info(
67
- `Found multiple possible schemas for ${filename}. Possible matches:\n${matchesLog}`
68
- );
69
- }
70
- // Either we found >1 matches in the same catalog or we found 0 matches
71
- // in the last catalog and there are no more catalogs left to try.
72
- throw new Error(`Could not find a schema to validate ${filename}`);
73
- }
74
- }
75
-
76
- function getSchemaMatchesForFilename(schemas, filename) {
77
- const matches = [];
78
- schemas.forEach(function (schema) {
79
- if ("fileMatch" in schema) {
80
- if (schema.fileMatch.includes(path.basename(filename))) {
81
- matches.push(schema);
82
- return;
83
- }
84
- for (const glob of schema.fileMatch) {
85
- if (minimatch(path.normalize(filename), glob)) {
86
- matches.push(schema);
87
- break;
88
- }
89
- }
90
- }
91
- });
92
- return matches;
93
- }
94
-
95
25
  function secondsToMilliseconds(seconds) {
96
26
  return seconds * 1000;
97
27
  }
@@ -103,45 +33,53 @@ function getFlatCache() {
103
33
  return flatCache.load("v8r", CACHE_DIR);
104
34
  }
105
35
 
106
- async function validateFile(filename, args, cache) {
107
- logging.info(`Processing ${filename}`);
36
+ async function validateFile(filename, config, cache) {
37
+ logger.info(`Processing ${filename}`);
38
+ let result = {
39
+ fileLocation: filename,
40
+ schemaLocation: null,
41
+ valid: null,
42
+ errors: [],
43
+ code: null,
44
+ };
108
45
  try {
109
- const data = parseFile(
110
- await fs.promises.readFile(filename, "utf8"),
111
- path.extname(filename)
112
- );
113
-
114
- const schemaLocation =
115
- args.schema ||
116
- (await getSchemaUrlForFilename(
117
- (args.catalogs || []).concat([SCHEMASTORE_CATALOG_URL]),
118
- filename,
119
- cache
120
- ));
46
+ const catalogs = getCatalogs(config);
47
+ const catalogMatch = config.schema
48
+ ? {}
49
+ : await getMatchForFilename(catalogs, filename, cache);
50
+ const schemaLocation = config.schema || catalogMatch.location;
51
+ result.schemaLocation = schemaLocation;
121
52
  const schema = await getFromUrlOrFile(schemaLocation, cache);
122
- logging.info(
53
+ logger.info(
123
54
  `Validating ${filename} against schema from ${schemaLocation} ...`
124
55
  );
125
56
 
126
- const valid = await validate(data, schema, cache);
57
+ const data = parseFile(
58
+ await fs.promises.readFile(filename, "utf8"),
59
+ catalogMatch.parser ? `.${catalogMatch.parser}` : path.extname(filename)
60
+ );
61
+
62
+ const strictMode = config.verbose >= 2 ? "log" : false;
63
+ const { valid, errors } = await validate(data, schema, strictMode, cache);
64
+ result.valid = valid;
65
+ result.errors = errors;
127
66
  if (valid) {
128
- logging.success(`${filename} is valid\n`);
67
+ logger.success(`${filename} is valid\n`);
129
68
  } else {
130
- logging.error(`${filename} is invalid\n`);
69
+ logger.error(`${filename} is invalid\n`);
131
70
  }
132
71
 
133
- if (valid) {
134
- return EXIT.VALID;
135
- }
136
- return EXIT.INVALID;
72
+ result.code = valid ? EXIT.VALID : EXIT.INVALID;
73
+ return result;
137
74
  } catch (e) {
138
- logging.error(`${e.message}\n`);
139
- return EXIT.ERROR;
75
+ logger.error(`${e.message}\n`);
76
+ result.code = EXIT.ERROR;
77
+ return result;
140
78
  }
141
79
  }
142
80
 
143
- function mergeResults(results, ignoreErrors) {
144
- const codes = Object.values(results);
81
+ function resultsToStatusCode(results, ignoreErrors) {
82
+ const codes = Object.values(results).map((result) => result.code);
145
83
  if (codes.includes(EXIT.INVALID)) {
146
84
  return EXIT.INVALID;
147
85
  }
@@ -152,98 +90,60 @@ function mergeResults(results, ignoreErrors) {
152
90
  }
153
91
 
154
92
  function Validator() {
155
- return async function (args) {
93
+ return async function (config) {
156
94
  let filenames = [];
157
- for (const pattern of args.patterns) {
95
+ for (const pattern of config.patterns) {
158
96
  const matches = await getFiles(pattern);
159
97
  if (matches.length === 0) {
160
- logging.error(`Pattern '${pattern}' did not match any files`);
161
- return EXIT.NOTFOUND;
98
+ logger.error(`Pattern '${pattern}' did not match any files`);
99
+ return EXIT.NOT_FOUND;
162
100
  }
163
101
  filenames = filenames.concat(matches);
164
102
  }
165
103
 
166
- const ttl = secondsToMilliseconds(args.cacheTtl || 0);
104
+ const ttl = secondsToMilliseconds(config.cacheTtl || 0);
167
105
  const cache = new Cache(getFlatCache(), ttl);
168
106
 
169
107
  const results = Object.fromEntries(filenames.map((key) => [key, null]));
170
108
  for (const [filename] of Object.entries(results)) {
171
- results[filename] = await validateFile(filename, args, cache);
109
+ results[filename] = await validateFile(filename, config, cache);
110
+
111
+ if (results[filename].valid === false && config.format === "text") {
112
+ logErrors(filename, results[filename].errors);
113
+ }
114
+ // else: silence is golden
115
+
172
116
  cache.resetCounters();
173
117
  }
174
- return mergeResults(results, args.ignoreErrors);
118
+
119
+ if (config.format === "json") {
120
+ resultsToJson(results);
121
+ }
122
+
123
+ return resultsToStatusCode(results, config.ignoreErrors);
175
124
  };
176
125
  }
177
126
 
178
- async function cli(args) {
179
- logging.init(args.verbose);
127
+ async function cli(config) {
128
+ if (!config) {
129
+ try {
130
+ config = await getConfig(process.argv);
131
+ } catch (e) {
132
+ logger.error(e.message);
133
+ return EXIT.INVALID_CONFIG;
134
+ }
135
+ }
136
+
137
+ logger.setVerbosity(config.verbose);
138
+ logger.debug(`Merged args/config: ${JSON.stringify(config, null, 2)}`);
139
+
180
140
  try {
181
141
  const validate = new Validator();
182
- return await validate(args);
142
+ return await validate(config);
183
143
  } catch (e) {
184
- logging.error(e.message);
144
+ logger.error(e.message);
185
145
  return EXIT.ERROR;
186
- } finally {
187
- logging.cleanup();
188
146
  }
189
147
  }
190
148
 
191
- function parseArgs(argv) {
192
- return yargs(hideBin(argv))
193
- .command(
194
- "$0 <patterns..>",
195
- "Validate local json/yaml files against schema(s)",
196
- (yargs) => {
197
- yargs.positional("patterns", {
198
- describe:
199
- "One or more filenames or glob patterns describing local file or files to validate",
200
- });
201
- }
202
- )
203
- .version(
204
- // Workaround for https://github.com/yargs/yargs/issues/1934
205
- // TODO: remove once fixed
206
- createRequire(import.meta.url)("../package.json").version
207
- )
208
- .option("verbose", {
209
- alias: "v",
210
- type: "boolean",
211
- description: "Run with verbose logging. Can be stacked e.g: -vv -vvv",
212
- })
213
- .count("verbose")
214
- .option("schema", {
215
- alias: "s",
216
- type: "string",
217
- describe:
218
- "Local path or URL of a schema to validate against. " +
219
- "If not supplied, we will attempt to find an appropriate schema on " +
220
- "schemastore.org using the filename. If passed with glob pattern(s) " +
221
- "matching multiple files, all matching files will be validated " +
222
- "against this schema",
223
- })
224
- .option("catalogs", {
225
- type: "string",
226
- alias: "c",
227
- array: true,
228
- describe:
229
- "Local path or URL of custom catalogs to use prior to schemastore.org",
230
- })
231
- .conflicts("schema", "catalogs")
232
- .option("ignore-errors", {
233
- type: "boolean",
234
- default: false,
235
- describe:
236
- "Exit with code 0 even if an error was encountered. Passing this flag " +
237
- "means a non-zero exit code is only issued if validation could be " +
238
- "completed successfully and one or more files were invalid",
239
- })
240
- .option("cache-ttl", {
241
- type: "number",
242
- default: 600,
243
- describe:
244
- "Remove cached HTTP responses older than <cache-ttl> seconds old. " +
245
- "Passing 0 clears and disables cache completely",
246
- }).argv;
247
- }
248
-
249
- export { cli, parseArgs };
149
+ export { cli };
package/src/config.js ADDED
@@ -0,0 +1,177 @@
1
+ import { createRequire } from "module";
2
+ // TODO: once JSON modules is stable these requires could become imports
3
+ // https://nodejs.org/api/esm.html#esm_experimental_json_modules
4
+ const require = createRequire(import.meta.url);
5
+
6
+ import Ajv2019 from "ajv/dist/2019.js";
7
+ import { cosmiconfig } from "cosmiconfig";
8
+ import decamelize from "decamelize";
9
+ import isUrl from "is-url";
10
+ import path from "path";
11
+ import yargs from "yargs";
12
+ import { hideBin } from "yargs/helpers";
13
+ import logger from "./logger.js";
14
+ import { logErrors } from "./output-formatters.js";
15
+
16
+ function validateConfig(configFile) {
17
+ const ajv = new Ajv2019({ allErrors: true, strict: false });
18
+ const schema = require("../config-schema.json");
19
+ const validateFn = ajv.compile(schema);
20
+ const valid = validateFn(configFile.config);
21
+ if (!valid) {
22
+ logErrors(
23
+ configFile.filepath ? configFile.filepath : "",
24
+ validateFn.errors
25
+ );
26
+ throw new Error("Malformed config file");
27
+ }
28
+ return valid;
29
+ }
30
+
31
+ function preProcessConfig(configFile) {
32
+ if (!configFile?.config?.customCatalog?.schemas) {
33
+ return;
34
+ }
35
+ for (const schema of configFile.config.customCatalog.schemas) {
36
+ if (!path.isAbsolute(schema.location) && !isUrl(schema.location)) {
37
+ schema.location = path.join(
38
+ path.dirname(configFile.filepath),
39
+ schema.location
40
+ );
41
+ }
42
+ }
43
+ }
44
+
45
+ async function getCosmiConfig(cosmiconfigOptions) {
46
+ cosmiconfigOptions.stopDir = process.cwd();
47
+ const configFile = (await cosmiconfig("v8r", cosmiconfigOptions).search(
48
+ process.cwd()
49
+ )) || { config: {} };
50
+ if (configFile.filepath) {
51
+ logger.info(`Loaded config file from ${getRelativeFilePath(configFile)}`);
52
+ } else {
53
+ logger.info(`No config file found`);
54
+ }
55
+ validateConfig(configFile);
56
+ preProcessConfig(configFile);
57
+ return configFile;
58
+ }
59
+
60
+ function mergeConfigs(args, config) {
61
+ const mergedConfig = { ...args };
62
+ mergedConfig.cacheName = config?.config?.cacheName;
63
+ mergedConfig.customCatalog = config?.config?.customCatalog;
64
+ mergedConfig.configFileRelativePath = undefined;
65
+ if (config.filepath) {
66
+ mergedConfig.configFileRelativePath = getRelativeFilePath(config);
67
+ }
68
+ return mergedConfig;
69
+ }
70
+
71
+ function getRelativeFilePath(config) {
72
+ return path.relative(process.cwd(), config.filepath);
73
+ }
74
+
75
+ function parseArgs(argv, config) {
76
+ const parser = yargs(hideBin(argv));
77
+
78
+ let command = "$0 <patterns..>";
79
+ const patternsOpts = {
80
+ describe:
81
+ "One or more filenames or glob patterns describing local file or files to validate",
82
+ };
83
+ if (Object.keys(config.config).includes("patterns")) {
84
+ command = "$0 [patterns..]";
85
+ patternsOpts.default = config.config.patterns;
86
+ patternsOpts.defaultDescription = `${JSON.stringify(
87
+ config.config.patterns
88
+ )} (from config file ${getRelativeFilePath(config)})`;
89
+ }
90
+
91
+ parser
92
+ .command(
93
+ command,
94
+ "Validate local json/yaml files against schema(s)",
95
+ (yargs) => {
96
+ yargs.positional("patterns", patternsOpts);
97
+ }
98
+ )
99
+ .version(
100
+ // Workaround for https://github.com/yargs/yargs/issues/1934
101
+ // TODO: remove once fixed
102
+ require("../package.json").version
103
+ )
104
+ .option("verbose", {
105
+ alias: "v",
106
+ type: "boolean",
107
+ description: "Run with verbose logging. Can be stacked e.g: -vv -vvv",
108
+ })
109
+ .count("verbose")
110
+ .option("schema", {
111
+ alias: "s",
112
+ type: "string",
113
+ describe:
114
+ "Local path or URL of a schema to validate against. " +
115
+ "If not supplied, we will attempt to find an appropriate schema on " +
116
+ "schemastore.org using the filename. If passed with glob pattern(s) " +
117
+ "matching multiple files, all matching files will be validated " +
118
+ "against this schema",
119
+ })
120
+ .option("catalogs", {
121
+ type: "string",
122
+ alias: "c",
123
+ array: true,
124
+ describe:
125
+ "Local path or URL of custom catalogs to use prior to schemastore.org",
126
+ })
127
+ .conflicts("schema", "catalogs")
128
+ .option("ignore-errors", {
129
+ type: "boolean",
130
+ default: false,
131
+ describe:
132
+ "Exit with code 0 even if an error was encountered. Passing this flag " +
133
+ "means a non-zero exit code is only issued if validation could be " +
134
+ "completed successfully and one or more files were invalid",
135
+ })
136
+ .option("cache-ttl", {
137
+ type: "number",
138
+ default: 600,
139
+ describe:
140
+ "Remove cached HTTP responses older than <cache-ttl> seconds old. " +
141
+ "Passing 0 clears and disables cache completely",
142
+ })
143
+ .option("format", {
144
+ type: "string",
145
+ choices: ["text", "json"],
146
+ default: "text",
147
+ describe: "Output format for validation results",
148
+ })
149
+ .example([
150
+ ["$0 file.json", "Validate a single file"],
151
+ ["$0 file1.json file2.json", "Validate multiple files"],
152
+ [
153
+ "$0 'dir/*.yml' 'dir/*.yaml'",
154
+ "Specify files to validate with glob patterns",
155
+ ],
156
+ ]);
157
+
158
+ for (const [key, value] of Object.entries(config.config)) {
159
+ if (["cacheTtl", "format", "ignoreErrors", "verbose"].includes(key)) {
160
+ parser.default(
161
+ decamelize(key, { separator: "-" }),
162
+ value,
163
+ `${value} (from config file ${getRelativeFilePath(config)})`
164
+ );
165
+ }
166
+ }
167
+
168
+ return parser.argv;
169
+ }
170
+
171
+ async function getConfig(argv, cosmiconfigOptions = {}) {
172
+ const config = await getCosmiConfig(cosmiconfigOptions);
173
+ const args = parseArgs(argv, config);
174
+ return mergeConfigs(args, config);
175
+ }
176
+
177
+ export { getConfig, parseArgs, preProcessConfig, validateConfig };
package/src/glob.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import glob from "glob";
2
- import logging from "./logging.js";
2
+ import logger from "./logger.js";
3
3
 
4
4
  const globPromise = function (pattern, options) {
5
5
  return new Promise((resolve, reject) => {
@@ -13,7 +13,7 @@ async function getFiles(pattern) {
13
13
  try {
14
14
  return await globPromise(pattern, { dot: true });
15
15
  } catch (e) {
16
- logging.error(e.message);
16
+ logger.error(e.message);
17
17
  return [];
18
18
  }
19
19
  }
package/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cli, parseArgs } from "./cli.js";
3
+ import { cli } from "./cli.js";
4
4
 
5
5
  (async () => {
6
- const exitCode = await cli(parseArgs(process.argv));
6
+ const exitCode = await cli();
7
7
  process.exit(exitCode);
8
8
  })();
package/src/io.js ADDED
@@ -0,0 +1,10 @@
1
+ import fs from "fs";
2
+ import isUrl from "is-url";
3
+
4
+ async function getFromUrlOrFile(location, cache) {
5
+ return isUrl(location)
6
+ ? await cache.fetch(location)
7
+ : JSON.parse(await fs.promises.readFile(location, "utf8"));
8
+ }
9
+
10
+ export { getFromUrlOrFile };
package/src/logger.js ADDED
@@ -0,0 +1,64 @@
1
+ import chalk from "chalk";
2
+
3
+ class Logger {
4
+ constructor(verbosity = 0) {
5
+ this.stderr = [];
6
+ this.stdout = [];
7
+ this.verbosity = verbosity;
8
+ }
9
+
10
+ setVerbosity(verbosity) {
11
+ this.verbosity = verbosity;
12
+ }
13
+
14
+ writeOut(message) {
15
+ process.stdout.write(message + "\n");
16
+ }
17
+
18
+ writeErr(message) {
19
+ process.stderr.write(message + "\n");
20
+ }
21
+
22
+ resetStderr() {
23
+ this.stderr = [];
24
+ }
25
+
26
+ resetStdout() {
27
+ this.stdout = [];
28
+ }
29
+
30
+ log(message) {
31
+ this.stdout.push(message);
32
+ this.writeOut(message);
33
+ }
34
+
35
+ info(message) {
36
+ const formatedMessage = chalk.blue.bold("ℹ ") + message;
37
+ this.stderr.push(formatedMessage);
38
+ this.writeErr(formatedMessage);
39
+ }
40
+
41
+ debug(message) {
42
+ const formatedMessage = chalk.blue.bold("ℹ ") + message;
43
+ this.stderr.push(formatedMessage);
44
+ if (this.verbosity === 0) {
45
+ return;
46
+ }
47
+ this.writeErr(formatedMessage);
48
+ }
49
+
50
+ error(message) {
51
+ const formatedMessage = chalk.red.bold("✖ ") + message;
52
+ this.stderr.push(formatedMessage);
53
+ this.writeErr(formatedMessage);
54
+ }
55
+
56
+ success(message) {
57
+ const formatedMessage = chalk.green.bold("✔ ") + message;
58
+ this.stderr.push(formatedMessage);
59
+ this.writeErr(formatedMessage);
60
+ }
61
+ }
62
+
63
+ const logger = new Logger();
64
+ export default logger;
@@ -0,0 +1,19 @@
1
+ import Ajv from "ajv";
2
+ import logger from "./logger.js";
3
+
4
+ function logErrors(filename, errors) {
5
+ const ajv = new Ajv();
6
+ logger.log(
7
+ ajv.errorsText(errors, {
8
+ separator: "\n",
9
+ dataVar: filename + "#",
10
+ })
11
+ );
12
+ logger.log("");
13
+ }
14
+
15
+ function resultsToJson(results) {
16
+ logger.log(JSON.stringify({ results }, null, 2));
17
+ }
18
+
19
+ export { logErrors, resultsToJson };
package/src/logging.js DELETED
@@ -1,39 +0,0 @@
1
- import chalk from "chalk";
2
-
3
- const origWarn = console.warn;
4
- const origInfo = console.info;
5
- const origDebug = console.debug;
6
-
7
- function init(verbosity) {
8
- if (verbosity === 0) {
9
- console.warn = function () {};
10
- console.info = function () {};
11
- console.debug = function () {};
12
- }
13
- // TODO: implement multiple log levels if/when you need them
14
- }
15
-
16
- function cleanup() {
17
- console.warn = origWarn;
18
- console.info = origInfo;
19
- console.debug = origDebug;
20
- }
21
-
22
- function info(message) {
23
- console.log(chalk.blue.bold("ℹ ") + message);
24
- }
25
-
26
- function debug(message) {
27
- console.debug(chalk.blue.bold("ℹ ") + message);
28
- }
29
-
30
- function error(message) {
31
- console.error(chalk.red.bold("✖ ") + message);
32
- }
33
-
34
- function success(message) {
35
- console.log(chalk.green.bold("✔ ") + message);
36
- }
37
-
38
- const logging = { cleanup, init, info, debug, error, success };
39
- export default logging;