UNPKG

@pitifulhawk/flash-up

Version:

Interactive project scaffolder for modern web applications

664 lines (651 loc) 27.6 kB
import * as path from "path"; import { Framework, PackageManager, ProjectLanguage, } from "../types/index.js"; import { PackageManagerUtil } from "./package-manager.js"; import { TemplateManager } from "./template-manager.js"; import { CleanupManager } from "./cleanup-manager.js"; import { AddOnManager } from "./addon-manager.js"; import { createDirectory, pathExists } from "../utils/file-system.js"; import { logger } from "../ui/logger.js"; const FRAMEWORK_TEMPLATES = { [Framework.REACT]: { framework: Framework.REACT, displayName: "React with Vite", description: "Modern React application with Vite build tool", createCommand: (projectName, packageManager, language) => { const pmMap = { [PackageManager.NPM]: "npm create vite@latest", [PackageManager.PNPM]: "pnpm create vite", [PackageManager.YARN]: "yarn create vite", [PackageManager.BUN]: "bun create vite", }; const template = language === ProjectLanguage.JAVASCRIPT ? "react" : "react-ts"; return `${pmMap[packageManager]} ${projectName} -- --template ${template} --yes`; }, }, [Framework.NEXTJS]: { framework: Framework.NEXTJS, displayName: "Next.js", description: "Full-stack React framework", createCommand: (projectName, packageManager, language) => { const pmMap = { [PackageManager.NPM]: "npx create-next-app@latest", [PackageManager.PNPM]: "pnpm create next-app", [PackageManager.YARN]: "yarn create next-app", [PackageManager.BUN]: "bunx create-next-app", }; const languageFlag = (language === ProjectLanguage.TYPESCRIPT || !language) ? "--typescript" : "--js"; return `${pmMap[packageManager]} ${projectName} ${languageFlag} --tailwind --eslint --app --src-dir --import-alias "@/*" --yes`; }, }, [Framework.EXPRESS]: { framework: Framework.EXPRESS, displayName: "Express.js", description: "Fast Node.js web framework with TypeScript", createCommand: (_projectName, _packageManager) => { return ""; }, defaultDependencies: ["express", "cors", "helmet", "morgan"], defaultDevDependencies: [ "@types/express", "@types/cors", "@types/morgan", "@types/node", "typescript", "ts-node", "nodemon", ], }, }; export class ProjectScaffolder { config; packageManager; templateManager; cleanupManager; addOnManager; steps = []; constructor(config) { this.config = config; this.packageManager = new PackageManagerUtil(config.packageManager); this.templateManager = new TemplateManager(); this.cleanupManager = new CleanupManager(config.targetPath, config.framework, config.language); this.addOnManager = new AddOnManager(config.targetPath, config.framework, config.packageManager, config.language); } async createProject() { try { this.initializeSteps(); await this.executeStep("validate", () => this.validatePrerequisites()); await this.executeStep("create-dir", () => this.createProjectDirectory()); await this.executeStep("init-framework", () => this.initializeFramework()); await this.executeStep("cleanup-boilerplate", () => this.cleanupBoilerplate()); await this.executeStep("install-addons", () => this.installAddOns()); await this.executeStep("copy-templates", () => this.copyTemplateFiles()); await this.executeStep("update-config", () => this.updateConfigurations()); await this.executeStep("final-install", () => this.finalInstallation()); return true; } catch (error) { logger.error(`Project creation failed: ${error.message}`); return false; } } initializeSteps() { this.steps = [ { id: "validate", description: "Validating prerequisites", completed: false, }, { id: "create-dir", description: "Creating project directory", completed: false, }, { id: "init-framework", description: `Initializing ${this.config.framework} project`, completed: false, }, { id: "cleanup-boilerplate", description: "Cleaning up boilerplate files", completed: false, }, { id: "install-addons", description: "Installing add-ons", completed: false, }, { id: "copy-templates", description: "Copying template files", completed: false, }, { id: "update-config", description: "Updating configurations", completed: false, }, { id: "final-install", description: "Final installation", completed: false, }, ]; } async executeStep(stepId, action) { const step = this.steps.find((s) => s.id === stepId); if (!step) { throw new Error(`Unknown step: ${stepId}`); } const currentStepIndex = this.steps.findIndex((s) => s.id === stepId) + 1; logger.step(currentStepIndex, this.steps.length, step.description); logger.startSpinner(step.description); try { await action(); step.completed = true; logger.succeedSpinner(`${step.description} ✓`); } catch (error) { step.error = error.message; logger.failSpinner(`${step.description} ✗`); throw error; } } async validatePrerequisites() { const isAvailable = await this.packageManager.isAvailable(); if (!isAvailable) { throw new Error(`Package manager ${this.config.packageManager} is not available`); } const parentDir = path.dirname(this.config.targetPath); if (!(await pathExists(parentDir))) { throw new Error(`Parent directory ${parentDir} does not exist`); } } async createProjectDirectory() { const result = await createDirectory(this.config.targetPath); if (!result.success) { throw new Error(`Failed to create directory: ${result.error}`); } } async initializeFramework() { const template = FRAMEWORK_TEMPLATES[this.config.framework]; if (this.config.framework === Framework.EXPRESS) { await this.createExpressProjectManually(); } else { await this.createFrameworkProject(template); } } async createFrameworkProject(template) { const parentDir = path.dirname(this.config.targetPath); const projectName = path.basename(this.config.targetPath); if (this.config.framework === Framework.REACT) { await this.createReactProjectManually(); } else if (this.config.framework === Framework.NEXTJS) { const { executeCommand } = await import("../utils/shell.js"); const languageFlag = this.config.language === ProjectLanguage.TYPESCRIPT ? "--typescript" : "--js"; let command; let args; switch (this.config.packageManager) { case PackageManager.NPM: command = "npx"; args = ["create-next-app@latest", projectName]; break; case PackageManager.PNPM: command = "pnpm"; args = ["create", "next-app", projectName]; break; case PackageManager.YARN: command = "yarn"; args = ["create", "next-app", projectName]; break; case PackageManager.BUN: command = "bunx"; args = ["create-next-app@latest", projectName]; break; default: throw new Error(`Unsupported package manager: ${this.config.packageManager}`); } args.push(languageFlag, "--tailwind", "--eslint", "--app", "--src-dir", "--import-alias", "@/*", "--yes"); const result = await executeCommand(command, args, { cwd: parentDir, stdio: "inherit", timeout: 600000, }); if (!result.success) { throw new Error(`Failed to create ${template.displayName} project: ${result.stderr}`); } await this.fixNextJSLanguageConsistency(); } else if (this.config.framework === Framework.EXPRESS) { await this.createExpressProjectManually(); } } async fixNextJSLanguageConsistency() { if (this.config.language === ProjectLanguage.JAVASCRIPT) { await this.convertNextJSToJavaScript(); } } async convertNextJSToJavaScript() { const { pathExists, readTextFile, writeTextFile } = await import("../utils/file-system.js"); const fs = await import("fs/promises"); const componentFiles = [ "src/app/page.js", "src/app/layout.js" ]; const configFiles = [ { from: "next.config.ts", to: "next.config.js" }, { from: "tailwind.config.ts", to: "tailwind.config.js" } ]; const utilityFiles = [ { from: "src/lib/api.ts", to: "src/lib/api.js" }, { from: "src/utils/api.ts", to: "src/utils/api.js" }, { from: "src/lib/utils.ts", to: "src/lib/utils.js" } ]; try { for (const filePath of componentFiles) { const fullPath = path.join(this.config.targetPath, filePath); if (await pathExists(fullPath)) { const content = await readTextFile(fullPath); if (content) { const jsContent = content .replace(/import type \{[^}]+\} from [^;]+;?\s*/g, '') .replace(/export const metadata: Metadata = /g, 'export const metadata = ') .replace(/: \{[^}]*children: React\.ReactNode[^}]*\}/g, '') .replace(/: React\.ReactNode/g, '') .replace(/\{\s*children[^}]*\}/g, '{ children }'); await writeTextFile(fullPath, jsContent); logger.debug(`Converted content of ${filePath} to JavaScript`); } } } for (const { from, to } of configFiles) { const fromPath = path.join(this.config.targetPath, from); const toPath = path.join(this.config.targetPath, to); if (await pathExists(fromPath)) { const content = await readTextFile(fromPath); if (content) { const jsContent = content .replace(/import type \{[^}]+\} from [^;]+;?\s*/g, '') .replace(/: NextConfig/g, '') .replace(/: Config/g, '') .replace(/export default /g, 'module.exports = '); await writeTextFile(toPath, jsContent); await fs.unlink(fromPath); logger.debug(`Converted ${from} to ${to}`); } } } for (const { from, to } of utilityFiles) { const fromPath = path.join(this.config.targetPath, from); const toPath = path.join(this.config.targetPath, to); if (await pathExists(fromPath)) { const content = await readTextFile(fromPath); if (content) { const jsContent = content .replace(/import type \{[^}]+\} from [^;]+;?\s*/g, '') .replace(/: [A-Za-z<>\[\]]+/g, '') .replace(/export type \{[^}]+\}/g, '') .replace(/interface \w+ \{[^}]*\}/gs, '') .replace(/type \w+ = [^;]+;/g, ''); await writeTextFile(toPath, jsContent); await fs.unlink(fromPath); logger.debug(`Converted utility file ${from} to ${to}`); } } } const tsConfigFiles = ["tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"]; for (const configFile of tsConfigFiles) { const configPath = path.join(this.config.targetPath, configFile); if (await pathExists(configPath)) { await fs.unlink(configPath); logger.debug(`Removed ${configFile}`); } } } catch (error) { logger.warn(`Failed to convert Next.js files to JavaScript: ${error}`); } } async createExpressProjectManually() { const packageJson = { name: this.config.name, version: "1.0.0", description: "", main: "dist/index.js", scripts: { build: "tsc", start: "node dist/index.js", dev: "ts-node src/index.ts", "dev:watch": "nodemon --exec ts-node src/index.ts", }, keywords: [], author: "", license: "ISC", dependencies: {}, devDependencies: {}, }; const packageJsonResult = await this.templateManager.updatePackageJson(this.config.targetPath, this.config.framework, []); if (!packageJsonResult.success) { const { writeJsonFile } = await import("../utils/file-system.js"); const result = await writeJsonFile(path.join(this.config.targetPath, "package.json"), packageJson, 2); if (!result.success) { throw new Error(`Failed to create package.json: ${result.error}`); } } const dependencies = ["express", "cors", "helmet", "morgan"]; const devDependencies = [ "@types/express", "@types/cors", "@types/morgan", "@types/node", "typescript", "ts-node", "nodemon" ]; const depResult = await this.packageManager.addPackages(dependencies, this.config.targetPath, false, true); if (!depResult.success) { throw new Error(`Failed to install Express dependencies: ${depResult.stderr}`); } const devDepResult = await this.packageManager.addPackages(devDependencies, this.config.targetPath, true, true); if (!devDepResult.success) { throw new Error(`Failed to install Express dev dependencies: ${devDepResult.stderr}`); } await this.templateManager.createTsConfig(this.config.targetPath, Framework.EXPRESS); await this.createExpressAppStructure(); } async createExpressAppStructure() { const { writeTextFile, createDirectory } = await import("../utils/file-system.js"); await createDirectory(path.join(this.config.targetPath, "src")); const indexContent = `import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(helmet()); app.use(cors()); app.use(morgan('combined')); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Routes app.get('/', (req, res) => { res.json({ message: 'Hello from Express.js with TypeScript!' }); }); app.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString() }); }); // Error handling middleware app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error(err.stack); res.status(500).json({ error: 'Something went wrong!' }); }); // 404 handler app.use('*', (req, res) => { res.status(404).json({ error: 'Route not found' }); }); app.listen(PORT, () => { console.log(\`Server is running on port \${PORT}\`); }); `; const result = await writeTextFile(path.join(this.config.targetPath, "src", "index.ts"), indexContent); if (!result.success) { throw new Error(`Failed to create Express app: ${result.error}`); } } async createReactProjectManually() { const { writeJsonFile, writeTextFile, createDirectory } = await import("../utils/file-system.js"); const isTypeScript = this.config.language === ProjectLanguage.TYPESCRIPT; const ext = isTypeScript ? "tsx" : "jsx"; const moduleExt = isTypeScript ? "ts" : "js"; const packageJson = { name: this.config.name, private: true, version: "0.0.0", type: "module", scripts: { dev: "vite", build: isTypeScript ? "tsc -b && vite build" : "vite build", preview: "vite preview", }, dependencies: { react: "^19.1.1", "react-dom": "^19.1.1", }, devDependencies: { "@eslint/js": "^9.36.0", "@vitejs/plugin-react": "^5.0.4", vite: "^7.1.7", globals: "^16.4.0", eslint: "^9.36.0", "eslint-plugin-react-hooks": "^6.1.0", "eslint-plugin-react-refresh": "^0.4.23", ...(isTypeScript && { "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "typescript-eslint": "^8.45.0", typescript: "~5.9.3", }), }, }; await writeJsonFile(path.join(this.config.targetPath, "package.json"), packageJson, 2); const viteConfig = isTypeScript ? `import path from "path" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, })` : `import path from "path" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, })`; await writeTextFile(path.join(this.config.targetPath, `vite.config.${moduleExt}`), viteConfig); const indexCSS = `body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }`; await writeTextFile(path.join(this.config.targetPath, "src/index.css"), indexCSS); if (isTypeScript) { const tsconfigJson = { files: [], references: [ { path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }, ], }; const tsconfigAppJson = { compilerOptions: { tsBuildInfoFile: "./node_modules/.tmp/tsconfig.app.tsbuildinfo", target: "ES2022", useDefineForClassFields: true, lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", types: ["vite/client"], skipLibCheck: true, moduleResolution: "bundler", allowImportingTsExtensions: true, verbatimModuleSyntax: true, moduleDetection: "force", noEmit: true, jsx: "react-jsx", strict: true, noUnusedLocals: true, noUnusedParameters: true, erasableSyntaxOnly: true, noFallthroughCasesInSwitch: true, noUncheckedSideEffectImports: true, }, include: ["src"], }; const tsconfigNodeJson = { compilerOptions: { tsBuildInfoFile: "./node_modules/.tmp/tsconfig.node.tsbuildinfo", target: "ES2023", lib: ["ES2023"], module: "ESNext", types: ["node"], skipLibCheck: true, moduleResolution: "bundler", allowImportingTsExtensions: true, verbatimModuleSyntax: true, moduleDetection: "force", noEmit: true, strict: true, noUnusedLocals: true, noUnusedParameters: true, erasableSyntaxOnly: true, noFallthroughCasesInSwitch: true, noUncheckedSideEffectImports: true, }, include: ["vite.config.ts"], }; await writeJsonFile(path.join(this.config.targetPath, "tsconfig.json"), tsconfigJson, 2); await writeJsonFile(path.join(this.config.targetPath, "tsconfig.app.json"), tsconfigAppJson, 2); await writeJsonFile(path.join(this.config.targetPath, "tsconfig.node.json"), tsconfigNodeJson, 2); } await createDirectory(path.join(this.config.targetPath, "src")); const mainContent = isTypeScript ? `import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.${ext}' const root = document.getElementById('root'); if (root) { createRoot(root).render( <StrictMode> <App /> </StrictMode>, ); }` : `import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.${ext}' const root = document.getElementById('root'); if (root) { createRoot(root).render( <StrictMode> <App /> </StrictMode>, ); }`; await writeTextFile(path.join(this.config.targetPath, `src/main.${ext}`), mainContent); const appContent = `export default function App() { return ( <div className="min-h-screen flex items-center justify-center"> <h1 className="text-3xl font-bold underline">Hello World</h1> </div> ); }`; await writeTextFile(path.join(this.config.targetPath, `src/App.${ext}`), appContent); const indexHtml = `<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>${this.config.name}</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.${ext}"></script> </body> </html>`; await writeTextFile(path.join(this.config.targetPath, "index.html"), indexHtml); await createDirectory(path.join(this.config.targetPath, "public")); const viteSvg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>`; await writeTextFile(path.join(this.config.targetPath, "public/vite.svg"), viteSvg); const gitignore = `# Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw?`; await writeTextFile(path.join(this.config.targetPath, ".gitignore"), gitignore); const installResult = await this.packageManager.install(this.config.targetPath, false); if (!installResult.success) { throw new Error(`Failed to install dependencies: ${installResult.stderr}`); } } async cleanupBoilerplate() { if (this.config.framework === Framework.REACT) { return; } const success = await this.cleanupManager.performCleanup(); if (!success) { throw new Error("Failed to clean up boilerplate files"); } } async installAddOns() { const success = await this.addOnManager.installAddOns(this.config.addOns); if (!success) { throw new Error("Failed to install add-ons"); } } async copyTemplateFiles() { logger.debug("Template files handled by AddOnManager"); } async updateConfigurations() { logger.debug("Configurations handled by AddOnManager"); } async finalInstallation() { const result = await this.packageManager.install(this.config.targetPath, true); if (!result.success) { throw new Error(`Final installation failed: ${result.stderr}`); } } getNextSteps() { const steps = [`cd ${this.config.name}`]; switch (this.config.framework) { case Framework.REACT: steps.push(this.packageManager.getRunCommand("dev")); break; case Framework.NEXTJS: steps.push(this.packageManager.getRunCommand("dev")); break; case Framework.EXPRESS: steps.push(this.packageManager.getRunCommand("dev")); break; } return steps; } } //# sourceMappingURL=scaffolder.js.map