storyblok
Version:
A simple CLI to start Storyblok from your command line.
1,433 lines (1,417 loc) • 153 kB
JavaScript
#!/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