@entro314labs/at3-toolkit
Version:
Advanced development toolkit for AT3 Stack projects
573 lines (561 loc) • 19.9 kB
JavaScript
import { exec } from "child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { glob } from "glob";
import { dirname, join, relative } from "path";
import { promisify } from "util";
import { ProjectDetector } from "../detection/detector.js";
import { ConfigMerger } from "./config-merger.js";
const execAsync = promisify(exec);
export class MigrationRunner {
logger;
detector;
configMerger;
constructor(logger) {
this.logger = logger;
this.detector = new ProjectDetector(logger);
this.configMerger = new ConfigMerger(logger);
}
async migrate(options) {
const startTime = Date.now();
this.logger.step(1, 6, "Analyzing project...");
try {
// 1. Detect project
const projectInfo = await this.detector.detectProject(options.projectPath);
// 2. Create migration plan
this.logger.step(2, 6, "Creating migration plan...");
const plan = await this.createMigrationPlan(projectInfo, options);
// 3. Create backup
this.logger.step(3, 6, "Creating backup...");
const backupInfo = await this.createBackup(options.projectPath, options.backupDir);
// 4. Execute migration steps
this.logger.step(4, 6, "Executing migration...");
const stepResults = await this.executeMigrationSteps(plan.steps, options);
// 5. Update dependencies
if (!options.skipDeps) {
this.logger.step(5, 6, "Updating dependencies...");
await this.updateDependencies(options, projectInfo);
}
// 6. Validate migration
this.logger.step(6, 6, "Validating migration...");
const validationErrors = await this.validateMigration(options.projectPath);
const result = {
success: validationErrors.length === 0,
steps: stepResults,
backupPath: backupInfo.timestamp,
errors: validationErrors,
warnings: [],
};
const duration = Date.now() - startTime;
this.logger.success(`Migration completed in ${duration}ms`);
return result;
}
catch (error) {
this.logger.error("Migration failed", error);
throw error;
}
}
async rollback(projectPath, force = false) {
this.logger.info("Starting rollback process...");
const backupDir = join(projectPath, ".migration-backup");
if (!existsSync(backupDir)) {
throw new Error("No backup found to rollback from");
}
if (!force) {
// Interactive confirmation would go here
this.logger.warn("This will restore all files from the backup. Continue? (Not implemented in this version)");
}
// Restore files from backup
const files = await glob("**/*", { cwd: backupDir });
for (const file of files) {
const backupFile = join(backupDir, file);
const targetFile = join(projectPath, file);
if (existsSync(backupFile)) {
// Ensure target directory exists
mkdirSync(dirname(targetFile), { recursive: true });
copyFileSync(backupFile, targetFile);
this.logger.debug(`Restored: ${file}`);
}
}
this.logger.success("Rollback completed successfully");
}
async createMigrationPlan(projectInfo, options) {
const steps = [];
// Add configuration migration steps
if (projectInfo.hasNextJs) {
steps.push({
id: "next-config",
name: "Update Next.js Configuration",
description: "Migrate to Next.js 15.4 with modern features",
required: true,
execute: async () => this.migrateNextConfig(options.projectPath),
});
}
if (projectInfo.hasTailwind || options.updateVersions) {
steps.push({
id: "tailwind-config",
name: "Update Tailwind CSS Configuration",
description: "Migrate to Tailwind CSS 4.x CSS-first configuration",
required: true,
execute: async () => this.migrateTailwindConfig(options.projectPath),
});
}
if (options.replaceLinting && (projectInfo.hasEslint || projectInfo.hasPrettier)) {
steps.push({
id: "linting-config",
name: "Replace ESLint/Prettier with Biome",
description: "Modern unified linting and formatting",
required: false,
execute: async () => this.migrateLintingConfig(options.projectPath),
});
}
if (projectInfo.hasTypeScript) {
steps.push({
id: "typescript-config",
name: "Update TypeScript Configuration",
description: "Modern TypeScript 5.9+ configuration",
required: true,
execute: async () => this.migrateTypeScriptConfig(options.projectPath),
});
}
return {
steps,
conflicts: [],
backupFiles: [],
};
}
async createBackup(projectPath, customBackupDir) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir = join(projectPath, customBackupDir || ".migration-backup", timestamp);
mkdirSync(backupDir, { recursive: true });
// Files to backup
const filesToBackup = [
"package.json",
"next.config.*",
"tailwind.config.*",
"tsconfig.json",
"biome.json",
".eslintrc.*",
".prettierrc*",
"postcss.config.*",
"src/app/globals.css",
];
const backedUpFiles = [];
for (const pattern of filesToBackup) {
const files = await glob(pattern, { cwd: projectPath });
for (const file of files) {
const sourcePath = join(projectPath, file);
const backupPath = join(backupDir, file);
if (existsSync(sourcePath)) {
mkdirSync(dirname(backupPath), { recursive: true });
copyFileSync(sourcePath, backupPath);
backedUpFiles.push(file);
this.logger.debug(`Backed up: ${file}`);
}
}
}
// Create backup info file
const backupInfo = {
timestamp,
files: backedUpFiles,
migrationId: `migration-${timestamp}`,
canRollback: true,
};
writeFileSync(join(backupDir, "backup-info.json"), JSON.stringify(backupInfo, null, 2));
this.logger.success(`Backup created: ${relative(projectPath, backupDir)}`);
return backupInfo;
}
async executeMigrationSteps(steps, options) {
const results = [];
for (const [index, step] of steps.entries()) {
const spinner = this.logger.spinner(`Executing: ${step.name}`);
const startTime = Date.now();
try {
if (!options.dryRun) {
await step.execute(options);
}
const duration = Date.now() - startTime;
spinner.succeed(`✓ ${step.name}`);
results.push({
stepId: step.id,
success: true,
filesModified: [], // Would need to track this in real implementation
duration,
});
}
catch (error) {
const duration = Date.now() - startTime;
spinner.fail(`✗ ${step.name}`);
results.push({
stepId: step.id,
success: false,
error: error instanceof Error ? error.message : String(error),
filesModified: [],
duration,
});
if (step.required) {
throw error;
}
}
}
return results;
}
async updateDependencies(options, projectInfo) {
if (options.dryRun) {
this.logger.info("Dry run: Skipping dependency installation");
return;
}
const spinner = this.logger.spinner("Installing dependencies...");
try {
const packageManager = projectInfo.packageManager;
const installCommand = this.getInstallCommand(packageManager);
await execAsync(installCommand, {
cwd: options.projectPath,
timeout: 300000, // 5 minute timeout
});
spinner.succeed("Dependencies updated successfully");
}
catch (error) {
spinner.fail("Failed to update dependencies");
this.logger.warn("You may need to run the package manager install command manually");
}
}
async validateMigration(projectPath) {
const errors = [];
// Check if key files exist and are valid
const packageJsonPath = join(projectPath, "package.json");
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
// Validate package.json structure
}
catch (error) {
errors.push({
step: "validation",
message: "Invalid package.json after migration",
severity: "error",
});
}
return errors;
}
async migrateNextConfig(projectPath) {
this.logger.debug("Migrating Next.js configuration");
const configPaths = [
join(projectPath, "next.config.ts"),
join(projectPath, "next.config.mjs"),
join(projectPath, "next.config.js"),
];
let configPath = configPaths.find((p) => existsSync(p));
// Create new config if none exists
if (!configPath) {
configPath = join(projectPath, "next.config.ts");
}
const newConfig = `import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
// Enable experimental features for AT3 stack
experimental: {
// Enable server actions
serverActions: {
bodySizeLimit: "2mb",
},
// Optimize package imports
optimizePackageImports: [
"lucide-react",
"@radix-ui/react-icons",
"framer-motion",
],
},
// Image optimization
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
],
},
// Headers for security
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
];
},
};
export default nextConfig;
`;
writeFileSync(configPath, newConfig);
this.logger.success("Created/updated next.config.ts");
}
async migrateTailwindConfig(projectPath) {
this.logger.debug("Migrating Tailwind CSS configuration");
// For Tailwind CSS v4, we use CSS-first configuration
const cssPath = join(projectPath, "src", "app", "globals.css");
// Ensure directory exists
mkdirSync(dirname(cssPath), { recursive: true });
const tailwindCSS = `@import "tailwindcss";
/* Custom CSS variables for theming */
@theme {
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-primary: #6366f1;
--color-primary-foreground: #ffffff;
--color-secondary: #f4f4f5;
--color-secondary-foreground: #18181b;
--color-muted: #f4f4f5;
--color-muted-foreground: #71717a;
--color-accent: #f4f4f5;
--color-accent-foreground: #18181b;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-border: #e4e4e7;
--color-input: #e4e4e7;
--color-ring: #6366f1;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
/* Dark mode theme */
@media (prefers-color-scheme: dark) {
@theme {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-primary: #818cf8;
--color-secondary: #27272a;
--color-secondary-foreground: #fafafa;
--color-muted: #27272a;
--color-muted-foreground: #a1a1aa;
--color-accent: #27272a;
--color-accent-foreground: #fafafa;
--color-border: #27272a;
--color-input: #27272a;
}
}
/* Base styles */
@layer base {
*,
*::before,
*::after {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Utility classes */
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
`;
writeFileSync(cssPath, tailwindCSS);
// Create postcss.config.mjs
const postcssConfig = `/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
`;
writeFileSync(join(projectPath, "postcss.config.mjs"), postcssConfig);
this.logger.success("Created Tailwind CSS v4 configuration");
}
async migrateLintingConfig(projectPath) {
this.logger.debug("Migrating linting configuration to Biome");
// Remove ESLint and Prettier configs
const filesToRemove = [
".eslintrc.js",
".eslintrc.json",
".eslintrc.yml",
".eslintrc.yaml",
"eslint.config.js",
"eslint.config.mjs",
".prettierrc",
".prettierrc.js",
".prettierrc.json",
".prettierrc.yaml",
".prettierrc.yml",
".prettierignore",
];
for (const file of filesToRemove) {
const filePath = join(projectPath, file);
if (existsSync(filePath)) {
const { unlinkSync } = require("fs");
unlinkSync(filePath);
this.logger.debug(`Removed: ${file}`);
}
}
// Create Biome configuration
const biomeConfig = {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
vcs: {
enabled: true,
clientKind: "git",
useIgnoreFile: true,
},
files: {
ignoreUnknown: true,
ignore: [
"node_modules",
".next",
"dist",
"build",
"coverage",
"*.min.js",
"pnpm-lock.yaml",
],
},
formatter: {
enabled: true,
indentStyle: "space",
indentWidth: 2,
lineWidth: 100,
},
organizeImports: {
enabled: true,
},
linter: {
enabled: true,
rules: {
recommended: true,
complexity: {
noBannedTypes: "warn",
noUselessTypeConstraint: "warn",
},
correctness: {
noUnusedVariables: "warn",
noUnusedImports: "warn",
useExhaustiveDependencies: "warn",
},
style: {
noNonNullAssertion: "off",
useConst: "error",
useImportType: "error",
},
suspicious: {
noExplicitAny: "warn",
noArrayIndexKey: "warn",
},
nursery: {
useSortedClasses: {
level: "warn",
options: {
attributes: ["className", "class"],
functions: ["clsx", "cn", "twMerge"],
},
},
},
},
},
javascript: {
formatter: {
quoteStyle: "single",
trailingCommas: "es5",
semicolons: "asNeeded",
},
},
json: {
formatter: {
trailingCommas: "none",
},
},
};
writeFileSync(join(projectPath, "biome.json"), JSON.stringify(biomeConfig, null, 2));
// Update package.json to remove ESLint/Prettier and add Biome
await this.configMerger.mergePackageJson(join(projectPath, "package.json"), {
devDependencies: {
"@biomejs/biome": "^2.3.8",
},
scripts: {
lint: "biome check .",
"lint:fix": "biome check --fix .",
format: "biome format --write .",
"format:check": "biome format .",
},
removeDependencies: [
"eslint",
"eslint-config-next",
"eslint-config-prettier",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
"prettier",
"prettier-plugin-tailwindcss",
],
});
this.logger.success("Migrated to Biome for linting and formatting");
}
async migrateTypeScriptConfig(projectPath) {
this.logger.debug("Migrating TypeScript configuration");
const tsConfig = {
"$schema": "https://json.schemastore.org/tsconfig",
compilerOptions: {
target: "ES2022",
lib: ["dom", "dom.iterable", "ES2022"],
allowJs: true,
skipLibCheck: true,
strict: true,
noEmit: true,
esModuleInterop: true,
module: "ESNext",
moduleResolution: "bundler",
resolveJsonModule: true,
isolatedModules: true,
jsx: "preserve",
incremental: true,
noUncheckedIndexedAccess: true,
moduleDetection: "force",
plugins: [
{
name: "next",
},
],
paths: {
"@/*": ["./src/*"],
},
},
include: [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
exclude: ["node_modules"],
};
writeFileSync(join(projectPath, "tsconfig.json"), JSON.stringify(tsConfig, null, 2));
this.logger.success("Updated tsconfig.json for modern TypeScript");
}
getInstallCommand(packageManager) {
switch (packageManager) {
case "pnpm":
return "pnpm install";
case "yarn":
return "yarn install";
case "bun":
return "bun install";
default:
return "npm install";
}
}
}