Rémi de Juvigny 6bc8662ff6
Handle template download error (#8179)
Signed-off-by: Rémi de Juvigny <remi@hey.com>
2020-10-05 09:41:59 +02:00

199 lines
6.4 KiB
JavaScript

'use strict';
const os = require('os');
const path = require('path');
const fse = require('fs-extra');
const fetch = require('node-fetch');
const tar = require('tar');
const _ = require('lodash');
const chalk = require('chalk');
const gitInfo = require('hosted-git-info');
// Specify all the files and directories a template can have
const allowChildren = '*';
const allowedTemplateContents = {
'README.md': true,
'.env.example': true,
api: allowChildren,
components: allowChildren,
config: {
functions: allowChildren,
},
data: allowChildren,
plugins: allowChildren,
public: allowChildren,
scripts: allowChildren,
};
/**
* merge template with new project being created
* @param {string} templateUrl tempalte github url
* @param {string} rootPath project path
*/
module.exports = async function mergeTemplate(templateUrl, rootPath) {
// Parse template info
const repoInfo = getRepoInfo(templateUrl);
const { user, project } = repoInfo;
console.log(`Installing ${chalk.yellow(`${user}/${project}`)} template.`);
// Download template repository to a temporary directory
const templatePath = await fse.mkdtemp(path.join(os.tmpdir(), 'strapi-'));
try {
await downloadGithubRepo(repoInfo, templatePath);
} catch (error) {
throw Error(`Could not download ${chalk.yellow(`${user}/${project}`)} repository.`);
}
// Make sure the downloaded template matches the required format
await checkTemplateRootStructure(templatePath);
await checkTemplateContentsStructure(path.resolve(templatePath, 'template'));
// Merge contents of the template in the project
await mergePackageJSON(rootPath, templatePath, templateUrl);
await mergeFilesAndDirectories(rootPath, templatePath);
// Delete the downloaded template repo
await fse.remove(templatePath);
};
// Make sure the template has the required top-level structure
async function checkTemplateRootStructure(templatePath) {
// Make sure the root of the repo has a template.json and a template/ folder
const templateJsonPath = path.join(templatePath, 'template.json');
try {
const stat = await fse.stat(templateJsonPath);
if (!stat.isFile()) {
throw Error(`A template must have a root ${chalk.green('template.json')} file`);
}
} catch (error) {
if (error.code === 'ENOENT') {
throw Error(`A template must have a root ${chalk.green('template.json')} file`);
}
throw error;
}
// Make sure the root of the repo has a template.json and a template/ folder
const templateDirPath = path.join(templatePath, 'template');
try {
const stat = await fse.stat(templateDirPath);
if (!stat.isDirectory()) {
throw Error(`A template must have a root ${chalk.green('template/')} directory`);
}
} catch (error) {
if (error.code === 'ENOENT') {
throw Error(`A template must have a root ${chalk.green('template/')} directory`);
}
throw error;
}
}
// Traverse template tree to make sure each file and folder is allowed
async function checkTemplateContentsStructure(templateContentsPath) {
// Recursively check if each item in a directory is allowed
const checkPathContents = (pathToCheck, parents) => {
const contents = fse.readdirSync(pathToCheck);
contents.forEach(item => {
const nextParents = [...parents, item];
const matchingTreeValue = _.get(allowedTemplateContents, nextParents);
// Treat files and directories separately
const itemPath = path.resolve(pathToCheck, item);
const isDirectory = fse.statSync(itemPath).isDirectory();
if (matchingTreeValue === undefined) {
// Unknown paths are forbidden
throw Error(
`Illegal template structure, unknown path ${chalk.green(nextParents.join('/'))}`
);
}
if (matchingTreeValue === true) {
if (!isDirectory) {
// All good, the file is allowed
return;
}
throw Error(
`Illegal template structure, expected a file and got a directory at ${chalk.green(
nextParents.join('/')
)}`
);
}
if (isDirectory) {
if (matchingTreeValue === allowChildren) {
// All children are allowed
return;
}
// Check if the contents of the directory are allowed
checkPathContents(itemPath, nextParents);
} else {
throw Error(
`Illegal template structure, unknow file ${chalk.green(nextParents.join('/'))}`
);
}
});
};
checkPathContents(templateContentsPath, []);
}
function getRepoInfo(githubURL) {
const { type, user, project } = gitInfo.fromUrl(githubURL);
// Make sure it's a github url
if (type !== 'github') {
throw Error('A Strapi template can only be a GitHub URL');
}
return { user, project };
}
async function downloadGithubRepo(repoInfo, templatePath) {
// Download from GitHub
const { user, project } = repoInfo;
const codeload = `https://codeload.github.com/${user}/${project}/tar.gz/master`;
const response = await fetch(codeload);
if (!response.ok) {
throw Error(`Could not download the ${chalk.green(`${user}/${project}`)} repository`);
}
await new Promise(resolve => {
response.body.pipe(tar.extract({ strip: 1, cwd: templatePath })).on('close', resolve);
});
}
// Merge the template's template.json into the Strapi project's package.json
async function mergePackageJSON(rootPath, templatePath, templateUrl) {
// Import the package.json and template.json objects
const packageJSON = require(path.resolve(rootPath, 'package.json'));
const templateJSON = require(path.resolve(templatePath, 'template.json'));
if (!templateJSON.package) {
// Nothing to overwrite
return;
}
// Make sure the template.json doesn't overwrite the UUID
if (templateJSON.package.strapi && templateJSON.package.strapi.uuid) {
throw Error('A template cannot overwrite the Strapi UUID');
}
// Use lodash to deeply merge them
const mergedConfig = _.merge(packageJSON, templateJSON.package);
// Add starter info to package.json
_.set(mergedConfig, 'strapi.template', templateUrl);
// Save the merged config as the new package.json
const packageJSONPath = path.join(rootPath, 'package.json');
await fse.writeJSON(packageJSONPath, mergedConfig, { spaces: 2 });
}
// Merge all allowed files and directories
async function mergeFilesAndDirectories(rootPath, templatePath) {
const templateDir = path.join(templatePath, 'template');
await fse.copy(templateDir, rootPath, { overwrite: true, recursive: true });
}