UNPKG

tavern-stack

Version:

TAVERN Stack CLI

339 lines (316 loc) 11.3 kB
#!/usr/bin/env node // 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);