UNPKG

storyblok

Version:

A simple CLI to start Storyblok from your command line.

1,433 lines (1,417 loc) 153 kB
#!/usr/bin/env node import commander from 'commander'; import chalk from 'chalk'; import clear from 'clear'; import figlet from 'figlet'; import inquirer from 'inquirer'; import { ALL_REGIONS, EU_CODE, getRegionBaseUrl, CN_CODE, getRegionName, isRegion } from '@storyblok/region-helper'; import updateNotifier from 'update-notifier'; import fs from 'fs'; import pSeries from 'p-series'; import lodash from 'lodash'; import ghdownload from 'git-clone'; import axios from 'axios'; import Storyblok from 'storyblok-js-client'; import path, { resolve } from 'path'; import netrc from 'netrc'; import os from 'os'; import FormData from 'form-data'; import UUID from 'simple-uuid'; import open from 'open'; import onChange from 'on-change'; import fs$1 from 'fs-extra'; import csvReader from 'fast-csv'; import xmlConverter from 'xml-js'; import { compile } from 'json-schema-to-typescript'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const getOptions = (subCommand, argv = {}, api = {}) => { let email = ""; const moreOptions = [ "delete-templates", "pull-components", "push-components", "scaffold" ]; const regionInput = { type: "input", name: "region", message: `Please enter the region you would like to work in (${ALL_REGIONS}):`, default: EU_CODE, validate: function(value) { if (ALL_REGIONS.indexOf(value) > -1) { return true; } return `Please enter a valid region: ${ALL_REGIONS}`; } }; if (subCommand === "select") { return [ { type: "input", name: "name", message: "How should your Project be named?", validate: function(value) { if (value.length > 0) { return true; } return "Please enter a valid name for your project:"; }, filter: function(val) { return val.replace(/\s+/g, "-").toLowerCase(); } }, { type: "list", name: "type", message: "Select the type of your project:", choices: [ "Theme (Storyrenderer/Hosted)", "Boilerplate (Selfhosted)", "Fieldtype" ] }, { type: "list", name: "theme", message: "We got some Themes prepared for you:", choices: [ "Creator Theme (Blueprint) [https://github.com/storyblok/creator-theme]", "City Theme [https://github.com/storyblok/city-theme]", "Nexo Theme [https://github.com/storyblok/nexo-theme]", "Custom Theme [We will ask you about your Github URL]" ], when: function(answers) { return answers.type === "Theme (Storyrenderer/Hosted)"; } }, { type: "input", name: "custom_theme_url", message: 'What is your github theme URL? Tip: should look like: "https://github.com/storyblok/city-theme"', when: function(answers) { return answers.theme === "Custom Theme [We will ask you about your Github URL]"; } }, { type: "list", name: "theme", message: "We got some Boilerplates prepared for you:", choices: [ "PHP - Silex Boilerplate [https://github.com/storyblok/silex-boilerplate]", "JavaScript - NodeJs Boilerplate [https://github.com/storyblok/nodejs-boilerplate]", "Ruby - Sinatra Boilerplate [https://github.com/storyblok/sinatra-boilerplate]", "Python - Django Boilerplate [https://github.com/storyblok/django-boilerplate]", "JavaScript - VueJs Boilerplate [https://github.com/storyblok/vuejs-boilerplate]", "Custom Boilerplate [We will ask you about your Github URL]" ], when: function(answers) { return answers.type === "Boilerplate (Selfhosted)"; } }, { type: "input", name: "custom_theme_url", message: 'What is your github boilerplate URL? Tip: should look like: "https://github.com/storyblok/silex-boilerplate"', when: function(answers) { return answers.theme === "Custom Boilerplate [We will ask you about your Github URL]"; } }, { type: "input", name: "spaceId", message: "What is your space ID? Tip: You can find the space ID in the dashboard on https://app.storyblok.com:", when: function(answers) { return answers.type === "Theme (Storyrenderer/Hosted)"; } }, { type: "input", name: "spaceDomain", message: "What is your domain? Example: city.me.storyblok.com:", when: function(answers) { return answers.type === "Theme (Storyrenderer/Hosted)"; }, filter: function(val) { return val.replace(/https:/g, "").replace(/\//g, ""); } }, { type: "input", name: "themeToken", message: "What is your theme token?", when: function(answers) { return answers.type === "Theme (Storyrenderer/Hosted)"; } } ]; } if (subCommand === "login-strategy") { return [ { type: "list", name: "strategy", message: "Select the login strategy: ", choices: [ { name: "With email and password (Common users with storyblok account)", value: "login-with-email", short: "Email" }, { name: "With Token (Most recommended for SSO users)", value: "login-with-token", short: "Token" } ] } ]; } if (subCommand === "login-with-email") { return [ { type: "input", name: "email", message: "Enter your email address:", validate: function(value) { email = value; if (value.length > 0) { return true; } return "Please enter a valid email:"; } }, { type: "password", name: "password", message: "Enter your password:", validate: function(value) { if (value.length > 0) { return true; } return "Please enter a valid password:"; } }, regionInput ]; } if (subCommand === "login-with-token") { return [ { type: "input", name: "token", message: "Enter your token:", validate: function(value) { if (value.length > 0) { return true; } return "Please enter a valid token:"; } }, regionInput ]; } if (moreOptions.indexOf(subCommand) > -1) { const loginQuestions = [ { type: "input", name: "email", message: "Enter your email address:", validate: function(value) { email = value; if (value.length > 0) { return true; } return "Please enter a valid email:"; } }, { type: "password", name: "password", message: "Enter your password:", validate: function(value) { const done = this.async(); return api.login(email, value).then((_) => done(null, true)).catch((_) => { done("Password seams to be wrong. Please try again:"); }); } } ]; if (!api.isAuthorized()) { return loginQuestions; } return []; } return [ { type: "list", name: "has_account", message: "Do you have already a Storyblok account?", choices: [ "No", "Yes" ], when: function() { return !api.isAuthorized() && !argv.space; } }, { type: "input", name: "email", message: "Enter your email address:", validate: function(value) { email = value; if (value.length > 0) { return true; } return "Please enter a valid email:"; }, when: function() { return !api.isAuthorized(); } }, { type: "password", name: "password", message: "Define your password:", validate: function(value) { var done = this.async(); api.signup(email, value, (data) => { if (data.status === 200) { done(null, true); } else { done("Failed: " + JSON.stringify(data.body) + ". Please try again:"); } }); }, when: function(answers) { return answers.has_account === "No"; } }, { type: "password", name: "password", message: "Enter your password:", validate(value) { var done = this.async(); api.login(email, value).then((_) => done(null, true)).catch((_) => { done("Password seams to be wrong. Please try again:"); }); }, when: function(answers) { return answers.has_account === "Yes" || !api.isAuthorized() && !answers.has_account; } }, { type: "input", name: "name", message: "How should your Project be named?", validate: function(value) { if (value.length > 0) { return true; } return "Please enter a valid name for your project:"; }, filter: function(val) { return val.replace(/\s+/g, "-").toLowerCase(); }, when: function(answers) { return !argv.space; } } ]; }; const replace = (file, replacements) => { return new Promise((resolve, reject) => { fs.readFile(file, "utf8", function(err, data) { if (err) { return reject(err); } for (const from in replacements) { data = data.replace(from, replacements[from]); } fs.writeFile(file, data, "utf8", function(err2) { if (err2) { return reject(err2); } return resolve(true); }); }); }); }; const getFinalStep = (type) => { if (type === "Fieldtype" || type === "quickstart") { return "npm run dev"; } return "gulp"; }; const getRepository = (type, theme, custom_theme_url) => { const regex = /\[(.*?)\]/; if (type === "Theme (Storyrenderer/Hosted)" || type === "Boilerplate (Selfhosted)") { if (custom_theme_url) { return custom_theme_url + ".git"; } else { return regex.exec(theme)[1] + ".git"; } } if (type === "Fieldtype") { return "https://github.com/storyblok/storyblok-fieldtype.git"; } return "https://github.com/storyblok/quickstart.git"; }; const lastStep = (answers) => { return new Promise((resolve, reject) => { const { type, theme, spaceId, name, custom_theme_url, themeToken, spaceDomain, loginToken } = answers; const gitRepo = getRepository(type, theme, custom_theme_url); const outputDir = "./" + name; console.log(chalk.green("\u2713") + " - The github repository " + gitRepo + " will be cloned now..."); ghdownload(gitRepo, outputDir, async (err) => { if (err) { if (err.code === "ENOTEMPTY") { console.log(chalk.red(" Oh Snap! It seems that you already have a project with the name: " + name)); reject(new Error("This repository already has been cloned")); process.exit(0); } console.log(chalk.red("X We never had this kind of issue - Sorry for that!")); console.log(chalk.red("X Could you send us the error below as a stackoverflow question?")); console.log(chalk.red("X That would be great! :)")); console.log(chalk.red("X Don't forget to mark it with the tag `storyblok` so will can find it.")); return reject(err); } else { const finalStep = getFinalStep(type); console.log(chalk.green("\u2713") + " - " + chalk.white("Your Storyblok project is ready for you!")); console.log(chalk.white(" Execute the following command to start Storyblok:")); console.log(chalk.cyan(" cd ./" + name + " && npm install && " + finalStep)); console.log(chalk.white(" If you need more help, just try asking a question on stackoverflow")); console.log(chalk.white(" with the [storyblok] tag or live-chat with us on www.storyblok.com")); try { if (type === "Theme (Storyrenderer/Hosted)" || type === "quickstart") { fs.renameSync(outputDir + "/_token.js", outputDir + "/token.js"); if (themeToken) { await replace(outputDir + "/token.js", { INSERT_TOKEN: themeToken }); } var spaceConfig = {}; if (spaceId) { spaceConfig.INSERT_SPACE_ID = spaceId; } if (spaceDomain) { spaceConfig.INSERT_YOUR_DOMAIN = spaceDomain; } if (loginToken) { spaceConfig.TEMP_QUICKSTART_TOKEN = loginToken; } await replace(outputDir + "/config.js", spaceConfig); return resolve(true); } } catch (e) { return reject(new Error("An error occurred when finish the download repository task " + e.message)); } } }); }); }; var host = "api.storyblok.com"; const getFile = () => { const home = process.env[/^win/.test(process.platform) ? "USERPROFILE" : "HOME"]; return path.join(home, ".netrc"); }; const getNrcFile = () => { let obj = {}; try { obj = netrc(getFile()); } catch (e) { obj = {}; } return obj; }; const get = function() { const obj = getNrcFile(); if (process.env.STORYBLOK_LOGIN && process.env.STORYBLOK_TOKEN && process.env.STORYBLOK_REGION) { return { email: process.env.STORYBLOK_LOGIN, token: process.env.STORYBLOK_TOKEN, region: process.env.STORYBLOK_REGION }; } if (process.env.TRAVIS_STORYBLOK_LOGIN && process.env.TRAVIS_STORYBLOK_TOKEN && process.env.TRAVIS_STORYBLOK_REGION) { return { email: process.env.TRAVIS_STORYBLOK_LOGIN, token: process.env.TRAVIS_STORYBLOK_TOKEN, region: process.env.TRAVIS_STORYBLOK_REGION }; } if (Object.hasOwnProperty.call(obj, host)) { return { email: obj[host].login, token: obj[host].password, region: obj[host].region }; } return null; }; const set = function(email, token, region) { const file = getFile(); let obj = {}; try { obj = netrc(file); } catch (e) { obj = {}; } if (email === null) { delete obj[host]; fs.writeFileSync(file, netrc.format(obj) + os.EOL); return null; } else { obj[host] = { login: email, password: token, region }; fs.writeFileSync(file, netrc.format(obj) + os.EOL); return get(); } }; const creds = { set, get }; const SYNC_TYPES = [ "folders", "components", "roles", "stories", "datasources" ]; const COMMANDS = { GENERATE_MIGRATION: "generate-migration", IMPORT: "import", LOGIN: "login", LOGOUT: "logout", PULL_COMPONENTS: "pull-components", PUSH_COMPONENTS: "push-components", QUICKSTART: "quickstart", ROLLBACK_MIGRATION: "rollback-migration", RUN_MIGRATION: "run-migration", SCAFFOLD: "scaffold", SELECT: "select", SPACES: "spaces", SYNC: "sync", DELETE_DATASOURCES: "delete-datasources", GENERATE_TYPESCRIPT_TYPEDEFS: "generate-typescript-typedefs" }; const DEFAULT_AGENT = { SB_Agent: "SB-CLI", SB_Agent_Version: process.env.npm_package_version || "3.0.0" }; const getRegionApiEndpoint = (region) => `${getRegionBaseUrl(region)}/v1/`; const api = { accessToken: "", oauthToken: "", spaceId: null, region: "", getClient: /* @__PURE__ */ function() { let client, accessToken, oauthToken, region, credsRegion; return function getClient() { const { region: _credsRegion } = creds.get(); if (client && accessToken === this.accessToken && oauthToken === this.oauthToken && region === this.region && credsRegion === _credsRegion) { return client; } accessToken = this.accessToken; oauthToken = this.oauthToken; region = this.region; credsRegion = _credsRegion; try { return client = new Storyblok({ accessToken, oauthToken, region, headers: { ...DEFAULT_AGENT } }, this.apiSwitcher(credsRegion)); } catch (error) { throw new Error(error); } }; }(), getPath(path) { if (this.spaceId) { return `spaces/${this.spaceId}/${path}`; } return path; }, async login(content) { const { email, password, region = EU_CODE } = content; try { const response = await axios.post(`${this.apiSwitcher(region)}users/login`, { email, password }); const { data } = response; if (data.otp_required) { const questions = [ { type: "input", name: "otp_attempt", message: "We sent a code to your email / phone, please insert the authentication code:", validate(value) { if (value.length > 0) { return true; } return "Code cannot blank"; } } ]; const { otp_attempt: code } = await inquirer.prompt(questions); const newResponse = await axios.post(`${this.apiSwitcher(region)}users/login`, { email, password, otp_attempt: code }); return this.persistCredentials(email, newResponse.data.access_token || {}, region); } return this.persistCredentials(email, data.access_token, region); } catch (e) { return Promise.reject(e); } }, async getUser() { const { region } = creds.get(); try { const { data } = await axios.get(`${this.apiSwitcher(this.region ? this.region : region)}users/me`, { headers: { Authorization: this.oauthToken } }); return data.user; } catch (e) { return Promise.reject(e); } }, persistCredentials(email, token = null, region = EU_CODE) { if (token) { this.oauthToken = token; creds.set(email, token, region); return Promise.resolve(token); } return Promise.reject(new Error("The code could not be authenticated.")); }, async processLogin(token = null, region = null) { try { if (token && region) { await this.loginWithToken({ token, region }); console.log(chalk.green("\u2713") + " Log in successfully! Token has been added to .netrc file."); return Promise.resolve({ token, region }); } let content = {}; await inquirer.prompt(getOptions("login-strategy")).then(async ({ strategy }) => { content = await inquirer.prompt(getOptions(strategy)); }).catch((error) => { console.log(error); }); if (!content.token) { await this.login(content); } else { await this.loginWithToken(content); } console.log(chalk.green("\u2713") + " Log in successfully! Token has been added to .netrc file."); return Promise.resolve(content); } catch (e) { if (e.response && e.response.data && e.response.data.error) { console.error(chalk.red("X") + " An error ocurred when login the user: " + e.response.data.error); return Promise.reject(e); } console.error(chalk.red("X") + " An error ocurred when login the user"); return Promise.reject(e); } }, async loginWithToken(content) { const { token, region } = content; try { const { data } = await axios.get(`${this.apiSwitcher(region)}users/me`, { headers: { Authorization: token } }); this.persistCredentials(data.user.email, token, region); return data.user; } catch (e) { return Promise.reject(e); } }, logout(unauthorized) { if (creds.get().email && unauthorized) { console.log(chalk.red("X") + " Your login seems to be expired, we logged you out. Please log back in again."); } creds.set(null); }, signup(email, password, region = EU_CODE) { return axios.post(`${this.apiSwitcher(region)}users/signup`, { email, password, region }).then((response) => { const token = this.extractToken(response); this.oauthToken = token; creds.set(email, token, region); return Promise.resolve(true); }).catch((err) => Promise.reject(err)); }, isAuthorized() { const { token } = creds.get() || {}; if (token) { this.oauthToken = token; return true; } return false; }, setSpaceId(spaceId) { this.spaceId = spaceId; }, setRegion(region) { this.region = region; }, getPresets() { const client = this.getClient(); return client.get(this.getPath("presets")).then((data) => data.data.presets || []).catch((err) => Promise.reject(err)); }, getSpaceOptions() { const client = this.getClient(); return client.get(this.getPath("")).then((data) => data.data.space.options || {}).catch((err) => Promise.reject(err)); }, getComponents() { const client = this.getClient(); return client.get(this.getPath("components")).then((data) => data.data.components || []).catch((err) => Promise.reject(err)); }, getComponentGroups() { const client = this.getClient(); return client.get(this.getPath("component_groups")).then((data) => data.data.component_groups || []).catch((err) => Promise.reject(err)); }, getDatasources() { const client = this.getClient(); return client.get(this.getPath("datasources")).then((data) => data.data.datasources || []).catch((err) => Promise.reject(err)); }, getDatasourceEntries(id) { const client = this.getClient(); return client.get(this.getPath(`datasource_entries?datasource_id=${id}`)).then((data) => data.data.datasource_entries || []).catch((err) => Promise.reject(err)); }, deleteDatasource(id) { const client = this.getClient(); return client.delete(this.getPath(`datasources/${id}`)).catch((err) => Promise.reject(err)); }, post(path, props) { return this.sendRequest(path, "post", props); }, put(path, props) { return this.sendRequest(path, "put", props); }, get(path, options = {}) { return this.sendRequest(path, "get", options); }, getStories(params = {}) { const client = this.getClient(); const _path = this.getPath("stories"); return client.getAll(_path, params); }, getSingleStory(id, options = {}) { const client = this.getClient(); const _path = this.getPath(`stories/${id}`); return client.get(_path, options).then((response) => response.data.story || {}); }, delete(path) { return this.sendRequest(path, "delete"); }, sendRequest(path, method, props = {}) { const client = this.getClient(); const _path = this.getPath(path); return client[method](_path, props); }, async getAllSpacesByRegion(region) { const customClient = new Storyblok({ accessToken: this.accessToken, oauthToken: this.oauthToken, region, headers: { ...DEFAULT_AGENT } }, this.apiSwitcher(region)); return await customClient.get("spaces/", {}).then((res) => res.data.spaces || []).catch((err) => Promise.reject(err)); }, apiSwitcher(region) { return region ? getRegionApiEndpoint(region) : getRegionApiEndpoint(this.region); } }; const capitalize = (word) => { const first = word.charAt(0).toUpperCase(); const rest = word.slice(1).toLowerCase(); return first + rest; }; const findByProperty = (collection, property, value) => { return collection.filter((item) => item[property] === value)[0] || {}; }; const parserError = (responseError) => { const response = responseError.response || {}; if (response && response.data && response.data.error) { return { status: response.status, statusText: response.statusText, message: response.data.error, error: responseError }; } return { status: null, statusText: null, message: responseError.message, error: responseError }; }; const saveFileFactory = (fileName, content, path = "./") => { try { fs.writeFileSync(`${path}${fileName}`, content); return true; } catch (err) { console.log(err); return false; } }; const buildFilterQuery = (keys, operations, values) => { const operators = ["is", "in", "not_in", "like", "not_like", "any_in_array", "all_in_array", "gt_date", "lt_date", "gt_int", "lt_int", "gt_float", "lt_float"]; if (!keys || !operations || !values) { throw new Error("Filter options are required: --keys; --operations; --values"); } const _keys = keys.split(","); const _operations = operations.split(","); const _values = values.split(","); if (_keys.length !== _operations.length || _keys.length !== _values.length) { throw new Error("The number of keys, operations and values must be the same"); } const invalidOperators = _operations.filter((o) => !operators.includes(o)); if (invalidOperators.length) { throw new Error("Invalid operator(s) applied for filter: " + invalidOperators.join(" ")); } const filterQuery = {}; _keys.forEach((key, index) => { filterQuery[key] = { [_operations[index]]: _values[index] }; }); return filterQuery; }; class SyncComponentGroups { /** * @param {{ sourceSpaceId: string, targetSpaceId: string, oauthToken: string }} options */ constructor(options) { this.sourceSpaceId = options.sourceSpaceId; this.targetSpaceId = options.targetSpaceId; this.oauthToken = options.oauthToken; this.sourceComponentGroups = []; this.targetComponentGroups = []; this.client = api.getClient(); } async init() { console.log(`${chalk.green("-")} Syncing component groups...`); try { this.sourceComponentGroups = await this.getComponentGroups( this.sourceSpaceId ); this.targetComponentGroups = await this.getComponentGroups( this.targetSpaceId ); return Promise.resolve(true); } catch (e) { console.error( `${chalk.red("-")} Error on load components groups from source and target spaces: ${e.message}` ); return Promise.reject(e); } } async sync() { try { await this.init(); for (const sourceGroup of this.sourceComponentGroups) { console.log(); console.log( chalk.blue("-") + ` Processing component group ${sourceGroup.name}` ); const targetGroupData = findByProperty( this.targetComponentGroups, "name", sourceGroup.name ); if (!targetGroupData.uuid) { const sourceGroupName = sourceGroup.name; try { console.log( `${chalk.blue("-")} Creating the ${sourceGroupName} component group` ); const groupCreated = await this.createComponentGroup( this.targetSpaceId, sourceGroupName ); this.targetComponentGroups.push(groupCreated); console.log( `${chalk.green("\u2713")} Component group ${sourceGroupName} created` ); } catch (e) { console.error( `${chalk.red("X")} Component Group ${sourceGroupName} creating failed: ${e.message}` ); } } else { console.log( `${chalk.green("\u2713")} Component group ${targetGroupData.name} already exists` ); } } return this.loadComponentsGroups(); } catch (e) { console.error( `${chalk.red("-")} Error on sync component groups: ${e.message}` ); return Promise.reject(e); } } /** * @method getComponentGroups * @return {Promise<Array>} */ async getComponentGroups(spaceId) { console.log( `${chalk.green("-")} Load component groups from space #${spaceId}` ); return this.client.get(`spaces/${spaceId}/component_groups`).then((response) => response.data.component_groups || []).catch((err) => Promise.reject(err)); } /** * @method loadComponentsGroups * @return {{source: Array, target: Array}} */ loadComponentsGroups() { return { source: this.sourceComponentGroups, target: this.targetComponentGroups }; } /** * @method createComponentGroup * @param {string} spaceId * @param {string} componentGroupName * @return {{source: Array, target: Array}} */ createComponentGroup(spaceId, componentGroupName) { return this.client.post(`spaces/${spaceId}/component_groups`, { component_group: { name: componentGroupName } }).then((response) => response.data.component_group || {}).catch((error) => Promise.reject(error)); } } const { last } = lodash; class PresetsLib { /** * @param {{ oauthToken: string, targetSpaceId: int }} options */ constructor(options) { this.oauthToken = options.oauthToken; this.client = api.getClient(); this.targetSpaceId = options.targetSpaceId; } async createPresets(presets = [], componentId, method = "post") { const presetsSize = presets.length; console.log(`${chalk.blue("-")} Pushing ${presetsSize} ${method === "post" ? "new" : "existing"} presets`); try { for (let i = 0; i < presetsSize; i++) { const presetData = presets[i]; const presetId = method === "put" ? `/${presetData.id}` : ""; await this.client[method](`spaces/${this.targetSpaceId}/presets${presetId}`, { preset: { name: presetData.name, component_id: componentId, preset: presetData.preset, image: presetData.image } }); } console.log(`${chalk.green("\u2713")} ${presetsSize} presets sync`); } catch (e) { console.error("An error ocurred while trying to save the presets " + e.message); return Promise.reject(e); } } getComponentPresets(component = {}, presets = []) { console.log(`${chalk.green("-")} Get presets from component ${component.name}`); return presets.filter((preset) => { return preset.component_id === component.id; }); } async getPresets(spaceId) { console.log(`${chalk.green("-")} Load presets from space #${spaceId}`); try { const response = await this.client.get( `spaces/${spaceId}/presets` ); return response.data.presets || []; } catch (e) { console.error("An error ocurred when load presets " + e.message); return Promise.reject(e); } } filterPresetsFromTargetComponent(presets, targetPresets) { console.log(chalk.blue("-") + " Checking target presets to sync..."); const targetPresetsNames = targetPresets.map((preset) => preset.name); const newPresets = presets.filter((preset) => !targetPresetsNames.includes(preset.name)); const updatePresetsSource = presets.filter((preset) => targetPresetsNames.includes(preset.name)); const updatePresets = updatePresetsSource.map((source) => { const target = targetPresets.find((target2) => target2.name === source.name); return Object.assign({}, source, target, { image: source.image }); }); return { newPresets, updatePresets }; } async getSamePresetFromTarget(spaceId, component, sourcePreset) { try { const presetsInTarget = await this.getPresets(spaceId); const componentPresets = this.getComponentPresets(component, presetsInTarget); const defaultPresetInTarget = componentPresets.find((preset) => preset.name === sourcePreset.name); return defaultPresetInTarget; } catch (err) { console.error(`An error occurred while trying to get the "${sourcePreset.name}" preset from target space: ${err.message}`); return null; } } async uploadImageForPreset(image = "") { const imageName = last(image.split("/")); return this.client.post(`spaces/${this.targetSpaceId}/assets`, { filename: imageName, asset_folder_id: null }).then((res) => this.uploadFileToS3(res.data, image, imageName)).catch((e) => Promise.reject(e)); } async uploadFileToS3(signedRequest, imageUrl, name) { try { const response = await axios.get(`https:${imageUrl}`, { responseType: "arraybuffer" }); return new Promise((resolve, reject) => { const form = new FormData(); for (const key in signedRequest.fields) { form.append(key, signedRequest.fields[key]); } form.append("file", response.data); form.submit(signedRequest.post_url, (err, res) => { if (err) { console.log(`${chalk.red("X")} There was an error uploading the image`); return reject(err); } console.log(`${chalk.green("\u2713")} Uploaded ${name} image successfully!`); return resolve(signedRequest.pretty_url); }); }); } catch (e) { console.error("An error occurred while uploading the image " + e.message); } } } const { find } = lodash; class SyncComponents { /** * @param {{ sourceSpaceId: string, targetSpaceId: string, oauthToken: string }} options */ constructor(options) { this.sourcePresets = []; this.targetComponents = []; this.sourceComponents = []; this.sourceSpaceId = options.sourceSpaceId; this.targetSpaceId = options.targetSpaceId; this.oauthToken = options.oauthToken; this.client = api.getClient(); this.presetsLib = new PresetsLib({ oauthToken: options.oauthToken, targetSpaceId: this.targetSpaceId }); this.componentsGroups = options.componentsGroups; this.componentsFullSync = options.componentsFullSync; } async sync() { const syncComponentGroupsInstance = new SyncComponentGroups({ oauthToken: this.oauthToken, sourceSpaceId: this.sourceSpaceId, targetSpaceId: this.targetSpaceId }); try { const componentsGroupsSynced = await syncComponentGroupsInstance.sync(); this.sourceComponentGroups = componentsGroupsSynced.source; this.targetComponentGroups = componentsGroupsSynced.target; console.log(`${chalk.green("-")} Syncing components...`); this.sourceComponents = await this.getComponents(this.sourceSpaceId); this.targetComponents = await this.getComponents(this.targetSpaceId); this.sourcePresets = await this.presetsLib.getPresets(this.sourceSpaceId); console.log( `${chalk.blue("-")} In source space #${this.sourceSpaceId}, it were found: ` ); console.log(` - ${this.sourcePresets.length} presets`); console.log(` - ${this.sourceComponentGroups.length} groups`); console.log(` - ${this.sourceComponents.length} components`); console.log( `${chalk.blue("-")} In target space #${this.targetSpaceId}, it were found: ` ); console.log(` - ${this.targetComponentGroups.length} groups`); console.log(` - ${this.targetComponents.length} components`); } catch (e) { console.error("An error ocurred when load data to sync: " + e.message); return Promise.reject(e); } for (var i = 0; i < this.sourceComponents.length; i++) { console.log(); const component = this.sourceComponents[i]; console.log(chalk.blue("-") + ` Processing component ${component.name}`); const componentPresets = this.presetsLib.getComponentPresets(component, this.sourcePresets); delete component.id; delete component.created_at; const sourceGroupUuid = component.component_group_uuid; if (this.componentsGroups && !this.componentsGroups.includes(sourceGroupUuid)) { console.log( chalk.yellow("-") + ` Component ${component.name} does not belong to the ${this.componentsGroups} group(s).` ); continue; } if (sourceGroupUuid) { const sourceGroup = findByProperty( this.sourceComponentGroups, "uuid", sourceGroupUuid ); const targetGroupData = findByProperty( this.targetComponentGroups, "name", sourceGroup.name ); console.log( `${chalk.yellow("-")} Linking the component to the group ${targetGroupData.name}` ); component.component_group_uuid = targetGroupData.uuid; } const { internal_tags_list, internal_tag_ids, ...rest } = component; const existingTags = await this.getSpaceInternalTags(this.targetSpaceId); let processedInternalTagsIds = []; if (internal_tags_list.length > 0) { await internal_tags_list.forEach(async (tag) => { const existingTag = existingTags.find(({ name }) => tag.name === name); if (!existingTag) { try { const response = await this.createComponentInternalTag(this.targetSpaceId, tag); processedInternalTagsIds.push(response.id); } catch (e) { console.error(chalk.red("X") + ` Internal tag ${tag} creation failed: ${e.message}`); } } else { processedInternalTagsIds.push(existingTag.id); } }); } const componentData = { ...rest, internal_tag_ids: processedInternalTagsIds || internal_tag_ids }; try { const componentCreated = await this.createComponent( this.targetSpaceId, componentData ); console.log(chalk.green("\u2713") + ` Component ${component.name} created`); if (componentPresets.length) { await this.presetsLib.createPresets(componentPresets, componentCreated.id); } } catch (e) { if (e.response && e.response.status || e.status === 422) { console.log( `${chalk.yellow("-")} Component ${component.name} already exists, updating it...` ); const componentTarget = this.getTargetComponent(component.name); await this.updateComponent( this.targetSpaceId, componentTarget.id, componentData, componentTarget ); console.log(chalk.green("\u2713") + ` Component ${component.name} synced`); const presetsToSave = this.presetsLib.filterPresetsFromTargetComponent( componentPresets || [], componentTarget.all_presets || [] ); if (presetsToSave.newPresets.length) { await this.presetsLib.createPresets(presetsToSave.newPresets, componentTarget.id, "post"); } if (presetsToSave.updatePresets.length) { await this.presetsLib.createPresets(presetsToSave.updatePresets, componentTarget.id, "put"); } console.log(chalk.green("\u2713") + " Presets in sync"); } else { console.error(chalk.red("X") + ` Component ${component.name} sync failed: ${e.message}`); } } } } getComponents(spaceId) { console.log( `${chalk.green("-")} Load components from space #${spaceId}` ); return this.client.get(`spaces/${spaceId}/components`).then((response) => response.data.components || []); } getTargetComponent(name) { return find(this.targetComponents, ["name", name]) || {}; } createComponent(spaceId, componentData) { const payload = { component: { ...componentData, schema: this.mergeComponentSchema( componentData.schema ) } }; return this.client.post(`spaces/${spaceId}/components`, payload).then((response) => { const component = response.data.component || {}; return component; }).catch((error) => Promise.reject(error)); } updateComponent(spaceId, componentId, sourceComponentData, targetComponentData) { const payload = { component: this.mergeComponents( sourceComponentData, targetComponentData ) }; payload.component.internal_tag_ids = sourceComponentData.internal_tag_ids; return this.client.put(`spaces/${spaceId}/components/${componentId}`, payload).then((response) => { const component = response.data.component || {}; return component; }).catch((error) => Promise.reject(error)); } mergeComponents(sourceComponent, targetComponent = {}) { const data = this.componentsFullSync ? { // This should be the default behavior in a major future version ...sourceComponent } : { ...sourceComponent, ...targetComponent }; data.schema = this.mergeComponentSchema( sourceComponent.schema ); return data; } mergeComponentSchema(sourceSchema) { return Object.keys(sourceSchema).reduce((acc, key) => { const sourceSchemaItem = sourceSchema[key]; const isBloksType = sourceSchemaItem && sourceSchemaItem.type === "bloks"; const isRichtextType = sourceSchemaItem && sourceSchemaItem.type === "richtext"; if (isBloksType || isRichtextType) { acc[key] = this.mergeBloksSchema(sourceSchemaItem); return acc; } acc[key] = sourceSchemaItem; return acc; }, {}); } mergeBloksSchema(sourceData) { return { ...sourceData, // prevent missing refence to group in whitelist component_group_whitelist: this.getWhiteListFromSourceGroups( sourceData.component_group_whitelist || [] ) }; } getWhiteListFromSourceGroups(whiteList = []) { return whiteList.map((sourceGroupUuid) => { const sourceGroupData = findByProperty( this.sourceComponentGroups, "uuid", sourceGroupUuid ); const targetGroupData = findByProperty( this.targetComponentGroups, "name", sourceGroupData.name ); return targetGroupData.uuid; }); } getSpaceInternalTags(spaceId) { return this.client.get(`spaces/${spaceId}/internal_tags`).then((response) => response.data.internal_tags || []); } createComponentInternalTag(spaceId, tag) { return this.client.post(`spaces/${spaceId}/internal_tags`, { internal_tag: { name: tag.name, object_type: "component" } }).then((response) => response.data.internal_tag || {}).catch((error) => Promise.reject(error)); } } class SyncDatasources { /** * @param {{ sourceSpaceId: string, targetSpaceId: string, oauthToken: string }} options */ constructor(options) { this.targetDatasources = []; this.sourceDatasources = []; this.sourceSpaceId = options.sourceSpaceId; this.targetSpaceId = options.targetSpaceId; this.oauthToken = options.oauthToken; this.client = api.getClient(); } async sync() { try { this.targetDatasources = await this.client.getAll(`spaces/${this.targetSpaceId}/datasources`); this.sourceDatasources = await this.client.getAll(`spaces/${this.sourceSpaceId}/datasources`); console.log( `${chalk.blue("-")} In source space #${this.sourceSpaceId}: ` ); console.log(` - ${this.sourceDatasources.length} datasources`); console.log( `${chalk.blue("-")} In target space #${this.targetSpaceId}: ` ); console.log(` - ${this.targetDatasources.length} datasources`); } catch (err) { console.error(`An error ocurred when loading the datasources: ${err.message}`); return Promise.reject(err); } console.log(chalk.green("-") + " Syncing datasources..."); await this.addDatasources(); await this.updateDatasources(); } async getDatasourceEntries(spaceId, datasourceId, dimensionId = null) { const dimensionQuery = dimensionId ? `&dimension=${dimensionId}` : ""; try { const entriesFirstPage = await this.client.get(`spaces/${spaceId}/datasource_entries/?datasource_id=${datasourceId}${dimensionQuery}`); const entriesRequets = []; for (let i = 2; i <= Math.ceil(entriesFirstPage.total / 25); i++) { entriesRequets.push(await this.client.get(`spaces/${spaceId}/datasource_entries?datasource_id=${datasourceId}&page=${i}${dimensionQuery}`)); } return entriesFirstPage.data.datasource_entries.concat(...(await Promise.all(entriesRequets)).map((r) => r.data.datasource_entries)); } catch (err) { console.error(`An error ocurred when loading the entries of the datasource #${datasourceId}: ${err.message}`); return Promise.reject(err); } } async addDatasourceEntry(entry, datasourceId) { try { return this.client.post(`spaces/${this.targetSpaceId}/datasource_entries/`, { datasource_entry: { name: entry.name, value: entry.value, datasource_id: datasourceId } }); } catch (err) { console.error(`An error ocurred when creating the datasource entry ${entry.name}: ${err.message}`); return Promise.reject(err); } } async updateDatasourceEntry(entry, newData, datasourceId) { try { return this.client.put(`spaces/${this.targetSpaceId}/datasource_entries/${entry.id}`, { datasource_entry: { name: newData.name, value: newData.value, datasource_id: datasourceId } }); } catch (err) { console.error(`An error ocurred when updating the datasource entry ${entry.name}: ${err.message}`); return Promise.reject(err); } } async syncDatasourceEntries(datasourceId, targetId) { try { const sourceEntries = await this.getDatasourceEntries(this.sourceSpaceId, datasourceId); const targetEntries = await this.getDatasourceEntries(this.targetSpaceId, targetId); const updateEntries = targetEntries.filter((e) => sourceEntries.map((se) => se.name).includes(e.name)); const addEntries = sourceEntries.filter((e) => !targetEntries.map((te) => te.name).includes(e.name)); const entriesUpdateRequests = []; for (let j = 0; j < updateEntries.length; j++) { const sourceEntry = sourceEntries.find((d) => d.name === updateEntries[j].name); entriesUpdateRequests.push(this.updateDatasourceEntry(updateEntries[j], sourceEntry, targetId)); } await Promise.all(entriesUpdateRequests); const entriesCreationRequests = []; for (let j = 0; j < addEntries.length; j++) { entriesCreationRequests.push(this.addDatasourceEntry(addEntries[j], targetId)); } await Promise.all(entriesCreationRequests); } catch (err) { console.error(`An error ocurred when syncing the datasource entries: ${err.message}`); return Promise.reject(err); } } async addDatasources() { const datasourcesToAdd = this.sourceDatasources.filter((d) => !this.targetDatasources.map((td) => td.slug).includes(d.slug)); if (datasourcesToAdd.length) { console.log( `${chalk.green("-")} Adding new datasources to target space #${this.targetSpaceId}...` ); } for (let i = 0; i < datasourcesToAdd.length; i++) { try { console.log(` ${chalk.green("-")} Creating datasource ${datasourcesToAdd[i].name} (${datasourcesToAdd[i].slug})`); const newDatasource = await this.client.post(`spaces/${this.targetSpaceId}/datasources`, { name: datasourcesToAdd[i].name, slug: datasourcesToAdd[i].slug }); if (datasourcesToAdd[i].dimensions.length) { console.log( ` ${chalk.blue("-")} Creating dimensions...` ); const { data } = await this.createDatasourcesDimensions(datasourcesToAdd[i].dimensions, newDatasource.data.datasource); await this.syncDatasourceEntries(datasourcesToAdd[i].id, newDatasource.data.datasource.id); console.log( ` ${chalk.blue("-")} Sync dimensions values...` ); await this.syncDatasourceDimensionsValues(datasourcesToAdd[i], data.datasource); console.log(` ${chalk.green("\u2713")} Created datasource ${datasourcesToAdd[i].name}`); } else { await this.syncDatasourceEntries(datasourcesToAdd[i].id, newDatasource.data.datasource.id); console.log(` ${chalk.green("\u2713")} Created datasource ${datasourcesToAdd[i].name}`); } } catch (err) { console.error( `${chalk.red("X")} Datasource ${datasourcesToAdd[i].name} creation failed: ${err.response.data.error || err.message}` ); } } } async updateDatasources() { const datasourcesToUpdate = this.targetDatasources.filter((d) => this.sourceDatasources.map((sd) => sd.slug).includes(d.slug)); if (datasourcesToUpdate.length) { console.log( `${chalk.green("-")} Updating datasources In target space #${this.targetSpaceId}...` ); } for (let i = 0; i < datasourcesToUpdate.length; i++) { try { const sourceDatasource = this.sourceDatasources.find((d) => d.slug === datasourcesToUpdate[i].slug); await this.client.put(`spaces/${this.targetSpaceId}/datasources/${datasourcesToUpdate[i].id}`, { name: sourceDatasource.name, slug: sourceDatasource.slug }); if (datasourcesToUpdate[i].dimensions.length) { console.log(` ${chalk.blue("-")} Updating datasources dimensions ${datasourcesToUpdate[i].name}...`); const sourceDimensionsNames = sourceDatasource.dimensions.map((dimension) => dimension.name); const targetDimensionsNames = datasourcesToUpdate[i].dimensions.map((dimension) => dimension.name); const intersection = sourceDimensionsNames.filter((item) => !targetDimensionsNames.includes(item)); let datasourceToSyncDimensionsValues = datasourcesToUpdate[i]; if (intersection) { const dimensionsToCreate = sourceDatasource.dimensions.filter((dimension) => { if (intersection.includes(dimension.name)) return dimension; }); const { data } = await this.createDatasourcesDimensions(dimensionsToCreate, datasourcesToUpdate[i], true); datasourceToSyncDimen