@entro314labs/at3-stack-kit
Version:
Upgrade existing projects to AT3 Stack with intelligent migration
1,657 lines (1,511 loc) • 98.4 kB
JavaScript
#!/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're offline
</h1>
<p className="mt-4 text-lg text-muted-foreground max-w-md mx-auto">
It looks like you'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