willow-cli
Version:
CLI for installing Willow Design System components
271 lines (270 loc) ⢠12.4 kB
JavaScript
import path from 'path';
import { stat } from 'fs/promises';
import chalk from 'chalk';
import { WILLOW_REGISTRY, STABLE_COMPONENTS, UNSTABLE_COMPONENTS } from '../types/index.js';
import { fileExists, getComponentDir, createDirectory, writeFileContent, readFileContent, listDirectory } from './fileSystem.js';
// Fetch component metadata from registry
export async function fetchComponentMeta(component, registry = WILLOW_REGISTRY) {
const url = `${registry}/${component}.json`;
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
// Validate the response structure
if (!data.files || !Array.isArray(data.files)) {
throw new Error('Invalid component metadata structure');
}
return data;
}
catch (error) {
if (error instanceof Error && error.message.includes('fetch')) {
throw new Error(`Network error fetching ${component}: ${error.message}`);
}
throw error;
}
}
// Write component files with proper error handling
export async function writeComponentFiles(files, options = {}) {
const { dryRun = false, overwrite = true, baseDir = process.cwd() } = options;
const writtenFiles = [];
const errors = [];
for (const file of files) {
try {
// Determine the actual file path
let filePath = file.path || file.name || '';
console.log(chalk.gray(` Processing file: ${filePath}`));
// Handle registry paths like "registry/components/ui/button.tsx"
if (filePath.startsWith('registry/')) {
filePath = filePath.replace('registry/', '');
}
// Adjust paths based on project structure
if (options.componentDir && filePath.includes('components/ui/')) {
// Extract filename and ensure lowercase for component files
const fileName = path.basename(filePath);
const normalizedFileName = fileName.toLowerCase();
filePath = path.join(options.componentDir, normalizedFileName);
console.log(chalk.gray(` Target path: ${filePath} (normalized from ${fileName})`));
}
else if (options.libDir && filePath.includes('lib/')) {
// Extract just the filename (keep case for lib files)
const fileName = path.basename(filePath);
filePath = path.join(options.libDir, fileName);
console.log(chalk.gray(` Target path: ${filePath}`));
}
const fullPath = path.resolve(baseDir, filePath);
const dir = path.dirname(fullPath);
// Check if file exists (case-sensitive)
const exists = await fileExists(fullPath);
// Also check for case variants of the same component to ensure we replace shadcn components
const fileName = path.basename(fullPath);
const fileNameLower = fileName.toLowerCase();
const fileNameUpper = fileName.charAt(0).toUpperCase() + fileName.slice(1);
// Check for existing files with different casing
let existingVariants = [];
try {
const dirFiles = await listDirectory(dir);
existingVariants = dirFiles.filter(f => f.toLowerCase() === fileNameLower && f !== fileName);
}
catch (e) {
// Directory might not exist yet
}
// Remove existing case variants before installing
if (existingVariants.length > 0 && overwrite) {
console.log(chalk.blue(`š Found ${existingVariants.length} existing variant(s) for ${fileName}`));
for (const variant of existingVariants) {
const variantPath = path.resolve(dir, variant);
try {
const { unlink } = await import('fs/promises');
await unlink(variantPath);
console.log(chalk.blue(`šļø Removed existing variant: ${variant}`));
}
catch (e) {
console.log(chalk.yellow(`ā ļø Could not remove ${variant}: ${e instanceof Error ? e.message : 'Unknown error'}`));
}
}
}
// Special handling for utils.ts - don't overwrite if it has our enhanced version
if (fullPath.includes('utils.ts') && exists) {
const content = await readFileContent(fullPath);
if (content.includes('Theme utilities') || content.includes('initializeTheme')) {
console.log(chalk.gray(` ā Keeping enhanced ${filePath}`));
writtenFiles.push(filePath);
continue;
}
}
if (exists && !overwrite && !dryRun) {
console.log(chalk.yellow(`ā ļø Skipping existing file: ${filePath}`));
continue;
}
if (exists && overwrite) {
console.log(chalk.blue(`š Overwriting existing file: ${filePath}`));
}
if (dryRun) {
console.log(chalk.blue(`[DRY RUN] Would write: ${filePath}`));
writtenFiles.push(filePath);
continue;
}
// Create directory if it doesn't exist
await createDirectory(dir);
// Process content (handle imports, etc)
let content = file.content;
content = patchImports(content, options);
// Write the file
await writeFileContent(fullPath, content);
// Verify the file was written
const stats = await stat(fullPath);
if (stats && stats.size > 0) {
writtenFiles.push(filePath);
console.log(chalk.green(`ā Created: ${filePath} (${stats.size} bytes)`));
}
else {
throw new Error('File created but empty');
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
errors.push({ file: file.name || 'unknown', error: errorMessage });
console.error(chalk.red(`ā Failed to write ${file.name}: ${errorMessage}`));
}
}
return { writtenFiles, errors };
}
// Patch imports to ensure they work in the target project
export function patchImports(content, options = {}) {
const { isVite = false } = options;
// Ensure @/lib/utils imports are correct
content = content.replace(/from ['"]@\/lib\/utils['"]/g, 'from "@/lib/utils"');
// Fix component imports
content = content.replace(/from ['"]@\/components\/ui\//g, 'from "@/components/ui/');
// Handle registry imports
content = content.replace(/from ['"]registry\/[^'"]+['"]/g, (match) => {
const componentName = match.match(/\/([^/'"]*)['"]$/)?.[1];
return `from "@/components/ui/${componentName}"`;
});
return content;
}
// Update barrel export file (index.ts)
export async function updateBarrelExport(baseDir, options = {}) {
const { dryRun = false } = options;
const indexPath = path.join(baseDir, 'index.ts');
try {
// Ensure directory exists
await createDirectory(baseDir);
// Read all component files
const files = await listDirectory(baseDir);
// Filter for component files (not index.ts itself)
const componentFiles = files
.filter(file => {
return (file.endsWith('.tsx') || file.endsWith('.ts')) &&
file !== 'index.ts' &&
file !== 'index.js';
})
.sort();
// Generate export statements
const exports = componentFiles
.map(file => {
const name = file.replace(/\.(tsx?|jsx?)$/, '');
return `export * from './${name}';`;
})
.join('\n');
if (dryRun) {
console.log(chalk.blue(`[DRY RUN] Would update barrel exports with ${componentFiles.length} components`));
return;
}
// Write the index file
if (exports) {
const content = `// Willow Design System Component Exports\n// Auto-generated - do not edit manually\n\n${exports}\n`;
await writeFileContent(indexPath, content);
console.log(chalk.gray(`š¦ Updated barrel exports: ${baseDir}/index.ts`));
}
}
catch (error) {
console.warn(chalk.yellow(`ā ļø Could not update barrel exports: ${error instanceof Error ? error.message : 'Unknown error'}`));
}
}
// Get component dependencies (only registry components, not npm packages)
export function getComponentDependencies(meta) {
const deps = new Set();
// Only add registry dependencies (other components)
if (meta.registryDependencies) {
meta.registryDependencies.forEach(dep => deps.add(dep));
}
return Array.from(deps);
}
// Get npm package dependencies
export function getNpmDependencies(meta) {
if (!meta.dependencies)
return [];
return [...meta.dependencies];
}
// Install multiple components with dependency resolution
export async function installComponents(components, registry = WILLOW_REGISTRY, options = {}) {
const installed = new Set();
const failed = [];
const toInstall = [...components];
const allNpmDeps = new Set();
// Process components including dependencies
while (toInstall.length > 0) {
const component = toInstall.shift();
// Skip if already installed
if (installed.has(component))
continue;
try {
console.log(chalk.blue(`\nš¦ Installing ${component}...`));
// Fetch metadata
const meta = await fetchComponentMeta(component, registry);
// Add registry dependencies to install queue
const registryDeps = getComponentDependencies(meta);
registryDeps.forEach(dep => {
if (!installed.has(dep) && !toInstall.includes(dep)) {
console.log(chalk.gray(` ā Adding component dependency: ${dep}`));
toInstall.unshift(dep); // Add to front to install deps first
}
});
// Collect npm dependencies
const npmDeps = getNpmDependencies(meta);
npmDeps.forEach(dep => allNpmDeps.add(dep));
// Write component files with proper directory paths
const result = await writeComponentFiles(meta.files, {
...options,
componentDir: options.componentDir,
libDir: options.libDir
});
if (result.errors.length === 0) {
installed.add(component);
}
else {
failed.push({ component, error: result.errors.map(e => e.error).join(', ') });
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
failed.push({ component, error: errorMessage });
console.error(chalk.red(`ā Failed to install ${component}: ${errorMessage}`));
}
}
return {
installed: Array.from(installed),
failed,
npmDependencies: Array.from(allNpmDeps)
};
}
// Install all Willow components
export async function installAllWillowComponents(options = {}) {
const { projectType, includeUnstable, ...installOptions } = options;
if (projectType) {
const componentDir = await getComponentDir(projectType);
console.log(chalk.gray(`Target directory: ${componentDir}`));
}
// Determine which components to install
const componentsToInstall = includeUnstable
? [...STABLE_COMPONENTS, ...UNSTABLE_COMPONENTS]
: [...STABLE_COMPONENTS];
if (!includeUnstable && UNSTABLE_COMPONENTS.length > 0) {
console.log(chalk.yellow(` ā ļø Skipping unstable components: ${UNSTABLE_COMPONENTS.join(', ')}`));
console.log(chalk.gray(` Use --include-unstable to install them`));
}
return installComponents(componentsToInstall, WILLOW_REGISTRY, installOptions);
}