tavern-stack
Version:
TAVERN Stack CLI
339 lines (316 loc) • 11.3 kB
JavaScript
// Import necessary modules
const {program} = require('commander');
const zip = require('cross-zip');
const fs = require('fs');
const path = require('path');
const stream = require('stream');
const {promisify} = require('util');
const pipeline = promisify(stream.pipeline);
const prompt = require('prompt-sync')();
const {select} = require('@inquirer/prompts');
const dev = false;
// URL to the TAVERN Stack repository zip file
const url = `https://github.com/Drew-Chase/TAVERN-Stack/archive/refs/heads/${dev ? "dev" : "main"}.zip`;
/**
* @typedef {Object} ProjectDetails
* @property {string} project_name - The name of the application.
* @property {string} description - The project description.
* @property {string} author - The project author.
* @property {string} license - The project license.
* @property {string} version - The project version.
* @property {string} repository - The project repository.
*
*/
/**
* Prompt the user for project details.
* @returns {ProjectDetails} An object containing the project details.
*/
async function promptUserForDetails(appName)
{
const description = prompt('Project description: ');
const author = prompt('Author: ');
const license = prompt('License: ');
const version = prompt('Version: ');
const repository = prompt('Repository: ');
return {
project_name: appName,
description,
author,
license,
repository,
version: version === "" ? "0.0.0" : version
}
}
/**
* Sanitize the app name to prevent path injection.
* @param {string} appName - The name of the application.
* @returns {string} The sanitized app name.
* @throws Will throw an error if the app name contains invalid characters.
*/
function sanitizeAppName(appName)
{
const sanitizedAppName = appName.replace(/[^a-zA-Z0-9-_]/g, '');
if (sanitizedAppName !== appName)
{
throw new Error('Invalid app name. Only alphanumeric characters, hyphens, and underscores are allowed.');
}
return sanitizedAppName;
}
/**
* Fetch the TAVERN Stack repository zip file.
* @returns {Blob} The fetched zip file as a Blob.
* @throws Will throw an error if the fetch response is not ok.
*/
async function fetchRepository()
{
const res = await fetch(url);
if (!res.ok)
{
throw new Error(`unexpected response ${res.statusText}`);
}
return await res.blob();
}
/**
* Save the zip file to the local filesystem.
* @param {Blob} blob - The zip file blob.
*/
async function saveZipFile(blob)
{
await pipeline(blob.stream(), fs.createWriteStream('tavern.zip'));
}
/**
* Unzip the downloaded file.
* @param {string} appName - The name of the application.
* @returns {Promise} A promise that resolves when the file is unzipped.
*/
function unzipFile(appName)
{
return new Promise((resolve, reject) =>
{
zip.unzip('tavern.zip', `${appName}-tmp`, (err) =>
{
if (err)
{
reject(err);
}
else
{
resolve();
}
});
});
}
/**
* Remove temporary files and rename directories.
* @param {string} appName - The name of the application.
*/
function cleanupFiles(appName)
{
try
{
fs.unlinkSync('tavern.zip');
console.log('Temporary zip file removed');
}
catch (e)
{
console.error(e);
}
fs.renameSync(`${appName}-tmp/TAVERN-Stack-${dev ? "dev" : "main"}`, appName);
fs.rmSync(`${appName}-tmp`, {recursive: true});
try
{
fs.rmSync(path.join(process.cwd(), appName, '.git'), {recursive: true});
}
catch
{
}
try
{
fs.rmSync(path.join(process.cwd(), appName, '.idea'), {recursive: true});
}
catch
{
}
try
{
fs.rmSync(path.join(process.cwd(), appName, 'README.md'));
}
catch
{
}
try
{
fs.rmSync(path.join(process.cwd(), appName, 'darkmode.png'));
fs.rmSync(path.join(process.cwd(), appName, 'lightmode.png'));
}
catch
{
}
}
/**
* Update package.json with user-provided details.
* @param {ProjectDetails} details - The project details provided by the user.
*/
function updatePackageJson(details)
{
const packageJson = require(path.join(process.cwd(), details.project_name, 'package.json'));
packageJson.name = details.project_name;
packageJson.description = details.description === "" ? "TAVERN Stack Template" : details.description;
packageJson.author = details.author === "" ? "" : details.author;
packageJson.license = details.license === "" ? "GPL-3.0-or-later" : details.license;
packageJson.version = details.version === "" ? "0.0.0" : details.version;
packageJson.repository = details.repository === "" ? "" : details.repository;
fs.writeFileSync(path.join(process.cwd(), details.project_name, 'package.json'), JSON.stringify(packageJson, null, 2));
fs.writeFileSync(path.join(process.cwd(), details.project_name, 'README.md'), `# ${details.project_name}\n\n${details.description}`);
}
/**
* Copy template files to the new project directory.
* @param {string} appName - The name of the application.
* @param {string|null} template - The name of the application.
*/
function copyTemplateFiles(appName, template)
{
const templateDir = path.join(process.cwd(), appName, 'templates');
if (template === null)
{
fs.rmSync(templateDir, {recursive: true});
return;
}
fs.readdirSync(path.join(process.cwd(), appName, 'templates', template)).forEach(file =>
{
if (file === "package.json")
{
const templatePackageJson = require(path.join(process.cwd(), appName, 'templates', template, file));
const packageJson = require(path.join(process.cwd(), appName, "package.json"));
const fields = Object.keys(templatePackageJson);
fields.forEach(field =>
{
if (field.startsWith("{{INSERT}}"))
{
const oldField = field.substring(10);
const values = templatePackageJson[field];
const oldValues = packageJson[oldField];
// append the values to the old values
if (Array.isArray(oldValues))
{
packageJson[oldField] = oldValues.concat(values);
}
else if (typeof oldValues === 'object')
{
packageJson[oldField] = {...oldValues, ...values};
}
else
{
packageJson[oldField] = values;
}
return;
}
packageJson[field] = templatePackageJson[field];
});
fs.writeFileSync(path.join(process.cwd(), appName, 'package.json'), JSON.stringify(packageJson, null, 2));
return;
}
// Move the file to the new project directory
fs.renameSync(path.join(process.cwd(), appName, 'templates', template, file), path.join(process.cwd(), appName, file));
});
// remove the templates directory
fs.rmSync(templateDir, {recursive: true});
}
/**
* Prompts the user to select a template for a given application.
*
* @param {string} appName - The name of the application.
* @return {Promise<string|null>} - The selected template name or null if no template is selected.
*/
async function promptSelectTemplate(appName)
{
const templates = fs.readdirSync(path.join(process.cwd(), appName, 'templates'));
const choices = ['typescript-No template', ...templates];
const answer = await select({
message: 'Select a template:',
choices: choices.map(
(choice, index) =>
(
{
name: choice.split('-')[1],
description: `Language: ${choice.split('-')[0]}`,
value: index - 1
}
)
),
})
return answer === -1 ? null : templates[answer];
}
/**
* Replaces variables in all files inside the given directory.
*
* @param {string} directory - The directory to scan for files.
* @param {ProjectDetails} variables - An object containing the variables and their values to replace.
* @return {void}
*/
function replaceVariablesInDirectory(directory, variables)
{
const files = fs.readdirSync(directory);
files.forEach(file =>
{
const filePath = path.join(directory, file);
if (fs.lstatSync(filePath).isDirectory())
{
replaceVariablesInDirectory(filePath, variables);
return;
}
replaceVariablesInFile(filePath, variables);
}
)
}
/**
*
* @param {string} file
* @param {ProjectDetails} variables
*/
function replaceVariablesInFile(file, variables)
{
let data = fs.readFileSync(file, 'utf8');
const relativePath = path.relative(path.join(process.cwd(), variables.project_name), file);
// Replace all occurrences of variables in the file
// {{variable}} will be replaced with the value of the variable
// Ex: {{author}} will be replaced with the value of the author variable
const entries = Object.entries(variables);
let found = false;
for (const [key, value] of entries)
{
if (data.includes(`{{${key}}}`))
{
found = true
data = data.replace(RegExp(`{{${key}}}`, 'g'), value);
console.log(`Replaced {{${key}}} with ${value} in ${relativePath}`);
}
}
// Write the modified data back to the file
if (found)
{
fs.writeFileSync(file, data);
}
}
// Define the 'create' command using commander
program
.command('create <app-name>') // Command to create a new TAVERN Stack project
.description('Create a new TAVERN Stack project') // Description of the command
.action(async (appName) =>
{ // Action to be performed when the command is executed
const sanitizedAppName = sanitizeAppName(appName);
const details = await promptUserForDetails(sanitizedAppName);
console.log(`Creating new TAVERN Stack project: ${sanitizedAppName}`);
const blob = await fetchRepository();
await saveZipFile(blob);
await unzipFile(sanitizedAppName);
cleanupFiles(sanitizedAppName);
updatePackageJson(details);
updatePackageJson(details);
copyTemplateFiles(sanitizedAppName, await promptSelectTemplate(sanitizedAppName));
replaceVariablesInDirectory(path.join(process.cwd(), details.project_name), details);
console.log('TAVERN Stack project created successfully!');
});
// Parse command line arguments
program.parse(process.argv);