diff --git a/docs/3.0.0-beta.x/cli/CLI.md b/docs/3.0.0-beta.x/cli/CLI.md index dd5d5512ef..9218e3fc83 100644 --- a/docs/3.0.0-beta.x/cli/CLI.md +++ b/docs/3.0.0-beta.x/cli/CLI.md @@ -78,6 +78,68 @@ options: [--no-optimization] - **strapi build --no-optimization**
Builds the administration panel without minimizing the assets. The build duration is faster. +## strapi configuration:dump|config:dump + +Dumps configurations to a file or stdout to help you migrate to production. + +The dump format will be a JSON array. + +``` +strapi configuration:dump + +Options: + -f, --file Output file, default output is stdout +``` + +**Examples** + +- `strapi configuration:dump -f dump.json` +- `strapi config:dump --file dump.json` +- `strapi config:dump > dump.json` + +All these examples are equivalent. + +::: warning +When configuring your application you often enter credentials for thrid party services (e.g authentication providers). Be aware that those credentials will also be dumped into the output of this command. +In case of doubt, you should avoid committing the dump file into a versioning system. Here are some methods you can explore: + +- Copy the file directly to the environment you want and run the restore command there. +- Put the file in a secure location and download it at deploy time with the right credentials. +- Encrypt the file before committing and decrypt it when running the restore command. + +::: + +## strapi configuration:restore|config:restore + +Restores a configuration dump into your application. + +The input format must be a JSON array. + +``` +strapi configuration:restore + +Options: + -f, --file Input file, default input is stdin + -s, --strategy Strategy name, one of: "replace", "merge", "keep". Defaults to: "replace" +``` + +**Examples** + +- `strapi configuration:restore -f dump.json` +- `strapi config:restore --file dump.json -s replace` +- `cat dump.json | strapi config:restore` +- `strapi config:restore < dump.json` + +All these examples are equivalent. + +**Strategies** + +When running the restore command, you can choose from three different strategies: + +- **replace**: Will create missing keys and replace existing ones. +- **merge**: Will create missing keys and merge existing keys whith there new value. +- **keep**: Will create missing keys and keep existing keys as is. + ## strapi generate:api Scaffold a complete API with its configurations, controller, model and service. diff --git a/packages/strapi/bin/strapi.js b/packages/strapi/bin/strapi.js index cefc542648..47acca5c6b 100755 --- a/packages/strapi/bin/strapi.js +++ b/packages/strapi/bin/strapi.js @@ -49,7 +49,16 @@ const getLocalScript = name => (...args) => { process.exit(1); } - return require(cmdPath)(...args); + const script = require(cmdPath); + + Promise.resolve() + .then(() => { + return script(...args); + }) + .catch(error => { + console.error(`Error while running command ${name}: ${error.message}`); + process.exit(1); + }); }; /** @@ -205,6 +214,19 @@ program .description('Starts the admin dev server') .action(getLocalScript('watchAdmin')); +program + .command('configuration:dump') + .alias('config:dump') + .option('-f, --file ', 'Output file, default output is stdout') + .action(getLocalScript('configurationDump')); + +program + .command('configuration:restore') + .alias('config:restore') + .option('-f, --file ', 'Input file, default input is stdin') + .option('-s, --strategy ', 'Strategy name, one of: "replace", "merge", "keep"') + .action(getLocalScript('configurationRestore')); + /** * Normalize help argument */ diff --git a/packages/strapi/lib/Strapi.js b/packages/strapi/lib/Strapi.js index f2a5bfad43..ceffb5cbf7 100644 --- a/packages/strapi/lib/Strapi.js +++ b/packages/strapi/lib/Strapi.js @@ -53,6 +53,7 @@ class Strapi { this.admin = {}; this.plugins = {}; this.config = loadConfiguration(this.dir, opts); + this.isLoaded = false; // internal services. this.fs = createStrapiFs(this); @@ -157,12 +158,9 @@ class Strapi { async start(cb) { try { - await this.load(); - - // Run bootstrap function. - await this.runBootstrapFunctions(); - // Freeze object. - await this.freeze(); + if (!this.isLoaded) { + await this.load(); + } this.app.use(this.router.routes()).use(this.router.allowedMethods()); @@ -324,6 +322,13 @@ class Strapi { // Initialize hooks and middlewares. await initializeMiddlewares.call(this); await initializeHooks.call(this); + + await this.runBootstrapFunctions(); + await this.freeze(); + + this.isLoaded = true; + + return this; } async startWebhooks() { diff --git a/packages/strapi/lib/commands/configurationDump.js b/packages/strapi/lib/commands/configurationDump.js new file mode 100644 index 0000000000..58c08ee799 --- /dev/null +++ b/packages/strapi/lib/commands/configurationDump.js @@ -0,0 +1,50 @@ +'use strict'; + +const fs = require('fs'); +const strapi = require('../index'); + +const CHUNK_SIZE = 100; + +/** + * Will dump configurations to a file or stdout + * @param {string} file filepath to use as output + */ +module.exports = async function({ file: filePath }) { + const output = filePath ? fs.createWriteStream(filePath) : process.stdout; + + const app = await strapi().load(); + + const count = await app.query('core_store').count(); + + const exportData = []; + + const pageCount = Math.ceil(count / CHUNK_SIZE); + + for (let page = 0; page < pageCount; page++) { + const results = await app + .query('core_store') + .find({ _limit: CHUNK_SIZE, _start: page * CHUNK_SIZE, _sort: 'key' }); + + results + .filter(result => result.key.startsWith('plugin_')) + .forEach(result => { + exportData.push({ + key: result.key, + value: result.value, + type: result.type, + environment: result.environment, + tag: result.tag, + }); + }); + } + + output.write(JSON.stringify(exportData)); + output.write('\n'); + output.end(); + + // log success only when writting to file + if (filePath) { + console.log(`Successfully exported ${exportData.length} configuration entries`); + } + process.exit(0); +}; diff --git a/packages/strapi/lib/commands/configurationRestore.js b/packages/strapi/lib/commands/configurationRestore.js new file mode 100644 index 0000000000..706164bcac --- /dev/null +++ b/packages/strapi/lib/commands/configurationRestore.js @@ -0,0 +1,160 @@ +'use strict'; + +const _ = require('lodash'); +const fs = require('fs'); +const strapi = require('../index'); + +/** + * Will restore configurations. It reads from a file or stdin + * @param {string} file filepath to use as input + * @param {string} strategy import strategy. one of (replace, merge, keep, default: replace) + */ +module.exports = async function({ file: filePath, strategy = 'replace' }) { + const input = filePath ? fs.readFileSync(filePath) : await readStdin(process.stdin); + + const app = await strapi().load(); + + let dataToImport; + try { + dataToImport = JSON.parse(input); + } catch (error) { + throw new Error(`Invalid input data: ${error.message}. Expected a valid JSON array.`); + } + + if (!Array.isArray(dataToImport)) { + throw new Error(`Invalid input data. Expected a valid JSON array.`); + } + + const importer = createImporter(app.db, strategy); + + for (const config of dataToImport) { + await importer.import(config); + } + + console.log( + `Successfully imported configuration with ${strategy} strategy. Statistics: ${importer.printStatistics()}.` + ); + + process.exit(0); +}; + +const readStdin = () => { + const { stdin } = process; + let result = ''; + + if (stdin.isTTY) return Promise.resolve(result); + + return new Promise((resolve, reject) => { + stdin.setEncoding('utf8'); + stdin.on('readable', () => { + let chunk; + while ((chunk = stdin.read())) { + result += chunk; + } + }); + + stdin.on('end', () => { + resolve(result); + }); + + stdin.on('error', reject); + }); +}; + +const createImporter = (db, strategy) => { + switch (strategy) { + case 'replace': + return createReplaceImporter(db); + case 'merge': + return createMergeImporter(db); + case 'keep': + return createKeepImporter(db); + default: + throw new Error(`No importer available for strategy "${strategy}"`); + } +}; + +/** + * Replace importer. Will replace the keys that already exist and create the new ones + * @param {Object} db - DatabaseManager instance + */ +const createReplaceImporter = db => { + const stats = { + created: 0, + replaced: 0, + }; + + return { + printStatistics() { + return `${stats.created} created, ${stats.replaced} replaced`; + }, + + async import(conf) { + const matching = await db.query('core_store').count({ key: conf.key }); + if (matching > 0) { + stats.replaced += 1; + await db.query('core_store').update({ key: conf.key }, conf); + } else { + stats.created += 1; + await db.query('core_store').create(conf); + } + }, + }; +}; + +/** + * Merge importer. Will merge the keys that already exist with their new value and create the new ones + * @param {Object} db - DatabaseManager instance + */ +const createMergeImporter = db => { + const stats = { + created: 0, + merged: 0, + }; + + return { + printStatistics() { + return `${stats.created} created, ${stats.merged} merged`; + }, + + async import(conf) { + const existingConf = await db.query('core_store').find({ key: conf.key }); + if (existingConf) { + stats.merged += 1; + await db.query('core_store').update({ key: conf.key }, _.merge(existingConf, conf)); + } else { + stats.created += 1; + await db.query('core_store').create(conf); + } + }, + }; +}; + +/** + * Merge importer. Will keep the keys that already exist without changing them and create the new ones + * @param {Object} db - DatabaseManager instance + */ +const createKeepImporter = db => { + const stats = { + created: 0, + untouched: 0, + }; + + return { + printStatistics() { + return `${stats.created} created, ${stats.untouched} untouched`; + }, + + async import(conf) { + const matching = await db.query('core_store').count({ key: conf.key }); + if (matching > 0) { + stats.untouched += 1; + // if configuration already exists do not overwrite it + return; + } + + stats.created += 1; + await db.query('core_store').create(conf); + }, + }; +};