UNPKG

@entro314labs/at3-stack-kit

Version:

Upgrade existing projects to AT3 Stack with intelligent migration

1,657 lines (1,511 loc) 98.4 kB
#!/usr/bin/env node #!/usr/bin/env node var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/cli.ts import { cancel, confirm, intro, isCancel, multiselect, note, outro, spinner } from "@clack/prompts"; import chalk2 from "chalk"; import { program } from "commander"; import { detect as detectPackageManager2 } from "detect-package-manager"; import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs-extra"; import { join as join11 } from "path"; // src/detect.ts import { detect as detectPackageManager } from "detect-package-manager"; import { existsSync, readdirSync, readFileSync } from "fs-extra"; import { join } from "path"; async function detectProjectType(projectPath) { const packageJsonPath = join(projectPath, "package.json"); if (!existsSync(packageJsonPath)) { return "unknown"; } try { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); const deps = { ...packageJson.dependencies, ...packageJson.devDependencies }; const hasAI = deps.ai || deps["@ai-sdk/openai"] || deps["@ai-sdk/anthropic"] || deps["@ai-sdk/google"]; const hasSupabase = deps["@supabase/supabase-js"] || deps["@supabase/ssr"]; const hasNextJS = deps.next; const hasTailwind = deps.tailwindcss; const hasTypeScript = deps.typescript || existsSync(join(projectPath, "tsconfig.json")); if (hasAI && hasSupabase && hasNextJS && (hasTailwind || hasTypeScript)) { return "ait3e"; } if (deps.next && deps["@trpc/server"]) { if (deps.prisma || deps["@prisma/client"] || deps["drizzle-orm"]) { return "t3"; } } if (deps.next) return "nextjs"; if (deps.nuxt || deps["@nuxt/core"]) return "nuxt"; if (deps.vue) return "vue"; if (deps.react) return "react"; if (deps.vite) return "vite"; if (deps.webpack) return "webpack"; return "node"; } catch (error) { return "unknown"; } } // src/features/add-ai.ts import { join as join2 } from "path"; import { ensureDir, pathExists, readFile, writeFile } from "fs-extra"; async function addAI(type, projectPath) { const srcPath = join2(projectPath, "src"); await ensureDir(srcPath); const aiPath = join2(srcPath, "lib", "ai"); await ensureDir(aiPath); if (type === "custom") { await addCustomAI(aiPath); } if (type === "vercel") { await addVercelAI(aiPath); } const apiPath = join2(srcPath, "app", "api"); await ensureDir(apiPath); await addAPIRoutes(apiPath, type); await updatePackageJson(projectPath, type); } async function addCustomAI(aiPath) { const clientConfig = `/** * Custom AI client configuration */ export interface AIProvider { name: string; baseURL: string; apiKey: string; } export const AI_PROVIDERS = { openai: { name: 'OpenAI', baseURL: 'https://api.openai.com/v1', apiKey: process.env.OPENAI_API_KEY!, }, anthropic: { name: 'Anthropic', baseURL: 'https://api.anthropic.com', apiKey: process.env.ANTHROPIC_API_KEY!, }, google: { name: 'Google AI', baseURL: 'https://generativelanguage.googleapis.com', apiKey: process.env.GOOGLE_AI_API_KEY!, }, } as const; export type AIProviderKey = keyof typeof AI_PROVIDERS; `; await writeFile(join2(aiPath, "config.ts"), clientConfig); const client = `/** * Custom AI client implementation */ import { AI_PROVIDERS, type AIProviderKey } from './config.js'; export interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } export interface CompletionOptions { model: string; temperature?: number; maxTokens?: number; } export class AIClient { constructor( private provider: AIProviderKey, private options: CompletionOptions = {} ) {} async completion( messages: ChatMessage[], options: Partial<CompletionOptions> = {} ): Promise<string> { const config = AI_PROVIDERS[this.provider]; const finalOptions = { ...this.options, ...options }; // Implementation would depend on the provider // This is a basic structure const response = await fetch(\`\${config.baseURL}/chat/completions\`, { method: 'POST', headers: { 'Authorization': \`Bearer \${config.apiKey}\`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: finalOptions.model || 'gpt-3.5-turbo', messages, temperature: finalOptions.temperature || 0.7, max_tokens: finalOptions.maxTokens || 1000, }), }); const data = await response.json(); return data.choices[0]?.message?.content || ''; } } `; await writeFile(join2(aiPath, "client.ts"), client); } async function addVercelAI(aiPath) { const vercelClient = `/** * Vercel AI SDK integration */ import { anthropic } from '@ai-sdk/anthropic'; import { google } from '@ai-sdk/google'; import { openai } from '@ai-sdk/openai'; import { generateText, streamText } from 'ai'; export const AI_MODELS = { 'gpt-4-turbo': openai('gpt-4-turbo'), 'gpt-3.5-turbo': openai('gpt-3.5-turbo'), 'claude-3-haiku': anthropic('claude-3-haiku-20240307'), 'claude-3-sonnet': anthropic('claude-3-sonnet-20240229'), 'gemini-pro': google('models/gemini-pro'), } as const; export type AIModelKey = keyof typeof AI_MODELS; export async function generateCompletion( model: AIModelKey, prompt: string, options?: { temperature?: number; maxTokens?: number; } ) { const { text } = await generateText({ model: AI_MODELS[model], prompt, temperature: options?.temperature || 0.7, maxTokens: options?.maxTokens || 1000, }); return text; } export async function streamCompletion( model: AIModelKey, prompt: string, options?: { temperature?: number; maxTokens?: number; } ) { const { textStream } = await streamText({ model: AI_MODELS[model], prompt, temperature: options?.temperature || 0.7, maxTokens: options?.maxTokens || 1000, }); return textStream; } `; await writeFile(join2(aiPath, "vercel-client.ts"), vercelClient); const hooks = `/** * React hooks for Vercel AI SDK */ 'use client'; import { useChat, useCompletion } from 'ai/react'; import { type AIModelKey } from './vercel-client.js'; export function useAIChat(model: AIModelKey = 'gpt-3.5-turbo') { return useChat({ api: '/api/chat', body: { model, }, }); } export function useAICompletion(model: AIModelKey = 'gpt-3.5-turbo') { return useCompletion({ api: '/api/completion', body: { model, }, }); } `; await writeFile(join2(aiPath, "vercel-hooks.ts"), hooks); } async function addAPIRoutes(apiPath, type) { if (type === "vercel") { const chatRoute = `import { AI_MODELS, type AIModelKey } from '@/lib/ai/vercel-client'; import { streamText } from 'ai'; export async function POST(req: Request) { const { messages, model = 'gpt-3.5-turbo' } = await req.json(); const result = await streamText({ model: AI_MODELS[model as AIModelKey], messages, }); return result.toDataStreamResponse(); } `; await ensureDir(join2(apiPath, "chat")); await writeFile(join2(apiPath, "chat", "route.ts"), chatRoute); const completionRoute = `import { AI_MODELS, type AIModelKey } from '@/lib/ai/vercel-client'; import { generateText } from 'ai'; export async function POST(req: Request) { const { prompt, model = 'gpt-3.5-turbo' } = await req.json(); const { text } = await generateText({ model: AI_MODELS[model as AIModelKey], prompt, }); return Response.json({ text }); } `; await ensureDir(join2(apiPath, "completion")); await writeFile(join2(apiPath, "completion", "route.ts"), completionRoute); } } async function updatePackageJson(projectPath, type) { const packageJsonPath = join2(projectPath, "package.json"); if (!await pathExists(packageJsonPath)) return; const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8")); if (!packageJson.dependencies) packageJson.dependencies = {}; if (type === "vercel") { packageJson.dependencies.ai = "^5.0.104"; packageJson.dependencies["@ai-sdk/openai"] = "^2.0.74"; packageJson.dependencies["@ai-sdk/anthropic"] = "^2.0.50"; packageJson.dependencies["@ai-sdk/google"] = "^2.0.44"; } await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)); } // src/features/add-better-auth.ts import { readFile as readFile2, writeFile as writeFile2, pathExists as pathExists2, ensureDir as ensureDir2 } from "fs-extra"; import { join as join3 } from "path"; // src/utils/cli-styling.ts import boxen from "boxen"; import chalk from "chalk"; import figures from "figures"; import gradient from "gradient-string"; var colors = { primary: chalk.hex("#2563eb"), // Blue secondary: chalk.hex("#7c3aed"), // Purple success: chalk.green, error: chalk.red, warning: chalk.yellow, info: chalk.blue, muted: chalk.gray, accent: chalk.magenta, ai: chalk.hex("#10b981") // Emerald for AI features }; var gradients = { at3: gradient("#2563eb", "#10b981"), // Blue to Emerald ai: gradient("#10b981", "#059669"), // Emerald gradient success: gradient("#22c55e", "#16a34a"), // Green gradient warning: gradient("#f59e0b", "#d97706"), // Amber gradient error: gradient("#ef4444", "#dc2626") // Red gradient }; var symbols = { success: colors.success(figures.tick), error: colors.error(figures.cross), warning: colors.warning(figures.warning), info: colors.info(figures.info), arrow: colors.muted(figures.arrowRight), bullet: colors.muted(figures.bullet), line: colors.muted(figures.line), ai: colors.ai("\u{1F916}"), stack: colors.primary("\u{1F4DA}"), edge: colors.secondary("\u26A1"), database: colors.info("\u{1F5C4}\uFE0F"), config: colors.muted("\u2699\uFE0F") }; var style = { title: (text) => gradients.at3(text), subtitle: (text) => colors.secondary(text), heading: (text) => colors.primary.bold(text), label: (text) => colors.muted(text), value: (text) => colors.primary(text), success: (text) => colors.success(text), error: (text) => colors.error(text), warning: (text) => colors.warning(text), info: (text) => colors.info(text), muted: (text) => colors.muted(text), accent: (text) => colors.accent(text), ai: (text) => colors.ai(text), code: (text) => chalk.cyan.italic(text), path: (text) => chalk.dim.underline(text), command: (text) => chalk.bgBlack.white.bold(` ${text} `) }; var header = { main: (title, subtitle) => { const titleText = gradients.at3.multiline(title); const subtitleText = subtitle ? ` ${colors.muted(subtitle)}` : ""; return `${titleText}${subtitleText}`; }, section: (title) => ` ${colors.primary.bold(title)} ${colors.muted("\u2500".repeat(title.length))}` }; // src/features/add-better-auth.ts async function addBetterAuth(projectPath) { console.log(colors.info("Adding Better Auth...")); await updatePackageJson2(projectPath); await addEnvExample(projectPath); await createAuthFiles(projectPath); console.log(colors.success("\u2713 Added Better Auth configuration")); } async function updatePackageJson2(projectPath) { const packageJsonPath = join3(projectPath, "package.json"); if (!await pathExists2(packageJsonPath)) return; const packageJson = JSON.parse(await readFile2(packageJsonPath, "utf-8")); if (!packageJson.dependencies) packageJson.dependencies = {}; packageJson.dependencies["better-auth"] = "^1.1.0"; await writeFile2(packageJsonPath, JSON.stringify(packageJson, null, 2)); } async function addEnvExample(projectPath) { const envExamplePath = join3(projectPath, ".env.example"); let envContent = ""; if (await pathExists2(envExamplePath)) { envContent = await readFile2(envExamplePath, "utf-8"); } if (!envContent.includes("BETTER_AUTH_SECRET")) { envContent += ` # Better Auth BETTER_AUTH_SECRET=your_secret_here BETTER_AUTH_URL=http://localhost:3000 `; await writeFile2(envExamplePath, envContent); } } async function createAuthFiles(projectPath) { const libPath = join3(projectPath, "src", "lib", "auth"); await ensureDir2(libPath); await writeFile2(join3(libPath, "client.ts"), `import { createAuthClient } from "better-auth/react" export const authClient = createAuthClient({ baseURL: process.env.BETTER_AUTH_URL }) `); await writeFile2(join3(libPath, "auth.ts"), `import { betterAuth } from "better-auth"; // import { db } from "@/db"; // import { drizzleAdapter } from "better-auth/adapters/drizzle"; export const auth = betterAuth({ // adapter: drizzleAdapter(db, { // provider: "pg", // }), emailAndPassword: { enabled: true }, // socialProviders: { // github: { // clientId: process.env.GITHUB_CLIENT_ID, // clientSecret: process.env.GITHUB_CLIENT_SECRET, // } // }, }) `); const apiPath = join3(projectPath, "src", "app", "api", "auth", "[...all]"); await ensureDir2(apiPath); await writeFile2(join3(apiPath, "route.ts"), `import { auth } from "@/lib/auth/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { GET, POST } = toNextJsHandler(auth); `); } // src/features/add-clerk.ts import { readFile as readFile3, writeFile as writeFile3, pathExists as pathExists3 } from "fs-extra"; import { join as join4 } from "path"; async function addClerk(projectPath) { console.log(colors.info("Adding Clerk authentication...")); await updatePackageJson3(projectPath); await addEnvExample2(projectPath); await addMiddleware(projectPath); console.log(colors.warning("NOTE: You need to wrap your root layout with <ClerkProvider> manually.")); console.log(colors.success("\u2713 Added Clerk configuration")); } async function updatePackageJson3(projectPath) { const packageJsonPath = join4(projectPath, "package.json"); if (!await pathExists3(packageJsonPath)) return; const packageJson = JSON.parse(await readFile3(packageJsonPath, "utf-8")); if (!packageJson.dependencies) packageJson.dependencies = {}; packageJson.dependencies["@clerk/nextjs"] = "^6.9.0"; await writeFile3(packageJsonPath, JSON.stringify(packageJson, null, 2)); } async function addEnvExample2(projectPath) { const envExamplePath = join4(projectPath, ".env.example"); let envContent = ""; if (await pathExists3(envExamplePath)) { envContent = await readFile3(envExamplePath, "utf-8"); } if (!envContent.includes("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")) { envContent += ` # Clerk Auth NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... `; await writeFile3(envExamplePath, envContent); } } async function addMiddleware(projectPath) { const middlewarePath = join4(projectPath, "src", "middleware.ts"); const content = `import { clerkMiddleware } from "@clerk/nextjs/server"; export default clerkMiddleware(); export const config = { matcher: ["/((?!.*\\\\..*|_next).*)", "/", "/(api|trpc)(.*)"], }; `; await writeFile3(middlewarePath, content); } // src/features/add-drizzle.ts import { ensureDir as ensureDir3, pathExists as pathExists4, readFile as readFile4, writeFile as writeFile4 } from "fs-extra"; import { join as join5 } from "path"; async function addDrizzle(projectPath) { const srcPath = join5(projectPath, "src"); await ensureDir3(srcPath); const dbPath = join5(srcPath, "db"); await ensureDir3(dbPath); await addDrizzleConfig(projectPath); await addSchema(dbPath); await addClient(dbPath); await updatePackageJson4(projectPath); await addEnvExample3(projectPath); console.log("\u2713 Added Drizzle ORM + PostgreSQL support"); } async function addDrizzleConfig(projectPath) { const config = `import { defineConfig } from 'drizzle-kit'; import * as dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL is missing'); } export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL, }, }); `; await writeFile4(join5(projectPath, "drizzle.config.ts"), config); } async function addSchema(dbPath) { const schema = `import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name'), email: text('email').notNull(), createdAt: timestamp('created_at').defaultNow(), }); `; await writeFile4(join5(dbPath, "schema.ts"), schema); } async function addClient(dbPath) { const client = `import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; const connectionString = process.env.DATABASE_URL!; // Disable prefetch as it is not supported for "Transaction" pool mode const client = postgres(connectionString, { prepare: false }); export const db = drizzle(client, { schema }); `; await writeFile4(join5(dbPath, "index.ts"), client); } async function updatePackageJson4(projectPath) { const packageJsonPath = join5(projectPath, "package.json"); if (!await pathExists4(packageJsonPath)) return; const packageJson = JSON.parse(await readFile4(packageJsonPath, "utf-8")); if (!packageJson.dependencies) packageJson.dependencies = {}; if (!packageJson.devDependencies) packageJson.devDependencies = {}; if (!packageJson.scripts) packageJson.scripts = {}; packageJson.dependencies["drizzle-orm"] = "^0.36.0"; packageJson.dependencies["postgres"] = "^3.4.4"; packageJson.dependencies["dotenv"] = "^16.4.5"; packageJson.devDependencies["drizzle-kit"] = "^0.28.0"; packageJson.devDependencies["pg"] = "^8.13.0"; packageJson.devDependencies["@types/pg"] = "^8.11.10"; packageJson.scripts["db:generate"] = "drizzle-kit generate"; packageJson.scripts["db:migrate"] = "drizzle-kit migrate"; packageJson.scripts["db:push"] = "drizzle-kit push"; packageJson.scripts["db:studio"] = "drizzle-kit studio"; await writeFile4(packageJsonPath, JSON.stringify(packageJson, null, 2)); } async function addEnvExample3(projectPath) { const envExamplePath = join5(projectPath, ".env.example"); let envContent = ""; if (await pathExists4(envExamplePath)) { envContent = await readFile4(envExamplePath, "utf-8"); } if (!envContent.includes("DATABASE_URL")) { const dbVars = ` # Database (PostgreSQL) DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres" `; envContent = envContent + dbVars; await writeFile4(envExamplePath, envContent); } } // src/features/add-i18n.ts import { ensureDir as ensureDir4, readFile as readFile5, writeFile as writeFile5 } from "fs-extra"; import { join as join6 } from "path"; async function addI18n(projectPath) { const srcPath = join6(projectPath, "src"); await ensureDir4(srcPath); const messagesPath = join6(projectPath, "messages"); await ensureDir4(messagesPath); await addDefaultMessages(messagesPath); const i18nPath = join6(srcPath, "lib", "i18n"); await ensureDir4(i18nPath); await addI18nConfig(i18nPath); await addI18nRequest(i18nPath); await addI18nNavigation(i18nPath); await addI18nMiddleware(srcPath); await addLocaleProvider(i18nPath); await addLanguageSwitcher(srcPath); await addLocaleLayout(srcPath); await updatePackageJson5(projectPath); await updateNextConfig(projectPath); console.log("\u2713 Added i18n support (next-intl v4)"); } async function addDefaultMessages(messagesPath) { const enMessages = { Common: { loading: "Loading...", error: "An error occurred", retry: "Try again", cancel: "Cancel", save: "Save", delete: "Delete", edit: "Edit", back: "Back", next: "Next", previous: "Previous", search: "Search", noResults: "No results found" }, Navigation: { home: "Home", about: "About", contact: "Contact", dashboard: "Dashboard", settings: "Settings", profile: "Profile" }, Auth: { signIn: "Sign In", signOut: "Sign Out", signUp: "Sign Up", email: "Email", password: "Password", forgotPassword: "Forgot password?", rememberMe: "Remember me", noAccount: "Don't have an account?", hasAccount: "Already have an account?" }, Index: { title: "Welcome to AT3 Stack", description: "Build AI-native applications with edge deployment", getStarted: "Get Started", learnMore: "Learn More" }, Errors: { notFound: "Page not found", notFoundDescription: "The page you are looking for does not exist.", serverError: "Server error", serverErrorDescription: "Something went wrong on our end.", goHome: "Go to home" }, LanguageSwitcher: { label: "Language", en: "English", es: "Espa\xF1ol", fr: "Fran\xE7ais", de: "Deutsch", ja: "\u65E5\u672C\u8A9E", zh: "\u4E2D\u6587" } }; const esMessages = { Common: { loading: "Cargando...", error: "Ocurri\xF3 un error", retry: "Intentar de nuevo", cancel: "Cancelar", save: "Guardar", delete: "Eliminar", edit: "Editar", back: "Atr\xE1s", next: "Siguiente", previous: "Anterior", search: "Buscar", noResults: "No se encontraron resultados" }, Navigation: { home: "Inicio", about: "Acerca de", contact: "Contacto", dashboard: "Panel", settings: "Configuraci\xF3n", profile: "Perfil" }, Auth: { signIn: "Iniciar sesi\xF3n", signOut: "Cerrar sesi\xF3n", signUp: "Registrarse", email: "Correo electr\xF3nico", password: "Contrase\xF1a", forgotPassword: "\xBFOlvidaste tu contrase\xF1a?", rememberMe: "Recordarme", noAccount: "\xBFNo tienes una cuenta?", hasAccount: "\xBFYa tienes una cuenta?" }, Index: { title: "Bienvenido a AT3 Stack", description: "Construye aplicaciones nativas de IA con despliegue en el borde", getStarted: "Comenzar", learnMore: "Saber m\xE1s" }, Errors: { notFound: "P\xE1gina no encontrada", notFoundDescription: "La p\xE1gina que buscas no existe.", serverError: "Error del servidor", serverErrorDescription: "Algo sali\xF3 mal de nuestro lado.", goHome: "Ir al inicio" }, LanguageSwitcher: { label: "Idioma", en: "English", es: "Espa\xF1ol", fr: "Fran\xE7ais", de: "Deutsch", ja: "\u65E5\u672C\u8A9E", zh: "\u4E2D\u6587" } }; await writeFile5(join6(messagesPath, "en.json"), JSON.stringify(enMessages, null, 2)); await writeFile5(join6(messagesPath, "es.json"), JSON.stringify(esMessages, null, 2)); } async function addI18nConfig(i18nPath) { const config = `import { getRequestConfig } from 'next-intl/server' import { hasLocale } from 'next-intl' import { routing } from './navigation' export default getRequestConfig(async ({ requestLocale }) => { // Typically corresponds to the \`[locale]\` segment const requested = await requestLocale const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale return { locale, messages: (await import(\`../../../messages/\${locale}.json\`)).default, } }) `; await writeFile5(join6(i18nPath, "config.ts"), config); } async function addI18nRequest(i18nPath) { const request = `import { getRequestConfig } from 'next-intl/server' import { routing } from './navigation' export default getRequestConfig(async ({ requestLocale }) => { // This typically corresponds to the \`[locale]\` segment let locale = await requestLocale // Ensure that the incoming \`locale\` is valid if (!locale || !routing.locales.includes(locale as any)) { locale = routing.defaultLocale } return { locale, messages: (await import(\`../../../messages/\${locale}.json\`)).default, timeZone: 'UTC', now: new Date(), } }) `; await writeFile5(join6(i18nPath, "request.ts"), request); } async function addI18nNavigation(i18nPath) { const navigation = `import { createNavigation } from 'next-intl/navigation' import { defineRouting } from 'next-intl/routing' export const locales = ['en', 'es'] as const export const defaultLocale = 'en' as const export type Locale = (typeof locales)[number] export const routing = defineRouting({ locales, defaultLocale, localePrefix: 'as-needed', }) // Lightweight wrappers around Next.js' navigation APIs // that will consider the routing configuration export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing) `; await writeFile5(join6(i18nPath, "navigation.ts"), navigation); } async function addI18nMiddleware(srcPath) { const middleware = `import createMiddleware from 'next-intl/middleware' import { routing } from './lib/i18n/navigation' export default createMiddleware(routing) export const config = { // Match all pathnames except for // - ... if they start with \`/api\`, \`/_next\` or \`/_vercel\` // - ... the ones containing a dot (e.g. \`favicon.ico\`) matcher: ['/((?!api|_next|_vercel|.*\\\\..*).*)'], } `; await writeFile5(join6(srcPath, "middleware.ts"), middleware); } async function addLocaleProvider(i18nPath) { const provider = `'use client' import { NextIntlClientProvider } from 'next-intl' import type { ReactNode } from 'react' interface LocaleProviderProps { children: ReactNode locale: string messages: Record<string, unknown> timeZone?: string now?: Date } export function LocaleProvider({ children, locale, messages, timeZone = 'UTC', now, }: LocaleProviderProps) { return ( <NextIntlClientProvider locale={locale} messages={messages} timeZone={timeZone} now={now} > {children} </NextIntlClientProvider> ) } `; await writeFile5(join6(i18nPath, "provider.tsx"), provider); const index = `export { locales, defaultLocale, routing, type Locale } from './navigation' export { Link, redirect, usePathname, useRouter, getPathname } from './navigation' export { LocaleProvider } from './provider' `; await writeFile5(join6(i18nPath, "index.ts"), index); } async function addLanguageSwitcher(srcPath) { const componentPath = join6(srcPath, "components", "layout"); await ensureDir4(componentPath); const switcher = `'use client' import { useLocale, useTranslations } from 'next-intl' import { usePathname, useRouter } from '@/lib/i18n/navigation' import { locales, type Locale } from '@/lib/i18n/navigation' interface LanguageSwitcherProps { className?: string } export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const t = useTranslations('LanguageSwitcher') const locale = useLocale() const router = useRouter() const pathname = usePathname() const handleChange = (newLocale: string) => { router.replace(pathname, { locale: newLocale as Locale }) } return ( <div className={className}> <label htmlFor="language-select" className="sr-only"> {t('label')} </label> <select id="language-select" value={locale} onChange={(e) => handleChange(e.target.value)} className="rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" > {locales.map((loc) => ( <option key={loc} value={loc}> {t(loc)} </option> ))} </select> </div> ) } /** * Alternative: Language switcher with buttons */ export function LanguageSwitcherButtons({ className }: LanguageSwitcherProps) { const t = useTranslations('LanguageSwitcher') const locale = useLocale() const router = useRouter() const pathname = usePathname() return ( <div className={\`flex gap-2 \${className}\`}> {locales.map((loc) => ( <button key={loc} onClick={() => router.replace(pathname, { locale: loc })} disabled={locale === loc} className={\`px-3 py-1 text-sm rounded-md transition-colors \${ locale === loc ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground hover:bg-secondary/80' }\`} > {t(loc)} </button> ))} </div> ) } `; await writeFile5(join6(componentPath, "language-switcher.tsx"), switcher); } async function addLocaleLayout(srcPath) { const localePath = join6(srcPath, "app", "[locale]"); await ensureDir4(localePath); const layout = `import { NextIntlClientProvider, hasLocale } from 'next-intl' import { getMessages, setRequestLocale } from 'next-intl/server' import { notFound } from 'next/navigation' import type { ReactNode } from 'react' import { routing } from '@/lib/i18n/navigation' interface LocaleLayoutProps { children: ReactNode params: Promise<{ locale: string }> } export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })) } export default async function LocaleLayout({ children, params, }: LocaleLayoutProps) { const { locale } = await params // Ensure that the incoming \`locale\` is valid if (!hasLocale(routing.locales, locale)) { notFound() } // Enable static rendering setRequestLocale(locale) // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages() return ( <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> ) } `; await writeFile5(join6(localePath, "layout.tsx"), layout); const page = `import { useTranslations } from 'next-intl' import { setRequestLocale } from 'next-intl/server' import { Link } from '@/lib/i18n/navigation' interface HomePageProps { params: Promise<{ locale: string }> } export default async function HomePage({ params }: HomePageProps) { const { locale } = await params // Enable static rendering setRequestLocale(locale) return <HomeContent /> } function HomeContent() { const t = useTranslations('Index') return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-4xl font-bold mb-4">{t('title')}</h1> <p className="text-lg text-muted-foreground mb-8">{t('description')}</p> <div className="flex gap-4"> <Link href="/dashboard" className="rounded-md bg-primary px-6 py-3 text-primary-foreground hover:bg-primary/90" > {t('getStarted')} </Link> <Link href="/about" className="rounded-md border border-input bg-background px-6 py-3 hover:bg-accent hover:text-accent-foreground" > {t('learnMore')} </Link> </div> </main> ) } `; await writeFile5(join6(localePath, "page.tsx"), page); const notFound = `import { useTranslations } from 'next-intl' import { Link } from '@/lib/i18n/navigation' export default function NotFoundPage() { const t = useTranslations('Errors') return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-4xl font-bold mb-4">{t('notFound')}</h1> <p className="text-lg text-muted-foreground mb-8"> {t('notFoundDescription')} </p> <Link href="/" className="rounded-md bg-primary px-6 py-3 text-primary-foreground hover:bg-primary/90" > {t('goHome')} </Link> </main> ) } `; await writeFile5(join6(localePath, "not-found.tsx"), notFound); } async function updatePackageJson5(projectPath) { const packageJsonPath = join6(projectPath, "package.json"); const packageJson = JSON.parse(await readFile5(packageJsonPath, "utf-8")); if (!packageJson.dependencies) packageJson.dependencies = {}; packageJson.dependencies["next-intl"] = "^4.5.5"; await writeFile5(packageJsonPath, JSON.stringify(packageJson, null, 2)); } async function updateNextConfig(projectPath) { const configPath = join6(projectPath, "next.config.ts"); try { let configContent = await readFile5(configPath, "utf-8"); if (configContent.includes("withNextIntl") || configContent.includes("next-intl/plugin")) { return; } const importStatement = `import createNextIntlPlugin from 'next-intl/plugin' `; const pluginInit = `const withNextIntl = createNextIntlPlugin('./src/lib/i18n/request.ts') `; if (!configContent.includes("next-intl/plugin")) { configContent = importStatement + configContent; } const exportDefaultRegex = /export\s+default\s+(\w+);?\s*$/m; const match = configContent.match(exportDefaultRegex); if (match) { const configName = match[1]; configContent = configContent.replace( exportDefaultRegex, `${pluginInit}export default withNextIntl(${configName}) ` ); } else { const inlineExportRegex = /export\s+default\s+({[\s\S]*?})\s*;?\s*$/m; const inlineMatch = configContent.match(inlineExportRegex); if (inlineMatch) { configContent = configContent.replace( inlineExportRegex, `const nextConfig = ${inlineMatch[1]} ${pluginInit}export default withNextIntl(nextConfig) ` ); } } await writeFile5(configPath, configContent); } catch (error) { console.warn( "Could not update next.config.ts automatically. Please wrap your config with withNextIntl manually." ); } } // src/features/add-pwa.ts import { join as join7 } from "path"; import { ensureDir as ensureDir5, readFile as readFile6, writeFile as writeFile6 } from "fs-extra"; async function addPWA(projectPath) { const srcPath = join7(projectPath, "src"); const publicPath = join7(projectPath, "public"); await ensureDir5(publicPath); await ensureDir5(join7(publicPath, "icons")); await addManifest(publicPath); await addServiceWorker(publicPath); await addOfflinePage(srcPath); await addPWAUtils(srcPath); await addInstallPrompt(srcPath); await addPWAProvider(srcPath); await updatePackageJson6(projectPath); await updateNextConfig2(projectPath); await updateLayout(srcPath); console.log("\u2713 PWA support added"); } async function addManifest(publicPath) { const manifest = { name: "AT3 App", short_name: "AT3", description: "AT3 Stack Application - AI-native, edge-first", start_url: "/", display: "standalone", background_color: "#ffffff", theme_color: "#6366f1", orientation: "portrait-primary", scope: "/", lang: "en", categories: ["productivity", "utilities"], icons: [ { src: "/icons/icon-72x72.png", sizes: "72x72", type: "image/png", purpose: "any" }, { src: "/icons/icon-96x96.png", sizes: "96x96", type: "image/png", purpose: "any" }, { src: "/icons/icon-128x128.png", sizes: "128x128", type: "image/png", purpose: "any" }, { src: "/icons/icon-144x144.png", sizes: "144x144", type: "image/png", purpose: "any" }, { src: "/icons/icon-152x152.png", sizes: "152x152", type: "image/png", purpose: "any" }, { src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: "/icons/icon-384x384.png", sizes: "384x384", type: "image/png", purpose: "any" }, { src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" } ], screenshots: [ { src: "/screenshots/desktop.png", sizes: "1280x720", type: "image/png", form_factor: "wide", label: "Desktop view" }, { src: "/screenshots/mobile.png", sizes: "750x1334", type: "image/png", form_factor: "narrow", label: "Mobile view" } ], shortcuts: [ { name: "Dashboard", short_name: "Dashboard", description: "Go to dashboard", url: "/dashboard", icons: [{ src: "/icons/dashboard-96x96.png", sizes: "96x96" }] } ] }; await writeFile6(join7(publicPath, "manifest.json"), JSON.stringify(manifest, null, 2)); } async function addServiceWorker(publicPath) { const serviceWorker = `// Custom Service Worker for AT3 PWA // This works alongside @ducanh2912/next-pwa const CACHE_NAME = 'at3-cache-v1' const OFFLINE_URL = '/offline' // Resources to pre-cache const PRECACHE_RESOURCES = [ '/', '/offline', '/manifest.json', ] // Install event - pre-cache resources self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('[SW] Pre-caching resources') return cache.addAll(PRECACHE_RESOURCES) }) ) // Force waiting service worker to become active self.skipWaiting() }) // Activate event - clean up old caches self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((cacheName) => cacheName !== CACHE_NAME) .map((cacheName) => { console.log('[SW] Deleting old cache:', cacheName) return caches.delete(cacheName) }) ) }) ) // Take control of all pages immediately self.clients.claim() }) // Fetch event - network first, fallback to cache self.addEventListener('fetch', (event) => { // Skip non-GET requests if (event.request.method !== 'GET') return // Skip cross-origin requests if (!event.request.url.startsWith(self.location.origin)) return // Handle navigation requests if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(() => { return caches.match(OFFLINE_URL) }) ) return } // Network first strategy for API requests if (event.request.url.includes('/api/')) { event.respondWith( fetch(event.request).catch(() => { return new Response( JSON.stringify({ error: 'Offline', message: 'No network connection' }), { status: 503, headers: { 'Content-Type': 'application/json' }, } ) }) ) return } // Cache first strategy for static assets event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { // Return cached response and update cache in background event.waitUntil( fetch(event.request).then((networkResponse) => { if (networkResponse.ok) { caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, networkResponse.clone()) }) } }) ) return cachedResponse } // If not in cache, fetch from network return fetch(event.request).then((networkResponse) => { // Cache successful responses if (networkResponse.ok) { const responseClone = networkResponse.clone() caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseClone) }) } return networkResponse }) }) ) }) // Push notification event self.addEventListener('push', (event) => { if (!event.data) return const data = event.data.json() const options = { body: data.body || 'New notification', icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', vibrate: [100, 50, 100], data: { url: data.url || '/', }, actions: data.actions || [], } event.waitUntil( self.registration.showNotification(data.title || 'AT3 App', options) ) }) // Notification click event self.addEventListener('notificationclick', (event) => { event.notification.close() const url = event.notification.data?.url || '/' event.waitUntil( clients.matchAll({ type: 'window' }).then((clientList) => { // If a window is already open, focus it for (const client of clientList) { if (client.url === url && 'focus' in client) { return client.focus() } } // Otherwise, open a new window if (clients.openWindow) { return clients.openWindow(url) } }) ) }) // Background sync event self.addEventListener('sync', (event) => { if (event.tag === 'sync-data') { event.waitUntil(syncData()) } }) async function syncData() { // Implement your background sync logic here console.log('[SW] Background sync triggered') } `; await writeFile6(join7(publicPath, "sw.js"), serviceWorker); } async function addOfflinePage(srcPath) { const offlinePath = join7(srcPath, "app", "offline"); await ensureDir5(offlinePath); const offlinePage = `export default function OfflinePage() { return ( <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-background text-foreground"> <div className="text-center"> <div className="mb-8"> <svg className="mx-auto h-24 w-24 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a5 5 0 01-7.072-7.072m0 0l2.829 2.829M6.343 6.343L3 3" /> </svg> </div> <h1 className="text-3xl font-bold tracking-tight sm:text-4xl"> You&apos;re offline </h1> <p className="mt-4 text-lg text-muted-foreground max-w-md mx-auto"> It looks like you&apos;ve lost your internet connection. Please check your network and try again. </p> <div className="mt-8"> <button onClick={() => window.location.reload()} className="rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > Try again </button> </div> <p className="mt-6 text-sm text-muted-foreground"> Some features may still be available offline. </p> </div> </main> ) } `; await writeFile6(join7(offlinePath, "page.tsx"), offlinePage); } async function addPWAUtils(srcPath) { const pwaPath = join7(srcPath, "lib", "pwa"); await ensureDir5(pwaPath); const pwaHooks = `'use client' import { useCallback, useEffect, useState } from 'react' interface BeforeInstallPromptEvent extends Event { prompt(): Promise<void> userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> } /** * Hook to manage PWA installation */ export function useInstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null) const [isInstallable, setIsInstallable] = useState(false) const [isInstalled, setIsInstalled] = useState(false) useEffect(() => { // Check if already installed if (window.matchMedia('(display-mode: standalone)').matches) { setIsInstalled(true) return } const handleBeforeInstall = (e: Event) => { e.preventDefault() setDeferredPrompt(e as BeforeInstallPromptEvent) setIsInstallable(true) } const handleAppInstalled = () => { setIsInstalled(true) setIsInstallable(false) setDeferredPrompt(null) } window.addEventListener('beforeinstallprompt', handleBeforeInstall) window.addEventListener('appinstalled', handleAppInstalled) return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstall) window.removeEventListener('appinstalled', handleAppInstalled) } }, []) const install = useCallback(async () => { if (!deferredPrompt) return false await deferredPrompt.prompt() const { outcome } = await deferredPrompt.userChoice setDeferredPrompt(null) setIsInstallable(false) return outcome === 'accepted' }, [deferredPrompt]) return { isInstallable, isInstalled, install } } /** * Hook to check online/offline status */ export function useOnlineStatus() { const [isOnline, setIsOnline] = useState( typeof navigator !== 'undefined' ? navigator.onLine : true ) useEffect(() => { const handleOnline = () => setIsOnline(true) const handleOffline = () => setIsOnline(false) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) return () => { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) } }, []) return isOnline } /** * Hook to manage service worker registration */ export function useServiceWorker() { const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null) const [updateAvailable, setUpdateAvailable] = useState(false) useEffect(() => { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((reg) => { setRegistration(reg) reg.addEventListener('updatefound', () => { const newWorker = reg.installing if (newWorker) { newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { setUpdateAvailable(true) } }) } }) }) } }, []) const update = useCallback(() => { if (registration?.waiting) { registration.waiting.postMessage({ type: 'SKIP_WAITING' }) window.location.reload() } }, [registration]) const checkForUpdates = useCallback(async () => { if (registration) { await registration.update() } }, [registration]) return { registration, updateAvailable, update, checkForUpdates } } /** * Hook to manage push notifications */ export function usePushNotifications() { const [permission, setPermission] = useState<NotificationPermission>('default') const [subscription, setSubscription] = useState<PushSubscription | null>(null) useEffect(() => { if ('Notification' in window) { setPermission(Notification.permission) } }, []) const requestPermission = useCallback(async () => { if (!('Notification' in window)) return false const result = await Notification.requestPermission() setPermission(result) return result === 'granted' }, []) const subscribe = useCallback(async (vapidPublicKey: string) => { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return null } const registration = await navigator.serviceWorker.ready const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) setSubscription(sub) return sub }, []) const unsubscribe = useCallback(async () => { if (subscription) { await subscription.unsubscribe() setSubscription(null) } }, [subscription]) return { permission, subscription, requestPermission, subscribe, unsubscribe } } function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = window.atob(base64) const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i) } return outputArray } `; await writeFile6(join7(pwaPath, "hooks.ts"), pwaHooks); const index = `export { useInstallPrompt, useOnlineStatus, useServiceWorker, usePushNotifications } from './hooks' export { PWAProvider } from './provider' export { InstallPrompt } from './install-prompt' `; await writeFile6(join7(pwaPath, "index.ts"), index); } async function addInstallPrompt(srcPath) { const pwaPath = join7(srcPath, "lib", "pwa"); const installPrompt = `'use client' import { useEffect, useState } from 'react' import { useInstallPrompt } from './hooks' interface InstallPromptProps { title?: string description?: string installText?: string dismissText?: string onInstall?: () => void onDismiss?: () => void } export function InstallPrompt({ title = 'Install App', description = 'Install this app on your device for a better experience.', installText = 'Install', dismissText = 'Not now', onInstall, onDismiss, }: InstallPromptProps) { const { isInstallable, isInstalled, install } = useInstallPrompt() const [isDismissed, setIsDismissed] = useState(false) const [showPrompt, setShowPrompt] = useState(false) useEffect(() => { // Check if user has previously dismissed the prompt const dismissed = localStorage.getItem('pwa-install-dismissed') if (dismissed) { const dismissedTime = parseInt(dismissed, 10) // Show again after 7 days if (Date.now() - dismissedTime < 7 * 24 * 60 * 60 * 1000) { setIsDismissed(true) } } // Delay showing the prompt const timer = setTimeout(() => { setShowPrompt(true) }, 3000) return () => clearTimeout(timer) }, []) if (!showPrompt || !isInstallable || isInstalled || isDismisse