UNPKG

@pitifulhawk/flash-up

Version:

Interactive project scaffolder for modern web applications

676 lines (656 loc) 28.6 kB
import * as path from "path"; import { fileURLToPath } from "url"; import { Framework, CSSFramework, UILibrary, HTTPClient, ProjectLanguage, } from "../types/index.js"; import { PackageManagerUtil } from "./package-manager.js"; import { TemplateManager } from "./template-manager.js"; import { executeCommand } from "../utils/shell.js"; import { writeTextFile, createDirectory } from "../utils/file-system.js"; import { logger } from "../ui/logger.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ADDON_CONFIGS = { tailwind: { id: "tailwind", displayName: "Tailwind CSS", description: "Utility-first CSS framework", devDependencies: ["source-map-js", "postcss"], compatibleFrameworks: [ Framework.REACT, Framework.NEXTJS, Framework.EXPRESS, ], }, bootstrap: { id: "bootstrap", displayName: "Bootstrap", description: "Popular CSS framework", dependencies: ["bootstrap"], devDependencies: ["@types/bootstrap"], compatibleFrameworks: [ Framework.REACT, Framework.NEXTJS, Framework.EXPRESS, ], }, shadcn: { id: "shadcn", displayName: "Shadcn UI", description: " Beautiful and accessible React components", postInstallCommands: ["npx shadcn@latest init -y"], compatibleFrameworks: [Framework.REACT, Framework.NEXTJS], requiredCSSFramework: CSSFramework.TAILWIND, }, chakra: { id: "chakra", displayName: "Chakra UI", description: "Modular and accessible component library", dependencies: ["@chakra-ui/react", "@emotion/react", "next-themes"], devDependencies: ["vite-tsconfig-paths", "source-map-js"], compatibleFrameworks: [Framework.REACT, Framework.NEXTJS], }, aceternity: { id: "aceternity", displayName: "Aceternity UI", description: " Beautiful animated components", dependencies: ["motion", "clsx", "tailwind-merge"], compatibleFrameworks: [Framework.REACT, Framework.NEXTJS], requiredCSSFramework: CSSFramework.TAILWIND, }, axios: { id: "axios", displayName: "Axios", description: "Promise-based HTTP client", dependencies: ["axios"], devDependencies: ["@types/axios"], templateFiles: ["axios-config.ts"], compatibleFrameworks: [ Framework.REACT, Framework.NEXTJS, Framework.EXPRESS, ], }, fetch: { id: "fetch", displayName: "Fetch API", description: "Native fetch with utilities", templateFiles: ["fetch-utils.ts"], compatibleFrameworks: [ Framework.REACT, Framework.NEXTJS, Framework.EXPRESS, ], }, eslint: { id: "eslint", displayName: "ESLint + Prettier", description: "Code linting and formatting", devDependencies: [ "eslint", "prettier", "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", "eslint-config-prettier", "eslint-plugin-prettier", ], templateFiles: [".eslintrc.json", ".prettierrc"], compatibleFrameworks: [ Framework.REACT, Framework.NEXTJS, Framework.EXPRESS, ], }, }; export class AddOnManager { targetPath; framework; packageManager; templateManager; language; constructor(targetPath, framework, packageManager, language = ProjectLanguage.TYPESCRIPT) { this.targetPath = targetPath; this.framework = framework; this.packageManager = new PackageManagerUtil(packageManager); this.templateManager = new TemplateManager(); this.language = language; } async installAddOns(config) { try { if (config.cssFramework && config.cssFramework !== CSSFramework.NONE) { await this.installCSSFramework(config.cssFramework); } if (config.uiLibrary && config.uiLibrary !== UILibrary.NONE) { await this.installUILibrary(config.uiLibrary, config.cssFramework); } if (config.httpClient) { await this.installHTTPClient(config.httpClient); } if (config.includeLinting) { await this.installLinting(); } return true; } catch (error) { logger.error(`Add-on installation failed: ${error.message}`); return false; } } async installCSSFramework(cssFramework) { const configKey = cssFramework === CSSFramework.TAILWIND ? "tailwind" : "bootstrap"; const config = ADDON_CONFIGS[configKey]; if (!config) { throw new Error(`Unknown CSS framework: ${cssFramework}`); } await this.installAddOnConfig(config); if (cssFramework === CSSFramework.TAILWIND) { await this.setupTailwindCSS(); } } async installUILibrary(uiLibrary, cssFramework) { const config = ADDON_CONFIGS[uiLibrary]; if (!config) { throw new Error(`Unknown UI library: ${uiLibrary}`); } if (config.requiredCSSFramework && config.requiredCSSFramework !== cssFramework) { throw new Error(`${config.displayName} requires ${config.requiredCSSFramework} CSS framework`); } await this.installAddOnConfig(config); switch (uiLibrary) { case UILibrary.SHADCN: await this.setupShadcnUI(); break; case UILibrary.CHAKRA: await this.setupChakraUI(); break; case UILibrary.ACETERNITY: await this.setupAceternityUI(); break; } } async installHTTPClient(httpClient) { const configKey = httpClient === HTTPClient.AXIOS ? "axios" : "fetch"; const config = ADDON_CONFIGS[configKey]; if (!config) { throw new Error(`Unknown HTTP client: ${httpClient}`); } await this.installAddOnConfig(config); if (httpClient === HTTPClient.AXIOS) { await this.createAxiosConfig(); } else { await this.createFetchUtils(); } } async installLinting() { const config = ADDON_CONFIGS["eslint"]; if (!config) { throw new Error("ESLint configuration not found"); } await this.installAddOnConfig(config); } async installAddOnConfig(config) { if (config.dependencies && config.dependencies.length > 0) { const result = await this.packageManager.addPackages(config.dependencies, this.targetPath, false, true); if (!result.success) { throw new Error(`Failed to install ${config.displayName} dependencies: ${result.stderr}`); } } if (config.devDependencies && config.devDependencies.length > 0) { const result = await this.packageManager.addPackages(config.devDependencies, this.targetPath, true, true); if (!result.success) { throw new Error(`Failed to install ${config.displayName} dev dependencies: ${result.stderr}`); } } if (config.templateFiles && config.templateFiles.length > 0) { for (const templateFile of config.templateFiles) { const results = await this.templateManager.copyTemplateFiles(templateFile, this.targetPath); for (const result of results) { if (!result.success) { logger.warn(`Failed to copy template file: ${result.error}`); } } } } if (config.postInstallCommands && config.postInstallCommands.length > 0) { for (const command of config.postInstallCommands) { const parts = command.split(" "); const cmd = parts[0]; const args = parts.slice(1); if (cmd) { const result = await executeCommand(cmd, args, { cwd: this.targetPath, stdio: "inherit", }); if (!result.success) { logger.warn(`Post-install command failed: ${command}`); } } } } } async setupTailwindCSS() { if (this.framework === Framework.REACT) { await this.setupTailwindVite(); } else if (this.framework === Framework.NEXTJS) { await this.setupTailwindNextJS(); } logger.debug(`Tailwind CSS v4 setup completed for ${this.framework}`); } async setupTailwindVite() { const { readTextFile, writeTextFile, pathExists } = await import("../utils/file-system.js"); const { executeCommand } = await import("../utils/shell.js"); const installResult = await executeCommand(this.packageManager.getConfig().name, [this.packageManager.getConfig().addCmd, "-D", "tailwindcss", "@tailwindcss/vite"], { cwd: this.targetPath }); if (!installResult.success) { throw new Error(`Failed to install Tailwind dependencies: ${installResult.stderr}`); } const indexCssPath = path.join(this.targetPath, "src/index.css"); const tailwindImport = `@import "tailwindcss";\n`; await writeTextFile(indexCssPath, tailwindImport); const viteConfigPaths = [ path.join(this.targetPath, "vite.config.ts"), path.join(this.targetPath, "vite.config.js") ]; for (const viteConfigPath of viteConfigPaths) { if (await pathExists(viteConfigPath)) { const content = await readTextFile(viteConfigPath); if (content && !content.includes('@tailwindcss/vite')) { let updatedContent = content.replace(/(import react from ['"]@vitejs\/plugin-react['"])/, `$1\nimport tailwindcss from '@tailwindcss/vite'`); updatedContent = updatedContent.replace(/plugins:\s*\[\s*react\(\)\s*\]/, 'plugins: [react(), tailwindcss()]'); await writeTextFile(viteConfigPath, updatedContent); } break; } } } async setupTailwindNextJS() { const { writeTextFile } = await import("../utils/file-system.js"); const { executeCommand } = await import("../utils/shell.js"); const installResult = await executeCommand(this.packageManager.getConfig().name, [this.packageManager.getConfig().addCmd, "-D", "tailwindcss", "@tailwindcss/postcss", "@tailwindcss/cli", "source-map-js", "postcss"], { cwd: this.targetPath }); if (!installResult.success) { throw new Error(`Failed to install Tailwind dependencies: ${installResult.stderr}`); } const globalsCssPath = path.join(this.targetPath, "src/app/globals.css"); const tailwindContent = `@import "tailwindcss";`; await writeTextFile(globalsCssPath, tailwindContent); await this.ensureGlobalsCssImport(); const postcssConfigPath = path.join(this.targetPath, "postcss.config.js"); const postcssConfig = `module.exports = { plugins: { "@tailwindcss/postcss": {}, }, };`; await writeTextFile(postcssConfigPath, postcssConfig); logger.debug(`Created PostCSS config for Next.js 15 + Turbopack: ${postcssConfigPath}`); } async ensureGlobalsCssImport() { const { pathExists, readTextFile, writeTextFile } = await import("../utils/file-system.js"); const layoutPaths = [ path.join(this.targetPath, "src/app/layout.js"), path.join(this.targetPath, "src/app/layout.tsx") ]; for (const layoutPath of layoutPaths) { if (await pathExists(layoutPath)) { const content = await readTextFile(layoutPath); if (content && !content.includes("./globals.css")) { const updatedContent = `import "./globals.css"\n${content}`; await writeTextFile(layoutPath, updatedContent); logger.debug(`Added globals.css import to ${layoutPath}`); } break; } } } async setupShadcnUI() { logger.debug("Shadcn UI setup completed"); } async setupChakraUI() { if (this.framework === Framework.REACT) { await this.createChakraReactSetup(); } else if (this.framework === Framework.NEXTJS) { await this.createChakraNextSetup(); } } async setupAceternityUI() { const { writeTextFile, createDirectory } = await import("../utils/file-system.js"); const libDir = path.join(this.targetPath, "src/lib"); await createDirectory(libDir); const utilsContent = `import { ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }`; await writeTextFile(path.join(libDir, "utils.ts"), utilsContent); if (this.framework === Framework.NEXTJS) { await this.setupMotionOverrides(); } logger.debug("Aceternity UI setup completed"); } async setupMotionOverrides() { const { readJsonFile, writeJsonFile } = await import("../utils/file-system.js"); try { const packageJsonPath = path.join(this.targetPath, "package.json"); const packageJson = await readJsonFile(packageJsonPath); if (packageJson) { const nextVersion = packageJson.dependencies?.next || packageJson.devDependencies?.next; if (nextVersion && nextVersion.includes('15')) { packageJson.overrides = { ...packageJson.overrides, motion: { react: "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106" } }; await writeJsonFile(packageJsonPath, packageJson, 2); logger.debug("Added motion overrides for Next.js 15 + React 19 compatibility"); } } } catch (error) { logger.warn(`Could not add motion overrides: ${error}`); } } async createAxiosConfig() { const libDir = path.join(this.targetPath, "src", "lib"); await createDirectory(libDir); const axiosConfig = `import axios from 'axios'; const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api', timeout: 10000, }); // Request interceptor api.interceptors.request.use( (config) => { // Add auth token if available const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = \`Bearer \${token}\`; } return config; }, (error) => { return Promise.reject(error); } ); // Response interceptor api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // Handle unauthorized access localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(error); } ); export default api; `; const apiFileName = this.language === ProjectLanguage.TYPESCRIPT ? "api.ts" : "api.js"; await writeTextFile(path.join(libDir, apiFileName), axiosConfig); } async createFetchUtils() { const utilsDir = path.join(this.targetPath, "src", "utils"); await createDirectory(utilsDir); const fetchUtils = `const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; interface FetchOptions extends RequestInit { timeout?: number; } class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; } } async function fetchWithTimeout(url: string, options: FetchOptions = {}): Promise<Response> { const { timeout = 10000, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); throw error; } } export async function apiRequest<T>( endpoint: string, options: FetchOptions = {} ): Promise<T> { const url = \`\${API_BASE_URL}\${endpoint}\`; // Add auth token if available const token = localStorage.getItem('token'); const headers = { 'Content-Type': 'application/json', ...(token && { Authorization: \`Bearer \${token}\` }), ...options.headers, }; const response = await fetchWithTimeout(url, { ...options, headers, }); if (!response.ok) { if (response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } throw new ApiError(response.status, \`HTTP \${response.status}: \${response.statusText}\`); } return response.json(); } export const api = { get: <T>(endpoint: string, options?: FetchOptions) => apiRequest<T>(endpoint, { ...options, method: 'GET' }), post: <T>(endpoint: string, data?: any, options?: FetchOptions) => apiRequest<T>(endpoint, { ...options, method: 'POST', body: data ? JSON.stringify(data) : undefined, }), put: <T>(endpoint: string, data?: any, options?: FetchOptions) => apiRequest<T>(endpoint, { ...options, method: 'PUT', body: data ? JSON.stringify(data) : undefined, }), delete: <T>(endpoint: string, options?: FetchOptions) => apiRequest<T>(endpoint, { ...options, method: 'DELETE' }), }; `; const apiUtilsFileName = this.language === ProjectLanguage.TYPESCRIPT ? "api.ts" : "api.js"; await writeTextFile(path.join(utilsDir, apiUtilsFileName), fetchUtils); } async createChakraReactSetup() { const { readTextFile, writeTextFile, pathExists } = await import("../utils/file-system.js"); await this.ensureChakraPathAliases(); const viteConfigPaths = [ path.join(this.targetPath, "vite.config.ts"), path.join(this.targetPath, "vite.config.js") ]; for (const viteConfigPath of viteConfigPaths) { if (await pathExists(viteConfigPath)) { const content = await readTextFile(viteConfigPath); if (content && !content.includes('tsconfigPaths')) { let updatedContent = content.replace(/(import { defineConfig } from ['"]vite['"])/, `$1\nimport tsconfigPaths from 'vite-tsconfig-paths'`); updatedContent = updatedContent.replace(/plugins:\s*\[(.*?)\]/s, (_, plugins) => { const pluginList = plugins.split(',').map((p) => p.trim()).filter(Boolean); pluginList.push('tsconfigPaths()'); return `plugins: [${pluginList.join(', ')}]`; }); await writeTextFile(viteConfigPath, updatedContent); } break; } } await this.createChakraReactProvider(); logger.debug("Chakra UI React setup completed with provider files"); } async createChakraReactProvider() { const { writeTextFile, createDirectory } = await import("../utils/file-system.js"); const componentsDir = path.join(this.targetPath, "src/components"); await createDirectory(componentsDir); const isTypeScript = this.language === ProjectLanguage.TYPESCRIPT; const extension = isTypeScript ? ".tsx" : ".js"; const providerContent = isTypeScript ? `import { ChakraProvider, defaultSystem } from '@chakra-ui/react';\nimport { ReactNode } from 'react';\n\ninterface ProviderProps {\n children: ReactNode;\n}\n\nexport function Provider({ children }: ProviderProps) {\n return <ChakraProvider value={defaultSystem}>{children}</ChakraProvider>;\n}` : `import { ChakraProvider, defaultSystem } from '@chakra-ui/react';\n\nexport function Provider({ children }) {\n return <ChakraProvider value={defaultSystem}>{children}</ChakraProvider>;\n}`; const providerPath = path.join(componentsDir, `Provider${extension}`); await writeTextFile(providerPath, providerContent); logger.debug(`Created Chakra UI React provider: ${providerPath}`); } async ensureChakraPathAliases() { const { pathExists, writeJsonFile, readJsonFile } = await import("../utils/file-system.js"); const tsconfigPath = path.join(this.targetPath, "tsconfig.json"); const jsconfigPath = path.join(this.targetPath, "jsconfig.json"); const hasTypeScript = await pathExists(tsconfigPath); if (hasTypeScript) { const tsconfig = await readJsonFile(tsconfigPath); if (tsconfig) { let updated = false; if (!tsconfig.compilerOptions) { tsconfig.compilerOptions = {}; updated = true; } if (tsconfig.compilerOptions.target !== "ESNext") { tsconfig.compilerOptions.target = "ESNext"; updated = true; } if (tsconfig.compilerOptions.module !== "ESNext") { tsconfig.compilerOptions.module = "ESNext"; updated = true; } if (tsconfig.compilerOptions.moduleResolution !== "Bundler") { tsconfig.compilerOptions.moduleResolution = "Bundler"; updated = true; } if (tsconfig.compilerOptions.skipLibCheck !== true) { tsconfig.compilerOptions.skipLibCheck = true; updated = true; } if (!tsconfig.compilerOptions.paths) { tsconfig.compilerOptions.paths = {}; updated = true; } if (!tsconfig.compilerOptions.paths["@/*"]) { tsconfig.compilerOptions.paths["@/*"] = ["./src/*"]; updated = true; } if (updated) { await writeJsonFile(tsconfigPath, tsconfig, 2); logger.debug("Updated tsconfig.json with Chakra UI requirements"); } } } else { const jsconfigContent = { compilerOptions: { target: "ESNext", module: "ESNext", moduleResolution: "Bundler", skipLibCheck: true, paths: { "@/*": ["./src/*"] } } }; await writeJsonFile(jsconfigPath, jsconfigContent, 2); logger.debug("Created jsconfig.json for Chakra UI path aliases"); } } async createChakraNextSetup() { const { pathExists, readTextFile, writeTextFile, createDirectory } = await import("../utils/file-system.js"); const componentsUiDir = path.join(this.targetPath, "src/components/ui"); await createDirectory(componentsUiDir); const isTypeScript = this.language === ProjectLanguage.TYPESCRIPT; const extension = isTypeScript ? ".tsx" : ".js"; const templateDir = isTypeScript ? "typescript" : "javascript"; const providerTemplatePath = path.join(__dirname, "../../templates", templateDir, isTypeScript ? "chakra-provider.tsx" : "chakra-provider.js"); const colorModeTemplatePath = path.join(__dirname, "../../templates", templateDir, isTypeScript ? "chakra-color-mode.tsx" : "chakra-color-mode.js"); const providerTargetPath = path.join(componentsUiDir, `provider${extension}`); const colorModeTargetPath = path.join(componentsUiDir, `color-mode${extension}`); if (await pathExists(providerTemplatePath)) { const providerContent = await readTextFile(providerTemplatePath); if (providerContent) { await writeTextFile(providerTargetPath, providerContent); } } if (await pathExists(colorModeTemplatePath)) { const colorModeContent = await readTextFile(colorModeTemplatePath); if (colorModeContent) { await writeTextFile(colorModeTargetPath, colorModeContent); } } await this.updateNextConfigForChakra(); await this.updateLayoutForChakra(); await this.ensureChakraPathAliases(); logger.debug("Chakra UI Next.js setup completed with provider files"); } async updateNextConfigForChakra() { const { pathExists, readTextFile, writeTextFile } = await import("../utils/file-system.js"); const nextConfigPaths = [ path.join(this.targetPath, "next.config.mjs"), path.join(this.targetPath, "next.config.js") ]; for (const configPath of nextConfigPaths) { if (await pathExists(configPath)) { const content = await readTextFile(configPath); if (content && !content.includes("optimizePackageImports")) { let updatedContent = content.replace(/(const nextConfig = \{)/, `$1\n experimental: {\n optimizePackageImports: ["@chakra-ui/react"],\n },`); await writeTextFile(configPath, updatedContent); logger.debug(`Updated Next.js config for Chakra UI optimization: ${configPath}`); } return; } } const nextConfigPath = path.join(this.targetPath, "next.config.mjs"); const nextConfigTemplate = path.join(__dirname, "../../templates/shared/chakra-next-config.mjs"); if (await pathExists(nextConfigTemplate)) { const templateContent = await readTextFile(nextConfigTemplate); if (templateContent) { await writeTextFile(nextConfigPath, templateContent); logger.debug("Created next.config.mjs for Chakra UI optimization"); } } } async updateLayoutForChakra() { const { pathExists, readTextFile, writeTextFile } = await import("../utils/file-system.js"); const layoutPaths = [ path.join(this.targetPath, "src/app/layout.js"), path.join(this.targetPath, "src/app/layout.tsx") ]; for (const layoutPath of layoutPaths) { if (await pathExists(layoutPath)) { const content = await readTextFile(layoutPath); if (content && !content.includes("@/components/ui/provider")) { let updatedContent = content; if (!updatedContent.includes('import { Provider }')) { const importLine = 'import { Provider } from "@/components/ui/provider"'; updatedContent = updatedContent.replace(/(import.*from.*['"];?\n)(\n*export)/, `$1${importLine}\n$2`); } if (!updatedContent.includes('<Provider>')) { updatedContent = updatedContent.replace(/<body([^>]*)>\s*{children}\s*<\/body>/, '<body$1>\n <Provider>{children}</Provider>\n </body>'); } if (!updatedContent.includes('suppressHydrationWarning')) { updatedContent = updatedContent.replace(/<html([^>]*)>/, '<html$1 suppressHydrationWarning>'); } await writeTextFile(layoutPath, updatedContent); logger.debug(`Updated layout file with Chakra UI provider: ${layoutPath}`); } break; } } } static getAddOnConfig(id) { return ADDON_CONFIGS[id]; } static getCompatibleAddOns(framework) { return Object.values(ADDON_CONFIGS).filter((config) => config.compatibleFrameworks.includes(framework)); } } //# sourceMappingURL=addon-manager.js.map