UNPKG

@vtex/fsp-cli

Version:

A VTEX CLI

405 lines (348 loc) 10.7 kB
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs' import path from 'node:path' import { cwd } from 'node:process' import { fileURLToPath } from 'node:url' import { checkbox, input } from '@inquirer/prompts' import { Command, Flags } from '@oclif/core' import { type FastStoreConfig, loadConfig } from '@vtex/fsp-config' import merge from 'deepmerge' import Handlebars from 'handlebars' import ora from 'ora' import * as prettier from 'prettier' import type { PackageJson, PartialDeep } from 'type-fest' import { moduleCliMap } from '../modules.js' import { copyFile } from '../utils/copy-file.js' import { getFileInfo } from '../utils/get-file-info.js' import { getPackageLatestVersion } from '../utils/npm-registry.js' interface TemplateData { accountName: string package: { name: string devDependencies: Record<string, string> } } export class Init extends Command { static args = {} static description = 'Initialize a new FastStore monorepo project from scratch.' static examples = ['<%= config.bin %> <%= command.id %>'] static flags = { 'from-discovery': Flags.boolean({ required: false, description: 'Migrates the current faststore discovery to the monorepo structure', }), } async run(): Promise<void> { const currentConfig = await loadConfig() if (currentConfig?.stores) { this.error('Already initialized') } const { flags } = await this.parse(Init) if (flags['from-discovery']) { await this.migrate() } else { this.freshStart() } } /** * Starts a store fresh */ private async freshStart(): Promise<void> { const appName = await input({ message: 'What is the application name?', default: 'faststore-app', }) if (appName.length === 0) { this.error('App name is required') } await this.execTemplate({ templateName: 'default', destination: path.join(process.cwd(), appName), aditionalDependencies: ['@biomejs/biome', 'turbo'], optionalTemplateData: { package: { name: appName, }, }, }) } /** * Use a template from the available templates */ private async execTemplate(props: { templateName: string destination: string aditionalDependencies?: string[] optionalTemplateData?: PartialDeep<TemplateData> }) { const { templateName, destination, aditionalDependencies = [], optionalTemplateData = {}, } = props try { this.log('Copying template files') const devDependencies = await this.fetchDevDependencies( aditionalDependencies ) const templateData = merge( { package: { name: 'faststore-monorepo', devDependencies, }, }, optionalTemplateData ) Handlebars.registerHelper('json', (context) => { return JSON.stringify(context, undefined, 2) }) // __dirname is not defined in ESM const dirname = path.dirname(fileURLToPath(import.meta.url)) const templatePath = path.join(dirname, '../src/templates', templateName) const templateDirectory = readdirSync(templatePath) for (const file of templateDirectory) { const fileInfo = getFileInfo(file) const filePath = path.join(templatePath, file) // For non-template files, we just copy the file if (fileInfo.extension !== '.hbs') { copyFile(templatePath, destination, file) continue } const parserOptions: Record<string, prettier.BuiltInParserName> = { '.json': 'json', '.js': 'babel', '.ts': 'babel-ts', } const fileBuffer = readFileSync(filePath) const handlebarsTemplate = Handlebars.compile(fileBuffer.toString()) let textContent = handlebarsTemplate(templateData) /** * After the removal of the .hbs extension from the file name * The new extension will be included on the fileInfo.name * * For example: * --> Before: package.json.hbs * --> After: package.json * * To select the correct parser, we get the new file extension from the name */ const newFileExtension = path.extname(fileInfo.name) const parser = parserOptions[newFileExtension] /** * We format the content if a parser is available */ if (parser) { try { textContent = await prettier.format(textContent, { semi: false, singleQuote: true, trailingComma: 'es5', parser, }) } catch (e) { console.error(e) } } writeFileSync(path.join(destination, fileInfo.name), textContent) } this.log('All files copied') } catch (e) { this.log('Could not copy files') } } private async fetchDevDependencies( additionalDeps: string[] = [] ): Promise<Record<string, string>> { const devDependencies: Record<string, string> = {} const spinner = ora('Fetching dependencies') try { spinner.start() const allClis = Object.values(moduleCliMap) const dependenciesToFetch = [ '@vtex/fsp-cli', ...allClis, ...additionalDeps, ] /** * Promise.all is used to run the promises in parallel. * We assemble the devDependencies object using this array index. */ const appsVersion = await Promise.all( dependenciesToFetch.map(getPackageLatestVersion) ) /** * We should always pick the latest stable release for each package. * All the possible CLIs are included as dependencies. * This enhances the ease of use. */ dependenciesToFetch.forEach((dependency, index) => { devDependencies[dependency] = appsVersion[index] }) return devDependencies } catch { spinner.fail('Could not fetch dependencies') return devDependencies } finally { spinner.succeed('All dependencies fetched') } } /** * Migrates an existent store to the monorepo struture */ private async migrate(): Promise<void> { this.log('⚡ Starting the migration of your store') const sourceDir = cwd() const destDir = path.join(cwd(), 'packages/discovery') const files = readdirSync(sourceDir) if (files.length === 0) { return } /** * Files that are ignored by the script. * They will remain on the source directory. */ const filesToIgnore: Record<string, boolean> = { '.git': true, '.github': true, 'yarn.lock': true, } /** * Files that are unable to be ignored. * Their movement to the folder is obligatory. */ const mandatoryFiles = { src: true, 'vtex.env': true, 'vercel.json': true, packages: true, public: true, 'next-env.d.ts': true, 'package.json': true, 'faststore.config.js': true, 'discovery.config.js': true, '.gitignore': true, } type MandatoryFile = keyof typeof mandatoryFiles /** * Files that must be renamed on move. * It must be one of Mandatory files. */ const fileRenames: Partial<Record<MandatoryFile, string>> = { 'faststore.config.js': 'discovery.config.js', } /** * Files that must be deleted. * It must be one of Mandatory files. */ const filesToDelete: Partial<Record<MandatoryFile, boolean>> = { 'vercel.json': true, } /** * Its pretty hard to define all the files that must continue on the root folder since projects have diferent needs. * This function asks for the user, files that must remain untouched. * Its important to avoid the CLI breaking the existent project. */ const optionalFilesToIgnore = await checkbox({ message: 'Choose the files that should remain on the repository', choices: files /** * Mandatory and ignored files are omited from the user options. * Displaying them would confuse the user. */ .filter( (file) => !filesToIgnore[file] && !mandatoryFiles[file as MandatoryFile] ) .map((file) => ({ name: file, value: file, })), }) /** * The optional files are aded on the ignored files. * After this line, we have the complete map of files that will remain on the root folder. */ for (const file of optionalFilesToIgnore) { filesToIgnore[file] = true } /** * We will reuse the next loop to get the account name */ let accountName = '' for (const file of files) { if (filesToIgnore[file]) { continue } if (filesToDelete[file as MandatoryFile]) { try { unlinkSync(path.join(sourceDir, file)) this.log(`✅ ${file} deleted`) } catch { this.log(`❌ Could not delete ${file}`) } continue } if (file === 'package.json') { const { name = '' }: PackageJson = JSON.parse( readFileSync(file).toString() ) /** * Its known that the store follows the pattern <account-name>.store */ accountName = String(name).replace('.store', '') } this.moveFile( sourceDir, destDir, file, fileRenames[file as MandatoryFile] ) } await this.execTemplate({ templateName: 'from-discovery', destination: sourceDir, aditionalDependencies: ['turbo'], optionalTemplateData: { accountName, }, }) this.log('🦄 Store migrated. You can install your packages with yarn.') } /** * Move a file between two directories. * It handles the creation of the destDir case its needed. * @param sourceDir Path of the source directory * @param destDir Path of the destination directory * @param file Name of the file */ private moveFile( sourceDir: string, destDir: string, file: string, rename?: string ): void { // Create the destDir directory if it not exists if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }) } const sourceFile = path.join(sourceDir, file) const destFile = path.join(destDir, rename ?? file) try { renameSync(sourceFile, destFile) this.log(`✅ Moved: ${file}`) } catch { this.log(`❌ Could not move the file: ${file}`) } } }