From 468d1fc0377ff7237a9688ebbb8cdc141508f827 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 12 Aug 2020 23:18:42 +0300 Subject: [PATCH] Use JSON Schema via AJV for config schema + validation --- package-lock.json | 39 ++++- package.json | 5 +- src/cfg.js | 229 +++++++++--------------------- src/data/cfg.jsdoc.js | 53 +++++++ src/data/cfg.schema.json | 268 +++++++++++++++++++++++++++++++++++ src/data/generateCfgJsdoc.js | 8 ++ 6 files changed, 431 insertions(+), 171 deletions(-) create mode 100644 src/data/cfg.jsdoc.js create mode 100644 src/data/cfg.schema.json create mode 100644 src/data/generateCfgJsdoc.js diff --git a/package-lock.json b/package-lock.json index 2ec9b68..6e9477c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,14 +55,21 @@ "dev": true }, "ajv": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", - "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz", + "integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==", "requires": { - "fast-deep-equal": "^2.0.1", + "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" + }, + "dependencies": { + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + } } }, "ansi-align": { @@ -1254,7 +1261,8 @@ "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true }, "fast-json-stable-stringify": { "version": "2.0.0", @@ -1372,6 +1380,11 @@ "for-in": "^1.0.1" } }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -2106,12 +2119,28 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" }, + "json-pointer": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.0.tgz", + "integrity": "sha1-jlAFUKaqxUZKRzN32leqbMIoKNc=", + "requires": { + "foreach": "^2.0.4" + } + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", "optional": true }, + "json-schema-to-jsdoc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-to-jsdoc/-/json-schema-to-jsdoc-1.0.0.tgz", + "integrity": "sha512-xuP+10g5VOOTrA5ELnOVO1puiCYPQfx0GqmtDQh/OGGh+CbXyNLtJeEpKl6HPXQbiPPYm7NmMypkRlznZmfZbg==", + "requires": { + "json-pointer": "^0.6.0" + } + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index c183c23..07a81ba 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,19 @@ "start": "node src/index.js", "watch": "nodemon -w src src/index.js", "test": "echo \"Error: no test specified\" && exit 1", - "lint": "eslint ./src" + "lint": "eslint ./src", + "generate-config-jsdoc": "node src/data/generateCfgJsdoc.js" }, "repository": { "type": "git", "url": "https://github.com/Dragory/modmailbot" }, "dependencies": { + "ajv": "^6.12.3", "eris": "^0.11.1", "humanize-duration": "^3.12.1", "ini": "^1.3.5", + "json-schema-to-jsdoc": "^1.0.0", "json5": "^2.1.1", "knex": "^0.20.3", "knub-command-manager": "^6.1.0", diff --git a/src/cfg.js b/src/cfg.js index dc3b47b..a31f36e 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -1,29 +1,23 @@ -/** - * !!! NOTE !!! - * - * If you're setting up the bot, DO NOT EDIT THIS FILE DIRECTLY! - * - * Create a configuration file in the same directory as the example file. - * You never need to edit anything under src/ to use the bot. - * - * !!! NOTE !!! - */ - const fs = require('fs'); const path = require('path'); +const Ajv = require('ajv'); +const schema = require('./data/cfg.schema.json'); -let userConfig = {}; +/** @type {ModmailConfig} */ +let config = {}; // Config files to search for, in priority order const configFiles = [ 'config.ini', - 'config.ini.ini', - 'config.ini.txt', 'config.json', 'config.json5', + 'config.js', + + // Possible config files when file extensions are hidden + 'config.ini.ini', + 'config.ini.txt', 'config.json.json', 'config.json.txt', - 'config.js' ]; let foundConfigFile; @@ -35,18 +29,18 @@ for (const configFile of configFiles) { } catch (e) {} } -// Load config file +// Load config values from a config file (if any) if (foundConfigFile) { console.log(`Loading configuration from ${foundConfigFile}...`); try { if (foundConfigFile.endsWith('.js')) { - userConfig = require(`../${foundConfigFile}`); + config = require(`../${foundConfigFile}`); } else { const raw = fs.readFileSync(__dirname + '/../' + foundConfigFile, {encoding: "utf8"}); if (foundConfigFile.endsWith('.ini') || foundConfigFile.endsWith('.ini.txt')) { - userConfig = require('ini').decode(raw); + config = require('ini').decode(raw); } else { - userConfig = require('json5').parse(raw); + config = require('json5').parse(raw); } } } catch (e) { @@ -54,76 +48,9 @@ if (foundConfigFile) { } } -const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId']; -const numericOptions = ['requiredAccountAge', 'requiredTimeOnServer', 'smallAttachmentLimit', 'port']; - -const defaultConfig = { - "token": null, - "mailGuildId": null, - "mainGuildId": null, - "logChannelId": null, - - "prefix": "!", - "snippetPrefix": "!!", - "snippetPrefixAnon": "!!!", - - "status": "Message me for help!", - "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible.", - "closeMessage": null, - "allowUserClose": false, - - "newThreadCategoryId": null, - "mentionRole": "here", - "pingOnBotMention": true, - "botMentionResponse": null, - - "inboxServerPermission": null, - "alwaysReply": false, - "alwaysReplyAnon": false, - "useNicknames": false, - "ignoreAccidentalThreads": false, - "threadTimestamps": false, - "allowMove": false, - "syncPermissionsOnMove": true, - "typingProxy": false, - "typingProxyReverse": false, - "mentionUserInThreadHeader": false, - "rolesInThreadHeader": false, - "allowStaffEdit": false, - "allowStaffDelete": false, - - "enableGreeting": false, - "greetingMessage": null, - "greetingAttachment": null, - - "guildGreetings": {}, - - "requiredAccountAge": null, // In hours - "accountAgeDeniedMessage": "Your Discord account is not old enough to contact modmail.", - - "requiredTimeOnServer": null, // In minutes - "timeOnServerDeniedMessage": "You haven't been a member of the server for long enough to contact modmail.", - - "relaySmallAttachmentsAsAttachments": false, - "smallAttachmentLimit": 1024 * 1024 * 2, - "attachmentStorage": "local", - "attachmentStorageChannelId": null, - - "categoryAutomation": {}, - - "updateNotifications": true, - "plugins": [], - - "commandAliases": {}, - - "port": 8890, - "url": null, - - "dbDir": path.join(__dirname, '..', 'db'), - "knex": null, - - "logDir": path.join(__dirname, '..', 'logs'), -}; +// Set dynamic default values which can't be set in the schema directly +config.dbDir = path.join(__dirname, '..', 'db'); +config.logDir = path.join(__dirname, '..', 'logs'); // Only used for migrating data from older Modmail versions // Load config values from environment variables const envKeyPrefix = 'MM_'; @@ -139,16 +66,16 @@ for (const [key, value] of Object.entries(process.env)) { .replace(/([a-z])_([a-z])/g, (m, m1, m2) => `${m1}${m2.toUpperCase()}`) .replace('__', '.'); - userConfig[configKey] = value.includes('||') + config[configKey] = value.includes('||') ? value.split('||') : value; loadedEnvValues++; } -if (process.env.PORT && !process.env.MM_PORT) { +if (process.env.PORT && ! process.env.MM_PORT) { // Special case: allow common "PORT" environment variable without prefix - userConfig.port = process.env.PORT; + config.port = process.env.PORT; loadedEnvValues++; } @@ -158,11 +85,11 @@ if (loadedEnvValues > 0) { // Convert config keys with periods to objects // E.g. commandAliases.mv -> commandAliases: { mv: ... } -for (const [key, value] of Object.entries(userConfig)) { +for (const [key, value] of Object.entries(config)) { if (! key.includes('.')) continue; const keys = key.split('.'); - let cursor = userConfig; + let cursor = config; for (let i = 0; i < keys.length; i++) { if (i === keys.length - 1) { cursor[keys[i]] = value; @@ -172,60 +99,47 @@ for (const [key, value] of Object.entries(userConfig)) { } } - delete userConfig[key]; + delete config[key]; } -// Combine user config with default config to form final config -const finalConfig = Object.assign({}, defaultConfig); - -for (const [prop, value] of Object.entries(userConfig)) { - if (! defaultConfig.hasOwnProperty(prop)) { - throw new Error(`Unknown option: ${prop}`); +// Cast boolean options (on, true, 1) (off, false, 0) +for (const [key, value] of Object.entries(config)) { + if (typeof value !== "string") continue; + if (["on", "true", "1"].includes(value)) { + config[key] = true; + } else if (["off", "false", "0"].includes(value)) { + config[key] = false; } - - finalConfig[prop] = value; } -// Default knex config -if (! finalConfig['knex']) { - finalConfig['knex'] = { +if (! config['knex']) { + config.knex = { client: 'sqlite', - connection: { - filename: path.join(finalConfig.dbDir, 'data.sqlite') + connection: { + filename: path.join(config.dbDir, 'data.sqlite') }, useNullAsDefault: true }; } // Make sure migration settings are always present in knex config -Object.assign(finalConfig['knex'], { +Object.assign(config['knex'], { migrations: { - directory: path.join(finalConfig.dbDir, 'migrations') + directory: path.join(config.dbDir, 'migrations') } }); -if (finalConfig.smallAttachmentLimit > 1024 * 1024 * 8) { - finalConfig.smallAttachmentLimit = 1024 * 1024 * 8; - console.warn('[WARN] smallAttachmentLimit capped at 8MB'); -} - -// Specific checks -if (finalConfig.attachmentStorage === 'discord' && ! finalConfig.attachmentStorageChannelId) { - console.error('Config option \'attachmentStorageChannelId\' is required with attachment storage \'discord\''); - process.exit(1); -} - // Make sure mainGuildId is internally always an array -if (! Array.isArray(finalConfig['mainGuildId'])) { - finalConfig['mainGuildId'] = [finalConfig['mainGuildId']]; +if (! Array.isArray(config['mainGuildId'])) { + config['mainGuildId'] = [config['mainGuildId']]; } // Make sure inboxServerPermission is always an array -if (! Array.isArray(finalConfig['inboxServerPermission'])) { - if (finalConfig['inboxServerPermission'] == null) { - finalConfig['inboxServerPermission'] = []; +if (! Array.isArray(config['inboxServerPermission'])) { + if (config['inboxServerPermission'] == null) { + config['inboxServerPermission'] = []; } else { - finalConfig['inboxServerPermission'] = [finalConfig['inboxServerPermission']]; + config['inboxServerPermission'] = [config['inboxServerPermission']]; } } @@ -233,59 +147,44 @@ if (! Array.isArray(finalConfig['inboxServerPermission'])) { // Or, in other words, if greetingMessage and/or greetingAttachment is set, it is applied for all servers that don't // already have something set up in guildGreetings. This retains backwards compatibility while allowing you to override // greetings for specific servers in guildGreetings. -if (finalConfig.greetingMessage || finalConfig.greetingAttachment) { - for (const guildId of finalConfig.mainGuildId) { - if (finalConfig.guildGreetings[guildId]) continue; - finalConfig.guildGreetings[guildId] = { - message: finalConfig.greetingMessage, - attachment: finalConfig.greetingAttachment +if (config.greetingMessage || config.greetingAttachment) { + for (const guildId of config.mainGuildId) { + if (config.guildGreetings[guildId]) continue; + config.guildGreetings[guildId] = { + message: config.greetingMessage, + attachment: config.greetingAttachment }; } } // newThreadCategoryId is syntactic sugar for categoryAutomation.newThread -if (finalConfig.newThreadCategoryId) { - finalConfig.categoryAutomation.newThread = finalConfig.newThreadCategoryId; - delete finalConfig.newThreadCategoryId; +if (config.newThreadCategoryId) { + config.categoryAutomation.newThread = config.newThreadCategoryId; + delete config.newThreadCategoryId; } // Turn empty string options to null (i.e. "option=" without a value in config.ini) -for (const [key, value] of Object.entries(finalConfig)) { +for (const [key, value] of Object.entries(config)) { if (value === '') { - finalConfig[key] = null; + config[key] = null; } } -// Cast numeric options to numbers -for (const numericOpt of numericOptions) { - if (finalConfig[numericOpt] != null) { - const number = parseFloat(finalConfig[numericOpt]); - if (Number.isNaN(number)) { - console.error(`Invalid numeric value for ${numericOpt}: ${finalConfig[numericOpt]}`); - process.exit(1); - } - finalConfig[numericOpt] = number; - } -} +// Validate config and assign defaults (if missing) +const ajv = new Ajv({ useDefaults: true }); +const validate = ajv.compile(schema); +const configIsValid = validate(config); -// Cast boolean options (on, true, 1) (off, false, 0) -for (const [key, value] of Object.entries(finalConfig)) { - if (typeof value !== "string") continue; - if (["on", "true", "1"].includes(value)) { - finalConfig[key] = true; - } else if (["off", "false", "0"].includes(value)) { - finalConfig[key] = false; - } -} - -// Make sure all of the required config options are present -for (const opt of required) { - if (! finalConfig[opt]) { - console.error(`Missing required configuration value: ${opt}`); - process.exit(1); +if (! configIsValid) { + console.error('Issues with configuration options:'); + for (const error of validate.errors) { + console.error(`The "${error.dataPath.slice(1)}" option ${error.message}`); } + console.error(''); + console.error('Please restart the bot after fixing the issues mentioned above.'); + process.exit(1); } console.log("Configuration ok!"); -module.exports = finalConfig; +module.exports = config; diff --git a/src/data/cfg.jsdoc.js b/src/data/cfg.jsdoc.js new file mode 100644 index 0000000..a581fe1 --- /dev/null +++ b/src/data/cfg.jsdoc.js @@ -0,0 +1,53 @@ +/** + * @typedef {object} ModmailConfig + * @property {string} [token] + * @property {*} [mainGuildId] + * @property {string} [mailGuildId] + * @property {string} [logChannelId] + * @property {string} [prefix="!"] + * @property {string} [snippetPrefix="!!"] + * @property {string} [snippetPrefixAnon="!!!"] + * @property {string} [status="Message me for help!"] + * @property {*} [responseMessage="Thank you for your message! Our mod team will reply to you here as soon as possible."] + * @property {*} [closeMessage] + * @property {boolean} [allowUserClose=false] + * @property {string} [newThreadCategoryId] + * @property {string} [mentionRole="here"] + * @property {boolean} [pingOnBotMention=true] + * @property {*} [botMentionResponse] + * @property {*} [inboxServerPermission] + * @property {boolean} [alwaysReply=false] + * @property {boolean} [alwaysReplyAnon=false] + * @property {boolean} [useNicknames=false] + * @property {boolean} [ignoreAccidentalThreads=false] + * @property {boolean} [threadTimestamps=false] + * @property {boolean} [allowMove=false] + * @property {boolean} [syncPermissionsOnMove=true] + * @property {boolean} [typingProxy=false] + * @property {boolean} [typingProxyReverse=false] + * @property {boolean} [mentionUserInThreadHeader=false] + * @property {boolean} [rolesInThreadHeader=false] + * @property {boolean} [allowStaffEdit=false] + * @property {boolean} [allowStaffDelete=false] + * @property {boolean} [enableGreeting=false] + * @property {*} [greetingMessage] + * @property {string} [greetingAttachment] + * @property {*} [guildGreetings={}] + * @property {number} [requiredAccountAge] Required account age to message Modmail, in hours + * @property {*} [accountAgeDeniedMessage="Your Discord account is not old enough to contact modmail."] + * @property {number} [requiredTimeOnServer] Required time on server to message Modmail, in minutes + * @property {*} [timeOnServerDeniedMessage="You haven't been a member of the server for long enough to contact modmail."] + * @property {boolean} [relaySmallAttachmentsAsAttachments=false] + * @property {number} [smallAttachmentLimit=2097152] Max size of attachment to relay directly. Default is 2MB. + * @property {string} [attachmentStorage="local"] + * @property {string} [attachmentStorageChannelId] + * @property {*} [categoryAutomation] + * @property {boolean} [updateNotifications=true] + * @property {array} [plugins=[]] + * @property {*} [commandAliases] + * @property {number} [port=8890] + * @property {string} [url] + * @property {string} [dbDir] + * @property {object} [knex] + * @property {string} [logDir] + */ diff --git a/src/data/cfg.schema.json b/src/data/cfg.schema.json new file mode 100644 index 0000000..de1a675 --- /dev/null +++ b/src/data/cfg.schema.json @@ -0,0 +1,268 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModmailConfig", + "type": "object", + "definitions": { + "stringOrArrayOfStrings": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "stringOrMultilineString": { + "$ref": "#/definitions/stringOrArrayOfStrings" + } + }, + "properties": { + "token": { + "type": "string" + }, + "mainGuildId": { + "$ref": "#/definitions/stringOrArrayOfStrings" + }, + "mailGuildId": { + "type": "string" + }, + "logChannelId": { + "type": "string" + }, + + "prefix": { + "type": "string", + "default": "!" + }, + "snippetPrefix": { + "type": "string", + "default": "!!" + }, + "snippetPrefixAnon": { + "type": "string", + "default": "!!!" + }, + "status": { + "type": "string", + "default": "Message me for help!" + }, + "responseMessage": { + "$ref": "#/definitions/stringOrMultilineString", + "default": "Thank you for your message! Our mod team will reply to you here as soon as possible." + }, + "closeMessage": { + "$ref": "#/definitions/stringOrMultilineString" + }, + "allowUserClose": { + "type": "boolean", + "default": false + }, + + "newThreadCategoryId": { + "type": "string" + }, + "mentionRole": { + "type": "string", + "default": "here" + }, + "pingOnBotMention": { + "type": "boolean", + "default": true + }, + "botMentionResponse": { + "$ref": "#/definitions/stringOrMultilineString" + }, + + "inboxServerPermission": { + "$ref": "#/definitions/stringOrArrayOfStrings" + }, + "alwaysReply": { + "type": "boolean", + "default": false + }, + "alwaysReplyAnon": { + "type": "boolean", + "default": false + }, + "useNicknames": { + "type": "boolean", + "default": false + }, + "ignoreAccidentalThreads": { + "type": "boolean", + "default": false + }, + "threadTimestamps": { + "type": "boolean", + "default": false + }, + "allowMove": { + "type": "boolean", + "default": false + }, + "syncPermissionsOnMove": { + "type": "boolean", + "default": true + }, + "typingProxy": { + "type": "boolean", + "default": false + }, + "typingProxyReverse": { + "type": "boolean", + "default": false + }, + "mentionUserInThreadHeader": { + "type": "boolean", + "default": false + }, + "rolesInThreadHeader": { + "type": "boolean", + "default": false + }, + "allowStaffEdit": { + "type": "boolean", + "default": false + }, + "allowStaffDelete": { + "type": "boolean", + "default": false + }, + + "enableGreeting": { + "type": "boolean", + "default": false + }, + "greetingMessage": { + "$ref": "#/definitions/stringOrMultilineString" + }, + "greetingAttachment": { + "type": "string" + }, + "guildGreetings": { + "patternProperties": { + "^\\d+$": { + "$ref": "#/definitions/stringOrMultilineString" + } + }, + "default": {} + }, + + "requiredAccountAge": { + "description": "Required account age to message Modmail, in hours", + "type": "number" + }, + "accountAgeDeniedMessage": { + "$ref": "#/definitions/stringOrMultilineString", + "default": "Your Discord account is not old enough to contact modmail." + }, + + "requiredTimeOnServer": { + "description": "Required time on server to message Modmail, in minutes", + "type": "number" + }, + "timeOnServerDeniedMessage": { + "$ref": "#/definitions/stringOrMultilineString", + "default": "You haven't been a member of the server for long enough to contact modmail." + }, + + "relaySmallAttachmentsAsAttachments": { + "type": "boolean", + "default": false + }, + "smallAttachmentLimit": { + "description": "Max size of attachment to relay directly. Default is 2MB.", + "type": "number", + "default": 2097152, + "maximum": 8388608, + "minimum": 0 + }, + + "attachmentStorage": { + "type": "string", + "default": "local" + }, + "attachmentStorageChannelId": { + "type": "string" + }, + + "categoryAutomation": { + "patternProperties": { + "^.+$": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "default": {} + }, + + "updateNotifications": { + "type": "boolean", + "default": true + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + + "commandAliases": { + "patternProperties": { + "^.+$": { + "type": "string" + } + } + }, + + "port": { + "type": "number", + "maximum": 65535, + "minimum": 1, + "default": 8890 + }, + "url": { + "type": "string" + }, + + "dbDir": { + "type": "string" + }, + "knex": { + "type": "object" + }, + + "logDir": { + "type": "string", + "deprecationMessage": "This option is no longer used" + } + }, + "allOf": [ + { + "$comment": "Base required values", + "required": ["token", "mainGuildId", "mailGuildId", "logChannelId"] + }, + { + "$comment": "Make attachmentStorageChannelId required if attachmentStorage is set to 'discord'", + "if": { + "properties": { + "attachmentStorage": { + "const": "discord" + } + }, + "required": ["attachmentStorage"] + }, + "then": { + "required": ["attachmentStorageChannelId"] + } + } + ] +} diff --git a/src/data/generateCfgJsdoc.js b/src/data/generateCfgJsdoc.js new file mode 100644 index 0000000..219615c --- /dev/null +++ b/src/data/generateCfgJsdoc.js @@ -0,0 +1,8 @@ +const path = require('path'); +const fs = require('fs'); +const toJsdoc = require('json-schema-to-jsdoc'); +const schema = require('./cfg.schema.json'); +const target = path.join(__dirname, 'cfg.jsdoc.js'); + +const result = toJsdoc(schema); +fs.writeFileSync(target, result, { encoding: 'utf8' });