import { existsSync, promises as fs, rmSync } from 'node:fs' import process from 'node:process' import { getConfig } from '@/src/utils/get-config' import { handleError } from '@/src/utils/handle-error' import { fetchTree, getItemTargetPath, getRegistryBaseColor, getRegistryIndex, resolveTree, } from '@/src/utils/registry' import { transform } from '@/src/utils/transformers' import { Command } from 'commander' import { consola } from 'consola' import { colors } from 'consola/utils' import { addDependency, addDevDependency } from 'nypm' import ora from 'ora' import path from 'pathe' import prompts from 'prompts' import { z } from 'zod' const addOptionsSchema = z.object({ components: z.array(z.string()).optional(), yes: z.boolean(), overwrite: z.boolean(), cwd: z.string(), all: z.boolean(), path: z.string().optional(), }) export const add = new Command() .name('add') .description('add a component to your project') .argument('[components...]', 'the components to add') .option('-y, --yes', 'skip confirmation prompt.', true) .option('-o, --overwrite', 'overwrite existing files.', false) .option( '-c, --cwd ', 'the working directory. defaults to the current directory.', process.cwd(), ) .option('-a, --all', 'add all available components', false) .option('-p, --path ', 'the path to add the component to.') .action(async (components, opts) => { try { const options = addOptionsSchema.parse({ components, ...opts, }) const cwd = path.resolve(options.cwd) if (!existsSync(cwd)) { consola.error(`The path ${cwd} does not exist. Please try again.`) process.exit(1) } const config = await getConfig(cwd) if (!config) { consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`) process.exit(1) } const registryIndex = await getRegistryIndex() let selectedComponents = options.all ? registryIndex.map(entry => entry.name) : options.components if (!options.components?.length && !options.all) { const { components } = await prompts({ type: 'multiselect', name: 'components', message: 'Which components would you like to add?', hint: 'Space to select. A to toggle all. Enter to submit.', instructions: false, choices: registryIndex.map(entry => ({ title: entry.name, value: entry.name, selected: options.all ? true : options.components?.includes(entry.name), })), }) selectedComponents = components } if (!selectedComponents?.length) { consola.warn('No components selected. Exiting.') process.exit(0) } const tree = await resolveTree(registryIndex, selectedComponents) const payload = await fetchTree(config.style, tree) const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) if (!payload.length) { consola.warn('Selected components not found. Exiting.') process.exit(0) } if (!options.yes) { const { proceed } = await prompts({ type: 'confirm', name: 'proceed', message: 'Ready to install components and dependencies. Proceed?', initial: true, }) if (!proceed) process.exit(0) } const spinner = ora('Installing components...').start() for (const item of payload) { spinner.text = `Installing ${item.name}...` const targetDir = getItemTargetPath( config, item, options.path ? path.resolve(cwd, options.path) : undefined, ) if (!targetDir) continue if (!existsSync(targetDir)) await fs.mkdir(targetDir, { recursive: true }) const existingComponent = item.files.filter(file => existsSync(path.resolve(targetDir, item.name, file.name)), ) if (existingComponent.length && !options.overwrite) { if (selectedComponents.includes(item.name)) { spinner.stop() const { overwrite } = await prompts({ type: 'confirm', name: 'overwrite', message: `Component ${item.name} already exists. Would you like to overwrite?`, initial: false, }) if (!overwrite) { consola.info( `Skipped ${item.name}. To overwrite, run with the ${colors.green( '--overwrite', )} flag.`, ) continue } spinner.start(`Installing ${item.name}...`) } else { continue } } // Install dependencies. await Promise.allSettled( [ item.dependencies?.length && await addDependency(item.dependencies, { cwd, silent: true, }), item.devDependencies?.length && await addDevDependency(item.devDependencies, { cwd, silent: true, }), ], ) const componentDir = path.resolve(targetDir, item.name) if (!existsSync(componentDir)) await fs.mkdir(componentDir, { recursive: true }) const files = item.files.map(file => ({ ...file, path: path.resolve( targetDir, item.name, file.name, ), })) // We need to write original files to disk if we're not using TypeScript. // Rewrite or delete added files after transformed if (!config.typescript) { for (const file of files) await fs.writeFile(file.path, file.content) } for (const file of files) { // Run transformers. const content = await transform({ filename: file.path, raw: file.content, config, baseColor, }) let filePath = file.path if (!config.typescript) { // remove original .ts file if we're not using TypeScript. if (file.path.endsWith('.ts')) { if (existsSync(file.path)) rmSync(file.path) } filePath = file.path.replace(/\.ts$/, '.js') } await fs.writeFile(filePath, content) } } spinner.succeed('Done.') } catch (error) { handleError(error) } })