@pitifulhawk/flash-up
Version:
Interactive project scaffolder for modern web applications
664 lines (651 loc) • 27.6 kB
JavaScript
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