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