#!/usr/bin/env node import { Command } from 'commander'; import prompts from 'prompts'; import chalk from 'chalk'; import ora from 'ora'; import fs from 'fs-extra'; import path from 'path'; import { fileURLToPath } from 'url'; import { execa } from 'execa'; import { colorThemes } from '../contexts/theme-data'; import { generateTokensCss } from './generate-tokens'; import { SUPPORTED_LANGUAGES, DEFAULT_SELECTION, readLanguagesConfig, writeLanguagesConfig, syncLocaleFiles, generateI18nFile, generateAppTsx, } from './language-config'; // ───────────────────────────────────────────────────────────────────────────── // Project config helpers (.xertica.json) // Persists per-project feature flags so `update` can read them later. // ───────────────────────────────────────────────────────────────────────────── const XERTICA_CONFIG_FILE = '.xertica.json'; interface XerticaConfig { version: 1; hasAssistant: boolean; disableDarkMode?: boolean; themeId?: string; } async function readXerticaConfig(targetDir: string): Promise { const configPath = path.join(targetDir, XERTICA_CONFIG_FILE); if (!(await fs.pathExists(configPath))) return null; try { return (await fs.readJson(configPath)) as XerticaConfig; } catch { return null; } } async function writeXerticaConfig( targetDir: string, config: Partial ): Promise { const configPath = path.join(targetDir, XERTICA_CONFIG_FILE); const existing = (await readXerticaConfig(targetDir)) ?? { version: 1 as const, hasAssistant: false, }; await fs.writeJson(configPath, { ...existing, ...config, version: 1 }, { spaces: 2 }); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Read CLI version from package.json so it always matches the published one, // instead of hardcoding a literal that drifts on every version bump. const pkgJson = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8') ) as { version: string }; // ───────────────────────────────────────────────────────────────────────────── // AuthGuard generator // Generates src/app/components/AuthGuard.tsx based on the selected features. // ───────────────────────────────────────────────────────────────────────────── function generateAuthGuard({ hasLogin, hasHome, hasTemplate, hasAssistant, firstProtectedPath, }: { hasLogin: boolean; hasHome: boolean; hasTemplate: boolean; hasAssistant: boolean; firstProtectedPath: string; }): string { return `import React from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; // ─── Lazy page imports ──────────────────────────────────────────────────────── ${ hasLogin ? `const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage }))); const ForgotPasswordPage = React.lazy(() => import('../../pages/ForgotPasswordPage').then(m => ({ default: m.ForgotPasswordPage }))); const VerifyEmailPage = React.lazy(() => import('../../pages/VerifyEmailPage').then(m => ({ default: m.VerifyEmailPage }))); const ResetPasswordPage = React.lazy(() => import('../../pages/ResetPasswordPage').then(m => ({ default: m.ResetPasswordPage })));` : '' } ${hasHome ? `const HomePage = React.lazy(() => import('../../pages/HomePage').then(m => ({ default: m.HomePage })));` : ''} ${hasTemplate ? `const TemplatePage = React.lazy(() => import('../../pages/TemplatePage').then(m => ({ default: m.TemplatePage })));` : ''} ${hasAssistant ? `const AssistantPage = React.lazy(() => import('../../pages/AssistantPage').then(m => ({ default: m.AssistantPage })));` : ''} // ─── Route guards ───────────────────────────────────────────────────────────── function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, isLoading } = useAuth(); if (isLoading) return null; if (!user) return ; return <>{children}; } ${ hasLogin ? `function GuestRoute({ children }: { children: React.ReactNode }) { const { user, isLoading } = useAuth(); if (isLoading) return null; if (user) return ; return <>{children}; } function LoginPageWithAuth() { const { login } = useAuth(); return ; }` : '' } // ─── Route tree ─────────────────────────────────────────────────────────────── export function AuthGuard() { const { user } = useAuth(); return (
${ hasLogin ? ` } /> } /> } /> } />` : '' } ${hasHome ? ` } />` : ''} ${hasTemplate ? ` } />` : ''} ${hasAssistant ? ` } />` : ''} } /> } />
); } `; } // ───────────────────────────────────────────────────────────────────────────── // Page generators // Generate page files that vary based on whether the assistant is included. // ───────────────────────────────────────────────────────────────────────────── function generateHomePage(hasAssistant: boolean): string { if (hasAssistant) { return `import React from 'react'; import { XerticaAssistant, generateDemoResponse } from 'xertica-ui/assistant'; import { useLayout } from 'xertica-ui/hooks'; import { useNavigate } from 'react-router-dom'; import { AppLayout } from '../app/components/AppLayout'; import { HomeContent } from '../features/home'; import { useAssistantConfig, getMockRichSuggestions, getMockFeedbackOptions } from '../features/assistant'; /** * Home page — thin layout shell. * * Assistant config (suggestions, feedback options) is fetched via React Query. * To connect to a real API replace \`fetchAssistantConfig\` in * \`features/assistant/data/mock.ts\`. */ export function HomePage() { const { assistenteExpanded, toggleAssistente } = useLayout(); const navigate = useNavigate(); const { data: assistantConfig } = useAssistantConfig(); return ( navigate('/settings')} onNavigateFullPage={() => navigate('/assistente')} onEvaluation={(messageId, type, reason) => { // Wire your feedback persistence logic here console.log(\`Avaliação: \${type} na mensagem \${messageId}. Motivo: \${reason}\`); }} /> } > ); } `; } return `import React from 'react'; import { AppLayout } from '../app/components/AppLayout'; import { HomeContent } from '../features/home'; /** * Home page — thin layout shell. */ export function HomePage() { return ( ); } `; } function generateTemplatePage(hasAssistant: boolean): string { if (hasAssistant) { return `import React from 'react'; import { XerticaAssistant } from 'xertica-ui/assistant'; import { useLayout } from 'xertica-ui/hooks'; import { AppLayout } from '../app/components/AppLayout'; import { TemplateContent } from '../features/template'; /** * Template page — thin layout shell. * * Auth state is consumed from \`AuthContext\` via \`AppLayout\` — no props needed. */ export function TemplatePage() { const { assistenteExpanded, toggleAssistente } = useLayout(); return ( console.log('Feedback:', id, type, reason)} /> } > ); } `; } return `import React from 'react'; import { AppLayout } from '../app/components/AppLayout'; import { TemplateContent } from '../features/template'; /** * Template page — thin layout shell. * * Auth state is consumed from \`AuthContext\` via \`AppLayout\` — no props needed. */ export function TemplatePage() { return ( ); } `; } const program = new Command(); program .name('xertica-ui') .description('CLI to initialize Xertica UI projects') .version(pkgJson.version); program .command('init') .description('Initialize a new Xertica UI project') .argument('[directory]', 'Directory to initialize in', '.') .action(async directory => { const targetDir = path.resolve(process.cwd(), directory); const templatesDir = path.resolve(__dirname, '../templates'); console.log(chalk.blue(`🚀 Welcome to Xertica UI CLI! ${chalk.dim(`v${pkgJson.version}`)}`)); const response = await prompts([ { type: 'multiselect', name: 'pages', message: 'Which pages/templates to include?', choices: [ { title: 'Login Page (+ Forgot / Verify / Reset Password)', value: 'login', selected: true, }, { title: 'Home Page', value: 'home', selected: true }, { title: 'Template Page (components showcase)', value: 'template', selected: true }, ], }, { type: 'multiselect', name: 'languages', message: 'Which languages should the app support?', instructions: false, hint: 'Select at least 1. Single-language apps auto-hide the LanguageSelector.', min: 1, choices: SUPPORTED_LANGUAGES.map(l => ({ title: l.label, value: l.code, selected: true, })), }, { type: 'select', name: 'theme', message: 'Select the default color theme for your project:', choices: colorThemes.map(t => ({ title: t.name, description: t.description, value: t.id, })), initial: 0, }, { type: 'confirm', name: 'hasAssistant', message: 'Include AI Assistant? (XerticaAssistant chat page + sidebar variant)', initial: true, }, { type: 'confirm', name: 'enableDarkMode', message: 'Enable dark mode support?', initial: true, }, { type: 'confirm', name: 'install', message: 'Install dependencies automatically?', initial: true, }, ]); // Abort if the user cancelled any prompt (prompts returns undefined on Ctrl+C) if (!response.pages || !response.languages || !response.theme) return; const spinner = ora('Initializing project...').start(); try { await fs.ensureDir(targetDir); const pages = response.pages || []; const hasLogin = pages.includes('login'); const hasHome = pages.includes('home'); const hasTemplate = pages.includes('template'); const hasAssistant = response.hasAssistant ?? true; // Resolve selected languages — fall back to all defaults if the user // somehow ended up with an empty array (the prompt's min:1 should prevent // this, but we defend defensively). const selectedLanguages: string[] = Array.isArray(response.languages) && response.languages.length > 0 ? response.languages : DEFAULT_SELECTION; // 1. Copy root config files const rootFilesToCopy = [ 'index.html', 'vite.config.ts', 'tsconfig.json', 'tsconfig.node.json', 'postcss.config.js', 'vite-env.d.ts', 'eslint.config.js', '.env.example', 'guidelines', 'CLAUDE.md', ]; for (const file of rootFilesToCopy) { const srcPath = path.join(templatesDir, file); if (await fs.pathExists(srcPath)) { await fs.copy(srcPath, path.join(targetDir, file)); } } // 2. Copy package.json const pkgTemplatePath = path.join(templatesDir, 'package.json'); if (await fs.pathExists(pkgTemplatePath)) { const pkgContent = await fs.readJson(pkgTemplatePath); const projectName = path.basename(targetDir) || 'my-xertica-app'; pkgContent.name = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); await fs.writeJson(path.join(targetDir, 'package.json'), pkgContent, { spaces: 2 }); } // 3. Copy src/main.tsx await fs.copy( path.join(templatesDir, 'src', 'main.tsx'), path.join(targetDir, 'src', 'main.tsx') ); const disableDarkMode = response.enableDarkMode === false; // 4. Generate src/app/App.tsx with the user's language selection // (instead of copying the static template, we inject `availableLanguages`) await fs.ensureDir(path.join(targetDir, 'src', 'app')); await fs.writeFile( path.join(targetDir, 'src', 'app', 'App.tsx'), generateAppTsx(selectedLanguages, disableDarkMode) ); // 5. Copy src/app/components/AppLayout.tsx (always needed) await fs.ensureDir(path.join(targetDir, 'src', 'app', 'components')); await fs.copy( path.join(templatesDir, 'src', 'app', 'components', 'AppLayout.tsx'), path.join(targetDir, 'src', 'app', 'components', 'AppLayout.tsx') ); // 6. Copy src/shared/ (always needed — auth helpers, navigation config, types) await fs.copy( path.join(templatesDir, 'src', 'shared'), path.join(targetDir, 'src', 'shared') ); // 6.1 Generate i18n.ts with only the imports/resources for selected languages await fs.writeFile( path.join(targetDir, 'src', 'i18n.ts'), generateI18nFile(selectedLanguages) ); // 6.2 Copy only the selected locale JSON files (no orphan locales) await syncLocaleFiles(templatesDir, targetDir, selectedLanguages, { pruneOthers: true }); // 6.3 Persist the language selection so `update` can remember it await writeLanguagesConfig(targetDir, selectedLanguages); // 6.5 Persist project feature flags (.xertica.json) await writeXerticaConfig(targetDir, { hasAssistant, disableDarkMode, themeId: response.theme }); // 6.4 Copy context await fs.ensureDir(path.join(targetDir, 'src', 'app', 'context')); await fs.copy( path.join(templatesDir, 'src', 'app', 'context', 'AuthContext.tsx'), path.join(targetDir, 'src', 'app', 'context', 'AuthContext.tsx') ); // 7. Copy features based on selections if (hasLogin) { await fs.copy( path.join(templatesDir, 'src', 'features', 'auth'), path.join(targetDir, 'src', 'features', 'auth') ); } if (hasHome) { await fs.copy( path.join(templatesDir, 'src', 'features', 'home'), path.join(targetDir, 'src', 'features', 'home') ); } if (hasTemplate) { await fs.copy( path.join(templatesDir, 'src', 'features', 'template'), path.join(targetDir, 'src', 'features', 'template') ); } // Copy assistant feature only if selected if (hasAssistant) { await fs.copy( path.join(templatesDir, 'src', 'features', 'assistant'), path.join(targetDir, 'src', 'features', 'assistant') ); } // 8. Copy pages based on selections await fs.ensureDir(path.join(targetDir, 'src', 'pages')); const pagesToCopy: string[] = []; if (hasLogin) pagesToCopy.push( 'LoginPage.tsx', 'ForgotPasswordPage.tsx', 'VerifyEmailPage.tsx', 'ResetPasswordPage.tsx' ); if (hasHome) pagesToCopy.push('HomePage.tsx'); if (hasTemplate) pagesToCopy.push('TemplatePage.tsx'); if (hasAssistant) pagesToCopy.push('AssistantPage.tsx'); for (const pageFile of pagesToCopy) { // HomePage and TemplatePage are generated (they vary by hasAssistant) if (pageFile === 'HomePage.tsx') { await fs.writeFile( path.join(targetDir, 'src', 'pages', 'HomePage.tsx'), generateHomePage(hasAssistant) ); continue; } if (pageFile === 'TemplatePage.tsx') { await fs.writeFile( path.join(targetDir, 'src', 'pages', 'TemplatePage.tsx'), generateTemplatePage(hasAssistant) ); continue; } const src = path.join(templatesDir, 'src', 'pages', pageFile); if (await fs.pathExists(src)) { await fs.copy(src, path.join(targetDir, 'src', 'pages', pageFile)); } } // 9. Generate AuthGuard.tsx based on selected pages const firstProtectedPath = hasHome ? '/home' : hasTemplate ? '/template' : '/login'; const authGuardContent = generateAuthGuard({ hasLogin, hasHome, hasTemplate, hasAssistant, firstProtectedPath, }); await fs.writeFile( path.join(targetDir, 'src', 'app', 'components', 'AuthGuard.tsx'), authGuardContent ); // 10. Generate theme tokens const selectedTheme = colorThemes.find(t => t.id === response.theme) || colorThemes[0]; const tokensDir = path.join(targetDir, 'src', 'styles', 'xertica'); await fs.ensureDir(tokensDir); await fs.copy( path.join(templatesDir, 'src', 'styles', 'index.css'), path.join(targetDir, 'src', 'styles', 'index.css') ); await fs.writeFile(path.join(tokensDir, 'tokens.css'), generateTokensCss(selectedTheme)); spinner.succeed('Project initialized successfully!'); if (response.install) { const installSpinner = ora('Installing dependencies...').start(); await execa('npm', ['install'], { cwd: targetDir }); installSpinner.succeed('Dependencies installed!'); } console.log(chalk.green('\n✅ Done! Your Xertica UI project is ready.')); console.log(chalk.cyan(`\n cd ${directory}`)); if (!response.install) { console.log(chalk.cyan(' npm install')); } console.log(chalk.cyan(' npm run dev')); console.log(); console.log(chalk.gray(' Components are imported from the xertica-ui package.')); console.log(chalk.gray(' Customize the theme in src/styles/xertica/tokens.css')); const langLabels = SUPPORTED_LANGUAGES.filter(l => selectedLanguages.includes(l.code)) .map(l => l.label) .join(', '); console.log( chalk.gray( ` Languages: ${langLabels}${selectedLanguages.length === 1 ? ' (monolingual — LanguageSelector hidden)' : ''}` ) ); console.log(chalk.gray(' To add/remove languages later: npx xertica-ui update → Languages')); console.log( chalk.gray(` AI Assistant: ${hasAssistant ? 'included (/assistente)' : 'not included'}`) ); if (!hasAssistant) { console.log(chalk.gray(' To add the assistant later: npx xertica-ui update → Assistant')); } } catch (error) { spinner.fail('Failed to initialize project'); console.error(error); } }); program .command('update') .alias('update-theme') .description('Update theme or project files to the latest version') .action(async () => { const targetDir = process.cwd(); console.log(chalk.blue(`🔧 Xertica UI CLI ${chalk.dim(`v${pkgJson.version}`)}`)); const currentConfig = await readXerticaConfig(targetDir); const { updateType } = await prompts({ type: 'select', name: 'updateType', message: 'What do you want to update?', choices: [ { title: 'Theme only', description: 'Change the color tokens (tokens.css)', value: 'theme', }, { title: 'Languages', description: 'Add or remove supported languages (pt-BR, en, es, …)', value: 'languages', }, { title: 'Assistant', description: currentConfig?.hasAssistant ? 'Remove the AI Assistant from your project' : 'Add the AI Assistant to your project', value: 'assistant', }, { title: 'Dark Mode', description: currentConfig?.disableDarkMode ? 'Enable dark mode support in your project' : 'Disable dark mode support in your project', value: 'darkmode', }, { title: 'Project files', description: 'Update app shell, shared, features and pages to a specific version', value: 'project', }, ], }); if (!updateType) return; // ── Theme update ───────────────────────────────────────────────────────── if (updateType === 'theme') { const currentThemeId = currentConfig?.themeId ?? 'xertica-original'; const currentThemeIndex = Math.max( 0, colorThemes.findIndex(t => t.id === currentThemeId) ); const currentThemeName = colorThemes[currentThemeIndex]?.name ?? 'Xertica'; console.log(chalk.gray(` Current theme: ${currentThemeName}`)); const { theme } = await prompts({ type: 'select', name: 'theme', message: 'Select the new color theme:', choices: colorThemes.map(t => ({ title: t.name, description: t.description, value: t.id, })), initial: currentThemeIndex, }); if (!theme) return; const spinner = ora('Updating theme...').start(); try { const tokensPath = path.join(targetDir, 'src', 'styles', 'xertica', 'tokens.css'); const selectedTheme = colorThemes.find(t => t.id === theme); if (selectedTheme) { await fs.ensureDir(path.dirname(tokensPath)); await fs.writeFile(tokensPath, generateTokensCss(selectedTheme)); await writeXerticaConfig(targetDir, { themeId: selectedTheme.id }); spinner.succeed(`Theme updated to "${selectedTheme.name}" successfully!`); console.log(chalk.gray(' File updated: src/styles/xertica/tokens.css')); } else { spinner.fail('Theme not found.'); } } catch (error) { spinner.fail('Failed to update theme'); console.error(error); } return; } // ── Languages update (add / remove) ────────────────────────────────────── if (updateType === 'languages') { // Resolve current selection — read from .languages.json if present, // else inspect the locales/ folder to infer it (for projects scaffolded // before this feature shipped). const persistedCodes = await readLanguagesConfig(targetDir); let currentCodes: string[] = persistedCodes ?? []; if (currentCodes.length === 0) { const localesDir = path.join(targetDir, 'src', 'locales'); if (await fs.pathExists(localesDir)) { const entries = await fs.readdir(localesDir); // Accept both the new folder layout (locales//) and the legacy // flat layout (locales/.json) when inferring the current set. currentCodes = SUPPORTED_LANGUAGES.filter( l => entries.includes(l.jsonFile) || entries.includes(`${l.jsonFile}.json`) ).map(l => l.code); } if (currentCodes.length === 0) currentCodes = DEFAULT_SELECTION; } console.log( chalk.cyan( `\nCurrent languages: ${ SUPPORTED_LANGUAGES.filter(l => currentCodes.includes(l.code)) .map(l => l.label) .join(', ') || '(none)' }\n` ) ); const { newCodes } = await prompts({ type: 'multiselect', name: 'newCodes', message: 'Select the languages this project should support:', instructions: false, hint: 'Press SPACE to toggle. At least 1 required. Single-language apps auto-hide the LanguageSelector.', min: 1, choices: SUPPORTED_LANGUAGES.map(l => ({ title: l.label, value: l.code, selected: currentCodes.includes(l.code), })), }); if (!Array.isArray(newCodes) || newCodes.length === 0) { console.log(chalk.gray('Update cancelled.')); return; } // Compute add/remove diff for the user-facing summary const toAdd = newCodes.filter((c: string) => !currentCodes.includes(c)); const toRemove = currentCodes.filter(c => !newCodes.includes(c)); if (toAdd.length === 0 && toRemove.length === 0) { console.log(chalk.gray('No changes — selection matches the current set.')); return; } const summary: string[] = []; if (toAdd.length > 0) summary.push(chalk.green(` + ${toAdd.join(', ')}`)); if (toRemove.length > 0) summary.push(chalk.red(` - ${toRemove.join(', ')}`)); console.log(`\n${summary.join('\n')}\n`); const { confirmed } = await prompts({ type: 'confirm', name: 'confirmed', message: chalk.yellow( `⚠️ This will regenerate src/app/App.tsx and src/i18n.ts (preserving language-only changes). Continue?` ), initial: true, }); if (!confirmed) { console.log(chalk.gray('Update cancelled.')); return; } const spinner = ora('Updating languages...').start(); try { // The freshly-installed library may not be present in this flow, so we // read locale JSON sources from `node_modules/xertica-ui/templates` // (installed when the project was created) — fallback to package // directory lookup. const installedTemplatesDir = path.join( targetDir, 'node_modules', 'xertica-ui', 'templates' ); const templatesSourceDir = (await fs.pathExists(installedTemplatesDir)) ? installedTemplatesDir : path.resolve(__dirname, '../templates'); // 1) Sync locale JSON files: copy newly-added, prune removed const { copied, removed } = await syncLocaleFiles(templatesSourceDir, targetDir, newCodes, { pruneOthers: true, }); // 2) Regenerate i18n.ts so imports/resources reflect the new set await fs.writeFile(path.join(targetDir, 'src', 'i18n.ts'), generateI18nFile(newCodes)); // 3) Regenerate App.tsx so the `availableLanguages` prop matches await fs.writeFile( path.join(targetDir, 'src', 'app', 'App.tsx'), generateAppTsx(newCodes, currentConfig?.disableDarkMode ?? false) ); // 4) Persist the new selection await writeLanguagesConfig(targetDir, newCodes); spinner.succeed('Languages updated successfully!'); if (copied.length > 0) console.log(chalk.green(` Copied: ${copied.join(', ')}`)); if (removed.length > 0) console.log(chalk.red(` Removed: ${removed.join(', ')}`)); if (newCodes.length === 1) { console.log( chalk.gray(` Project is now monolingual — the LanguageSelector will auto-hide.`) ); } } catch (error) { spinner.fail('Failed to update languages'); console.error(error); } return; } // ── Assistant add / remove ──────────────────────────────────────────────── if (updateType === 'assistant') { // Determine current state: prefer persisted config, fall back to file presence // (handles projects scaffolded before .xertica.json existed). let currentlyHas: boolean; if (currentConfig !== null) { currentlyHas = currentConfig.hasAssistant; } else { const assistantFeatureDir = path.join(targetDir, 'src', 'features', 'assistant'); const assistantPage = path.join(targetDir, 'src', 'pages', 'AssistantPage.tsx'); currentlyHas = (await fs.pathExists(assistantFeatureDir)) || (await fs.pathExists(assistantPage)); // Persist the inferred state so future runs don't need to infer again await writeXerticaConfig(targetDir, { hasAssistant: currentlyHas }); } console.log( chalk.cyan( `\nAI Assistant is currently: ${currentlyHas ? chalk.green('enabled') : chalk.red('disabled')}\n` ) ); const { action } = await prompts({ type: 'select', name: 'action', message: currentlyHas ? 'Remove the AI Assistant from your project?' : 'Add the AI Assistant to your project?', choices: currentlyHas ? [ { title: 'Remove assistant', description: 'Deletes AssistantPage and assistant feature files', value: 'remove', }, { title: 'Cancel', value: 'cancel' }, ] : [ { title: 'Add assistant', description: 'Copies AssistantPage and assistant feature files', value: 'add', }, { title: 'Cancel', value: 'cancel' }, ], }); if (!action || action === 'cancel') { console.log(chalk.gray('Update cancelled.')); return; } const { confirmed } = await prompts({ type: 'confirm', name: 'confirmed', message: chalk.yellow( action === 'remove' ? '⚠️ This will delete src/features/assistant/ and src/pages/AssistantPage.tsx and regenerate AuthGuard.tsx. Continue?' : '⚠️ This will copy src/features/assistant/ and src/pages/AssistantPage.tsx and regenerate AuthGuard.tsx. Continue?' ), initial: action === 'add', }); if (!confirmed) { console.log(chalk.gray('Update cancelled.')); return; } const spinner = ora( action === 'add' ? 'Adding assistant...' : 'Removing assistant...' ).start(); try { const installedTemplatesDir = path.join( targetDir, 'node_modules', 'xertica-ui', 'templates' ); const templatesSourceDir = (await fs.pathExists(installedTemplatesDir)) ? installedTemplatesDir : path.resolve(__dirname, '../templates'); // Infer current page set from the pages directory const pagesDir = path.join(targetDir, 'src', 'pages'); const existingPages = (await fs.pathExists(pagesDir)) ? await fs.readdir(pagesDir) : []; const hasLogin = existingPages.includes('LoginPage.tsx'); const hasHome = existingPages.includes('HomePage.tsx'); const hasTemplate = existingPages.includes('TemplatePage.tsx'); const firstProtectedPath = hasHome ? '/home' : hasTemplate ? '/template' : '/login'; // Read persisted language selection so AuthGuard can be regenerated correctly const persistedCodes = await readLanguagesConfig(targetDir); const selectedCodes = persistedCodes && persistedCodes.length > 0 ? persistedCodes : DEFAULT_SELECTION; if (action === 'add') { // Copy assistant feature await fs.copy( path.join(templatesSourceDir, 'src', 'features', 'assistant'), path.join(targetDir, 'src', 'features', 'assistant'), { overwrite: true } ); // Copy AssistantPage await fs.copy( path.join(templatesSourceDir, 'src', 'pages', 'AssistantPage.tsx'), path.join(targetDir, 'src', 'pages', 'AssistantPage.tsx'), { overwrite: true } ); } else { // Remove assistant feature await fs.remove(path.join(targetDir, 'src', 'features', 'assistant')); await fs.remove(path.join(targetDir, 'src', 'pages', 'AssistantPage.tsx')); } // Regenerate pages and AuthGuard reflecting the new assistant state const newHasAssistant = action === 'add'; if (hasHome) { await fs.writeFile( path.join(targetDir, 'src', 'pages', 'HomePage.tsx'), generateHomePage(newHasAssistant) ); } if (hasTemplate) { await fs.writeFile( path.join(targetDir, 'src', 'pages', 'TemplatePage.tsx'), generateTemplatePage(newHasAssistant) ); } await fs.writeFile( path.join(targetDir, 'src', 'app', 'components', 'AuthGuard.tsx'), generateAuthGuard({ hasLogin, hasHome, hasTemplate, hasAssistant: newHasAssistant, firstProtectedPath, }) ); // Persist the updated flag await writeXerticaConfig(targetDir, { hasAssistant: newHasAssistant }); spinner.succeed( action === 'add' ? 'AI Assistant added successfully!' : 'AI Assistant removed successfully!' ); if (action === 'add') { console.log(chalk.gray('\n Route /assistente is now available.')); console.log(chalk.gray(' Configure your Gemini API key in VITE_GEMINI_API_KEY.')); } else { console.log(chalk.gray('\n Assistant files removed and AuthGuard updated.')); } } catch (error) { spinner.fail('Failed to update assistant'); console.error(error); } return; } // ── Dark Mode update (enable / disable) ────────────────────────────────── if (updateType === 'darkmode') { const currentlyDisabled = currentConfig?.disableDarkMode ?? false; const { enableDarkMode } = await prompts({ type: 'confirm', name: 'enableDarkMode', message: currentlyDisabled ? 'Enable dark mode support in your project?' : 'Disable dark mode support in your project? (This will hide the toggle and force light mode)', initial: !currentlyDisabled, }); if (enableDarkMode === undefined) return; const newDisableDarkMode = !enableDarkMode; const spinner = ora( newDisableDarkMode ? 'Disabling dark mode...' : 'Enabling dark mode...' ).start(); try { // Persist the selection await writeXerticaConfig(targetDir, { disableDarkMode: newDisableDarkMode }); // Regenerate App.tsx with the new dark mode flag const persistedCodes = await readLanguagesConfig(targetDir); const selectedCodes = persistedCodes && persistedCodes.length > 0 ? persistedCodes : DEFAULT_SELECTION; await fs.writeFile( path.join(targetDir, 'src', 'app', 'App.tsx'), generateAppTsx(selectedCodes, newDisableDarkMode) ); spinner.succeed( newDisableDarkMode ? 'Dark mode disabled successfully! (Locked to Light Mode)' : 'Dark mode enabled successfully!' ); } catch (error) { spinner.fail('Failed to update dark mode configuration'); console.error(error); } return; } // ── Project files update ────────────────────────────────────────────────── const { versionType } = await prompts({ type: 'select', name: 'versionType', message: 'Which version do you want to update to?', choices: [ { title: 'Latest', description: 'Install the latest published version', value: 'latest' }, { title: 'Specific version', description: 'Enter a version number (e.g. 2.0.2)', value: 'specific', }, ], }); if (!versionType) return; let targetVersion = 'latest'; if (versionType === 'specific') { const { version } = await prompts({ type: 'text', name: 'version', message: 'Enter the version (e.g. 2.0.2):', validate: v => /^\d+\.\d+\.\d+/.test(v.trim()) ? true : 'Enter a valid semver (e.g. 2.0.2)', }); if (!version) return; targetVersion = version.trim(); } const { filesToUpdate } = await prompts({ type: 'multiselect', name: 'filesToUpdate', message: 'Select which parts of the project to update:', choices: [ { title: 'App shell (src/app/)', description: 'App.tsx, AppLayout.tsx', value: 'app', selected: true, }, { title: 'Shared utilities (src/shared/)', description: 'auth.ts, navigation.ts, types', value: 'shared', selected: true, }, { title: 'Features (src/features/)', description: 'auth, home, template UI components', value: 'features', selected: true, }, { title: 'Pages (src/pages/)', description: 'Thin page wrapper components', value: 'pages', selected: true, }, { title: 'Root config files', description: 'vite.config.ts, tsconfig.json, etc.', value: 'config', selected: false, }, ], }); if (!filesToUpdate || filesToUpdate.length === 0) return; const { confirmed } = await prompts({ type: 'confirm', name: 'confirmed', message: chalk.yellow( `⚠️ This will overwrite the selected files. Local changes will be lost. Continue?` ), initial: false, }); if (!confirmed) { console.log(chalk.gray('Update cancelled.')); return; } const spinner = ora(`Installing xertica-ui@${targetVersion}...`).start(); try { // Install the target version in the consumer project await execa('npm', ['install', `xertica-ui@${targetVersion}`], { cwd: targetDir }); spinner.text = 'Copying updated files...'; // Templates now come from the freshly installed version const updatedTemplatesDir = path.join(targetDir, 'node_modules', 'xertica-ui', 'templates'); if (filesToUpdate.includes('app')) { // AppLayout is always safe to overwrite (no per-project config baked in) await fs.copy( path.join(updatedTemplatesDir, 'src', 'app', 'components', 'AppLayout.tsx'), path.join(targetDir, 'src', 'app', 'components', 'AppLayout.tsx'), { overwrite: true } ); // For App.tsx and i18n.ts we must preserve the user's language selection. // Read it from .languages.json (or fall back to inspecting locales/ for // projects scaffolded before the file existed). const persistedCodes = await readLanguagesConfig(targetDir); let selectedCodes: string[] = persistedCodes ?? []; if (selectedCodes.length === 0) { const localesDir = path.join(targetDir, 'src', 'locales'); if (await fs.pathExists(localesDir)) { const entries = await fs.readdir(localesDir); // Accept both the new folder layout and the legacy flat layout selectedCodes = SUPPORTED_LANGUAGES.filter( l => entries.includes(l.jsonFile) || entries.includes(`${l.jsonFile}.json`) ).map(l => l.code); } if (selectedCodes.length === 0) selectedCodes = DEFAULT_SELECTION; // Persist the inferred selection so future updates have it cached await writeLanguagesConfig(targetDir, selectedCodes); } const projectConfig = await readXerticaConfig(targetDir); // Regenerate App.tsx and i18n.ts honoring the persisted language set await fs.writeFile( path.join(targetDir, 'src', 'app', 'App.tsx'), generateAppTsx(selectedCodes, projectConfig?.disableDarkMode ?? false) ); await fs.writeFile(path.join(targetDir, 'src', 'i18n.ts'), generateI18nFile(selectedCodes)); // Refresh locale JSON files for the selected languages (keys grow over // library updates) — but prune any orphans from prior selections. await syncLocaleFiles(updatedTemplatesDir, targetDir, selectedCodes, { pruneOthers: true, }); // Regenerate AuthGuard preserving the current page set and assistant flag const pagesDir = path.join(targetDir, 'src', 'pages'); const existingPages = (await fs.pathExists(pagesDir)) ? await fs.readdir(pagesDir) : []; const hasLoginP = existingPages.includes('LoginPage.tsx'); const hasHomeP = existingPages.includes('HomePage.tsx'); const hasTemplateP = existingPages.includes('TemplatePage.tsx'); const hasAssistantP = projectConfig?.hasAssistant ?? existingPages.includes('AssistantPage.tsx'); const firstProtectedP = hasHomeP ? '/home' : hasTemplateP ? '/template' : '/login'; await fs.writeFile( path.join(targetDir, 'src', 'app', 'components', 'AuthGuard.tsx'), generateAuthGuard({ hasLogin: hasLoginP, hasHome: hasHomeP, hasTemplate: hasTemplateP, hasAssistant: hasAssistantP, firstProtectedPath: firstProtectedP, }) ); } if (filesToUpdate.includes('shared')) { await fs.copy( path.join(updatedTemplatesDir, 'src', 'shared'), path.join(targetDir, 'src', 'shared'), { overwrite: true } ); } if (filesToUpdate.includes('features')) { // Only update feature directories that already exist in the project. // 'assistant' is only present if it was included during init (or added via update → Assistant). for (const feature of ['auth', 'home', 'template', 'assistant']) { const destFeature = path.join(targetDir, 'src', 'features', feature); const srcFeature = path.join(updatedTemplatesDir, 'src', 'features', feature); if ((await fs.pathExists(destFeature)) && (await fs.pathExists(srcFeature))) { await fs.copy(srcFeature, destFeature, { overwrite: true }); } } } if (filesToUpdate.includes('pages')) { const pagesDir = path.join(targetDir, 'src', 'pages'); const srcPagesDir = path.join(updatedTemplatesDir, 'src', 'pages'); if ((await fs.pathExists(pagesDir)) && (await fs.pathExists(srcPagesDir))) { const projectCfg = await readXerticaConfig(targetDir); const existingPages = await fs.readdir(pagesDir); const assistantEnabled = projectCfg?.hasAssistant ?? existingPages.includes('AssistantPage.tsx'); for (const pageFile of existingPages) { // Generated pages: regenerate instead of copy so assistant imports stay correct if (pageFile === 'HomePage.tsx') { await fs.writeFile( path.join(pagesDir, 'HomePage.tsx'), generateHomePage(assistantEnabled) ); continue; } if (pageFile === 'TemplatePage.tsx') { await fs.writeFile( path.join(pagesDir, 'TemplatePage.tsx'), generateTemplatePage(assistantEnabled) ); continue; } const src = path.join(srcPagesDir, pageFile); if (await fs.pathExists(src)) { await fs.copy(src, path.join(pagesDir, pageFile), { overwrite: true }); } } } } if (filesToUpdate.includes('config')) { // Config files are inside the templates/ directory (same level as src/) const configFiles = [ 'vite.config.ts', 'tsconfig.json', 'tsconfig.node.json', 'postcss.config.js', ]; for (const file of configFiles) { const src = path.join(updatedTemplatesDir, file); if (await fs.pathExists(src)) { await fs.copy(src, path.join(targetDir, file), { overwrite: true }); } } } spinner.succeed(`Project updated to xertica-ui@${targetVersion} successfully!`); console.log(chalk.gray('\n Run npm run dev to start the development server.')); } catch (error) { spinner.fail('Failed to update project'); console.error(error); } }); program.parse();