@vtex/fsp-cli
Version:
A VTEX CLI
405 lines (348 loc) • 10.7 kB
text/typescript
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}`)
}
}
}