From f051942ef64f1d1fd080a6c90aa99b418b86687e Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 20:17:02 -0700 Subject: [PATCH] feat(create-app): add API Route Generator (3.3) + AGENTS.md Auto-Generator (3.4) 3.3 API Route Generator (api-routes.ts): - Two modes: 'direct' (Cosmos DB CRUD) and 'proxy' (backend fetch) - Generates route.ts + [id]/route.ts (Next.js App Router named exports) - Direct mode also generates lib/schemas/ + lib/repositories/ files - withErrorHandler HOF wrapper, Zod validation, auth check - Dry-run preview, configurable methods, skip existing files 3.4 AGENTS.md Auto-Generator (agents-md.ts): - Reads shared/product.json for identity, port, domain - Scans repo for backend modules, lib files, test counts - Generates full AGENTS.md with identity, layout, stack, conventions - --update preserves CUSTOM sections - Creates CLAUDE.md, .cursorrules, .windsurfrules symlinks --- packages/create-app/package.json | 22 + .../create-app/src/generators/agents-md.ts | 603 ++++++++++++++ .../create-app/src/generators/api-routes.ts | 768 ++++++++++++++++++ packages/create-app/src/index.ts | 9 + packages/create-app/tsconfig.json | 9 + 5 files changed, 1411 insertions(+) create mode 100644 packages/create-app/package.json create mode 100644 packages/create-app/src/generators/agents-md.ts create mode 100644 packages/create-app/src/generators/api-routes.ts create mode 100644 packages/create-app/src/index.ts create mode 100644 packages/create-app/tsconfig.json diff --git a/packages/create-app/package.json b/packages/create-app/package.json new file mode 100644 index 00000000..aaa1efbd --- /dev/null +++ b/packages/create-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bytelyst/create-app", + "version": "0.1.0", + "private": true, + "description": "CLI tools for scaffolding ByteLyst product repos and code", + "type": "module", + "bin": { + "gen-api-route": "./dist/generators/api-routes.js", + "gen-agents-md": "./dist/generators/agents-md.js" + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "gen:api-route": "tsx src/generators/api-routes.ts", + "gen:agents-md": "tsx src/generators/agents-md.ts" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/create-app/src/generators/agents-md.ts b/packages/create-app/src/generators/agents-md.ts new file mode 100644 index 00000000..f3d99b75 --- /dev/null +++ b/packages/create-app/src/generators/agents-md.ts @@ -0,0 +1,603 @@ +#!/usr/bin/env node +/** + * AGENTS.md Auto-Generator + * + * Generates AGENTS.md from product.json + repo directory scan. + * Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules + * + * Usage: + * npx tsx agents-md.ts --repo /path/to/product-repo + * npx tsx agents-md.ts --repo /path/to/product-repo --dry-run + * npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves sections + * + * @module @bytelyst/create-app/generators/agents-md + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// ── CLI ────────────────────────────────────────────────────────────────────── + +interface Options { + repo: string; + dryRun: boolean; + update: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + const options: Options = { repo: '.', dryRun: false, update: false }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--repo' || arg === '-r') options.repo = args[++i]; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--update' || arg === '-u') options.update = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +function showHelp(): void { + console.log(` +AGENTS.md Auto-Generator + +Generates AGENTS.md from product.json + repo scan, plus symlinks for +CLAUDE.md, .cursorrules, and .windsurfrules. + +Usage: + npx tsx agents-md.ts --repo [--dry-run] [--update] + +Options: + --repo, -r Path to product repo root (default: ".") + --dry-run, -d Preview without writing files + --update, -u Preserve sections in existing AGENTS.md + --help, -h Show this help + +Custom Sections: + Wrap any hand-written content in / + markers. The --update flag preserves these sections when regenerating. +`); +} + +// ── Product.json Loader ────────────────────────────────────────────────────── + +interface ProductManifest { + productId: string; + displayName: string; + tagline?: string; + domain?: string; + backendPort?: number; + primarySurface?: string; + mobileCompanion?: boolean; + bundleIds?: Record; + bundleId?: string; + version?: string; + description?: string; + licensePrefix?: string; + configDirName?: string; + envVarPrefix?: string; +} + +async function loadProductJson(repoPath: string): Promise { + const candidates = [ + path.join(repoPath, 'shared', 'product.json'), + path.join(repoPath, 'product.json'), + ]; + + for (const p of candidates) { + try { + const raw = await fs.readFile(p, 'utf-8'); + return JSON.parse(raw); + } catch { + // try next + } + } + + throw new Error('product.json not found in shared/ or repo root'); +} + +// ── Repo Scanner ───────────────────────────────────────────────────────────── + +interface RepoInfo { + repoName: string; + hasFastifyBackend: boolean; + hasNextWeb: boolean; + hasExpoMobile: boolean; + hasSwiftIos: boolean; + hasKotlinAndroid: boolean; + hasKmpShared: boolean; + backendModules: string[]; + backendTestCount: number; + webTestCount: number; + mobileTestCount: number; + backendLibFiles: string[]; + webLibFiles: string[]; + cosmosContainers: string[]; + techStack: { layer: string; tech: string }[]; + buildCommands: string[]; +} + +async function dirExists(p: string): Promise { + try { + const st = await fs.stat(p); + return st.isDirectory(); + } catch { + return false; + } +} + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +function countTestsInFiles(dir: string): number { + try { + const output = execSync( + `grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`, + { encoding: 'utf-8', timeout: 5000 } + ); + return parseInt(output.trim()) || 0; + } catch { + return 0; + } +} + +async function listDirs(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter(e => e.isDirectory()).map(e => e.name); + } catch { + return []; + } +} + +async function listFiles(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter(e => e.isFile()).map(e => e.name); + } catch { + return []; + } +} + +async function scanRepo(repoPath: string, manifest: ProductManifest): Promise { + const repoName = path.basename(repoPath); + + // Detect surfaces + const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src')); + const hasNextWeb = + (await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) || + (await fileExists(path.join(repoPath, 'web', 'next.config.js'))) || + (await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts'))); + const hasExpoMobile = + (await fileExists(path.join(repoPath, 'mobile', 'app.json'))) || + (await fileExists(path.join(repoPath, 'app.json'))); + const hasSwiftIos = await dirExists(path.join(repoPath, 'ios')); + const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android')); + const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src')); + + // Backend modules + const modulesDir = path.join(repoPath, 'backend', 'src', 'modules'); + const backendModules = await listDirs(modulesDir); + + // Backend lib files + const libDir = path.join(repoPath, 'backend', 'src', 'lib'); + const backendLibFiles = (await listFiles(libDir)).filter( + f => f.endsWith('.ts') && !f.endsWith('.test.ts') + ); + + // Web lib files + let webLibDir = path.join(repoPath, 'web', 'src', 'lib'); + if (!(await dirExists(webLibDir))) { + webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib'); + } + const webLibFiles = (await listFiles(webLibDir)).filter( + f => f.endsWith('.ts') && !f.endsWith('.test.ts') + ); + + // Test counts + const backendTestCount = hasFastifyBackend + ? countTestsInFiles(path.join(repoPath, 'backend', 'src')) + : 0; + + let webTestDir = path.join(repoPath, 'web', 'src'); + if (!(await dirExists(webTestDir))) { + webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src'); + } + const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0; + + const mobileTestDir = hasExpoMobile + ? (await dirExists(path.join(repoPath, 'mobile'))) + ? path.join(repoPath, 'mobile') + : repoPath + : ''; + const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0; + + // Cosmos containers — scan backend types files + const cosmosContainers: string[] = []; + for (const mod of backendModules) { + const typesFile = path.join(modulesDir, mod, 'types.ts'); + if (await fileExists(typesFile)) { + cosmosContainers.push(mod.replace(/-/g, '_')); + } + } + + // Tech stack + const techStack: { layer: string; tech: string }[] = []; + if (hasFastifyBackend) + techStack.push({ + layer: 'Backend', + tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`, + }); + if (hasNextWeb) + techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' }); + if (hasExpoMobile) + techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' }); + if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' }); + if (hasKotlinAndroid) + techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' }); + if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' }); + techStack.push({ + layer: 'Platform', + tech: 'platform-service (port 4003) for auth, flags, telemetry, billing', + }); + techStack.push({ + layer: 'Database', + tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`, + }); + + // Build commands + const buildCommands: string[] = []; + if (hasFastifyBackend) { + const port = manifest.backendPort ?? 4000; + buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`); + buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`); + buildCommands.push(`cd backend && npm test # Vitest tests`); + } + if (hasNextWeb) { + buildCommands.push(`cd web && npm run dev # Dev server`); + buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`); + buildCommands.push(`cd web && npm run build # Production build`); + } + if (hasExpoMobile) { + buildCommands.push(`cd mobile && npm start # Expo dev server`); + buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`); + } + + return { + repoName, + hasFastifyBackend, + hasNextWeb, + hasExpoMobile, + hasSwiftIos, + hasKotlinAndroid, + hasKmpShared, + backendModules, + backendTestCount, + webTestCount, + mobileTestCount, + backendLibFiles, + webLibFiles, + cosmosContainers, + techStack, + buildCommands, + }; +} + +// ── Markdown Generator ─────────────────────────────────────────────────────── + +function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string { + const { productId, displayName, domain, tagline } = manifest; + const repoDesc = tagline ?? `${displayName} product`; + + const lines: string[] = []; + + // ── Header + lines.push(`# AGENTS.md — AI Coding Agent Instructions`); + lines.push(''); + lines.push( + `> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.` + ); + lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`); + lines.push(''); + lines.push('---'); + lines.push(''); + + // ── 1. Project Identity + lines.push('## 1. Project Identity'); + lines.push(''); + lines.push('| Key | Value |'); + lines.push('|-----|-------|'); + lines.push(`| **Product** | ${displayName} |`); + lines.push(`| **Product ID** | \`${productId}\` |`); + if (domain) lines.push(`| **Domain** | ${domain} |`); + lines.push(`| **Repo** | \`${info.repoName}\` |`); + lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`); + lines.push(''); + + // ── 2. Repo Layout (simplified tree) + lines.push('## 2. Repo Layout'); + lines.push(''); + lines.push('```'); + lines.push(`${info.repoName}/`); + if (info.hasFastifyBackend) { + const port = manifest.backendPort ?? 4000; + lines.push( + `├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})` + ); + lines.push(`│ ├── src/`); + if (info.backendLibFiles.length > 0) { + lines.push(`│ │ ├── lib/ # Shared backend wiring`); + for (const f of info.backendLibFiles.slice(0, 8)) { + lines.push(`│ │ │ ├── ${f}`); + } + if (info.backendLibFiles.length > 8) { + lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`); + } + } + if (info.backendModules.length > 0) { + lines.push(`│ │ ├── modules/`); + for (const m of info.backendModules) { + lines.push(`│ │ │ ├── ${m}/`); + } + } + lines.push(`│ │ └── server.ts`); + lines.push(`│ ├── package.json`); + lines.push(`│ └── tsconfig.json`); + lines.push('│'); + } + if (info.hasNextWeb) { + lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`); + lines.push(`│ ├── src/`); + lines.push(`│ │ ├── app/ # App Router pages`); + if (info.webLibFiles.length > 0) { + lines.push(`│ │ └── lib/ # Pure TS clients + config`); + } + lines.push(`│ ├── package.json`); + lines.push(`│ └── tsconfig.json`); + lines.push('│'); + } + if (info.hasExpoMobile) { + lines.push(`├── mobile/ # React Native + Expo`); + lines.push('│'); + } + if (info.hasSwiftIos) { + lines.push(`├── ios/ # SwiftUI native app`); + lines.push('│'); + } + if (info.hasKotlinAndroid) { + lines.push(`├── android/ # Jetpack Compose`); + lines.push('│'); + } + lines.push(`├── shared/`); + lines.push(`│ └── product.json # Canonical product identity`); + lines.push(`├── AGENTS.md # This file`); + lines.push(`└── README.md`); + lines.push('```'); + lines.push(''); + + // ── 3. Tech Stack + lines.push('## 3. Tech Stack'); + lines.push(''); + lines.push('| Layer | Technology |'); + lines.push('|-------|-----------|'); + for (const { layer, tech } of info.techStack) { + lines.push(`| **${layer}** | ${tech} |`); + } + + // Test counts + const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount; + if (totalTests > 0) { + const parts: string[] = []; + if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`); + if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`); + if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`); + lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`); + } + lines.push(''); + + // ── 4. Coding Conventions + lines.push('## 4. Coding Conventions'); + lines.push(''); + lines.push('### MUST follow'); + lines.push(''); + lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`); + if (info.hasFastifyBackend) { + lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern'); + lines.push( + '- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls' + ); + } + if (info.hasNextWeb) { + lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports'); + lines.push('- Web components in `web/src/components/` — React UI only'); + } + if (info.hasExpoMobile) { + lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports'); + } + lines.push( + '- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`' + ); + lines.push(''); + lines.push('### MUST NOT do'); + lines.push(''); + lines.push( + '- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify' + ); + lines.push('- Never use `any` type — use Zod inference or explicit types'); + lines.push('- Never hardcode colors — use theme tokens'); + lines.push('- Never hardcode API URLs — use env vars or config'); + lines.push(`- Never hardcode product ID — use \`productConfig.productId\``); + lines.push('- Never modify tests to make them pass — fix the actual code'); + lines.push('- Never delete existing comments or documentation unless explicitly asked'); + lines.push('- Never add emojis to code unless explicitly asked'); + lines.push(''); + + // ── 5. Build & Test Commands + if (info.buildCommands.length > 0) { + lines.push('## 5. Build & Test Commands'); + lines.push(''); + lines.push('```bash'); + for (const cmd of info.buildCommands) { + lines.push(cmd); + } + lines.push('```'); + lines.push(''); + } + + // ── 6. Backend API Modules (if applicable) + if (info.hasFastifyBackend && info.backendModules.length > 0) { + lines.push('## 6. Backend Modules'); + lines.push(''); + lines.push('| Module | Container | Description |'); + lines.push('|--------|-----------|-------------|'); + for (const mod of info.backendModules) { + const container = mod.replace(/-/g, '_'); + lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`); + } + lines.push(''); + } + + // ── Custom section placeholder + lines.push(''); + lines.push(''); + lines.push(''); + + return lines.join('\n'); +} + +// ── Custom Section Preservation ────────────────────────────────────────────── + +function extractCustomSections(content: string): Map { + const sections = new Map(); + const regex = /\n([\s\S]*?)/g; + let match; + while ((match = regex.exec(content)) !== null) { + sections.set(match[1], match[2]); + } + return sections; +} + +function mergeCustomSections(newContent: string, existing: Map): string { + let result = newContent; + for (const [key, value] of existing) { + const placeholder = `\n`; + const replacement = `\n${value}`; + result = result.replace(placeholder, replacement); + } + return result; +} + +// ── Symlink Manager ────────────────────────────────────────────────────────── + +async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise { + const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules']; + + for (const target of targets) { + const linkPath = path.join(repoPath, target); + const exists = await fileExists(linkPath); + + if (exists) { + try { + const stat = await fs.lstat(linkPath); + if (stat.isSymbolicLink()) { + const linkTarget = await fs.readlink(linkPath); + if (linkTarget === 'AGENTS.md') { + continue; // already correct + } + } + } catch { + // not a symlink + } + } + + if (dryRun) { + console.log(` 📝 Would create symlink: ${target} → AGENTS.md`); + } else { + try { + if (exists) await fs.unlink(linkPath); + await fs.symlink('AGENTS.md', linkPath); + console.log(` ✅ ${target} → AGENTS.md`); + } catch (err) { + console.log( + ` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const { repo, dryRun, update } = parseArgs(); + const repoPath = path.resolve(repo); + + console.log(`\n📄 AGENTS.md Generator`); + console.log(` Repo: ${repoPath}`); + if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); + if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n'); + + // Load product.json + const manifest = await loadProductJson(repoPath); + console.log(` Product: ${manifest.displayName} (${manifest.productId})`); + + // Scan repo + const info = await scanRepo(repoPath, manifest); + console.log(` Backend modules: ${info.backendModules.length}`); + console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`); + console.log(''); + + // Generate content + let content = generateAgentsMd(manifest, info); + + // Merge custom sections if updating + if (update) { + const agentsPath = path.join(repoPath, 'AGENTS.md'); + try { + const existing = await fs.readFile(agentsPath, 'utf-8'); + const customSections = extractCustomSections(existing); + if (customSections.size > 0) { + console.log(` 🔄 Preserving ${customSections.size} custom section(s)`); + content = mergeCustomSections(content, customSections); + } + } catch { + console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from'); + } + } + + if (dryRun) { + console.log('── AGENTS.md ──────────────────────────────────────'); + console.log(content); + console.log('\n── Symlinks ──────────────────────────────────────'); + await ensureSymlinks(repoPath, true); + console.log('\n✨ Dry run complete.'); + return; + } + + // Write AGENTS.md + const agentsPath = path.join(repoPath, 'AGENTS.md'); + await fs.writeFile(agentsPath, content, 'utf-8'); + console.log(` ✅ AGENTS.md written`); + + // Ensure symlinks + await ensureSymlinks(repoPath, false); + + console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`); +} + +main().catch(err => { + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/packages/create-app/src/generators/api-routes.ts b/packages/create-app/src/generators/api-routes.ts new file mode 100644 index 00000000..c8742d8b --- /dev/null +++ b/packages/create-app/src/generators/api-routes.ts @@ -0,0 +1,768 @@ +#!/usr/bin/env node +/** + * API Route Generator — Next.js App Router + * + * Generates two files: + * src/app/api//route.ts — GET (list) + POST (create) + * src/app/api//[id]/route.ts — GET (detail) + PATCH (update) + DELETE + * + * Follows the pattern used across ByteLyst product dashboards: + * - Named exports (export const GET, POST, PATCH, DELETE) + * - withErrorHandler HOF wrapper + * - Auth via getCurrentUser / getAccessToken + * - Zod validation on POST/PATCH bodies + * - NextRequest + NextResponse + * + * Usage: + * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src + * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src + * + * Modes: + * --mode direct (default) Direct Cosmos DB access via repository functions + * --mode proxy Proxy to product backend via fetch (for thin web clients) + * + * @module @bytelyst/create-app/generators/api-routes + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +// ── CLI ────────────────────────────────────────────────────────────────────── + +interface Options { + name: string; + fields: string; + target: string; + mode: 'direct' | 'proxy'; + methods: string[]; + withHandler: boolean; + dryRun: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + const options: Options = { + name: '', + fields: '', + target: './src', + mode: 'direct', + methods: ['GET', 'POST', 'PATCH', 'DELETE'], + withHandler: true, + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--name' || arg === '-n') options.name = args[++i]; + else if (arg === '--fields' || arg === '-f') options.fields = args[++i]; + else if (arg === '--target' || arg === '-t') options.target = args[++i]; + else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy'; + else if (arg === '--methods') + options.methods = args[++i].split(',').map(m => m.trim().toUpperCase()); + else if (arg === '--no-handler') options.withHandler = false; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + if (!options.name) { + console.error('Error: --name is required'); + showHelp(); + process.exit(1); + } + + if (!/^[a-z][a-z0-9-]*$/.test(options.name)) { + console.error('Error: --name must be lowercase alphanumeric with optional hyphens'); + process.exit(1); + } + + if (!['direct', 'proxy'].includes(options.mode)) { + console.error('Error: --mode must be "direct" or "proxy"'); + process.exit(1); + } + + return options; +} + +function showHelp(): void { + console.log(` +API Route Generator — Next.js App Router + +Generates CRUD API route files for a Next.js App Router project. + +Usage: + npx tsx api-routes.ts --name [--fields ""] [options] + +Options: + --name, -n Entity name (e.g., "tasks", "sessions") [required] + --fields, -f Comma-separated field definitions [optional for proxy mode] + --target, -t Target src/ directory (default: "./src") + --mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct] + --methods HTTP methods to generate (default: GET,POST,PATCH,DELETE) + --no-handler Skip withErrorHandler wrapper + --dry-run, -d Preview without writing files + --help, -h Show this help + +Field Types (same as gen-module): + string z.string() + number z.number() + boolean z.boolean() + date z.string().datetime() + enum(a,b,c) z.enum(["a","b","c"]) + Append ? for optional fields + +Modes: + direct — Generates route files that call repository functions (direct Cosmos DB). + Also generates a lib/repositories/.ts and lib/schemas/.ts. + proxy — Generates route files that proxy to a product backend via fetch. + Requires lib/api-helpers.ts to exist (createApiClient pattern). + +Examples: + # Direct mode (full CRUD with Cosmos) + npx tsx api-routes.ts --name tasks \\ + --fields "title:string,status:enum(pending,active,done),priority:number?" \\ + --target ./src + + # Proxy mode (thin web client forwarding to backend) + npx tsx api-routes.ts --name tasks --mode proxy --target ./src + + # Preview only + npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run +`); +} + +// ── Field Parser ───────────────────────────────────────────────────────────── + +interface ParsedField { + name: string; + type: string; + optional: boolean; + zodType: string; + tsType: string; + enumValues: string[] | null; +} + +function splitFields(str: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const ch of str) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +function parseFields(fieldsStr: string): ParsedField[] { + if (!fieldsStr) return []; + const fields: ParsedField[] = []; + const fieldDefs = splitFields(fieldsStr); + + for (const raw of fieldDefs) { + const def = raw.trim(); + if (!def) continue; + + const optional = def.endsWith('?'); + const cleaned = optional ? def.slice(0, -1) : def; + const colonIdx = cleaned.indexOf(':'); + if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`); + + const name = cleaned.slice(0, colonIdx).trim(); + const type = cleaned.slice(colonIdx + 1).trim(); + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid field name: ${name}`); + } + + let zodType: string; + let tsType: string; + let enumValues: string[] | null = null; + + if (type.startsWith('enum(') && type.endsWith(')')) { + enumValues = type + .slice(5, -1) + .split(',') + .map(v => v.trim()) + .filter(Boolean); + if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`); + zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`; + tsType = enumValues.map(v => `'${v}'`).join(' | '); + } else { + const MAP: Record = { + string: { zod: 'z.string().min(1)', ts: 'string' }, + number: { zod: 'z.number()', ts: 'number' }, + boolean: { zod: 'z.boolean()', ts: 'boolean' }, + date: { zod: 'z.string().datetime()', ts: 'string' }, + datetime: { zod: 'z.string().datetime()', ts: 'string' }, + 'string[]': { zod: 'z.array(z.string())', ts: 'string[]' }, + 'number[]': { zod: 'z.array(z.number())', ts: 'number[]' }, + }; + const mapping = MAP[type]; + if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`); + zodType = mapping.zod; + tsType = mapping.ts; + } + + fields.push({ name, type, optional, zodType, tsType, enumValues }); + } + return fields; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function pascal(s: string): string { + return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase()); +} +// ── Proxy Mode Templates ──────────────────────────────────────────────────── + +function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string { + const hasGet = methods.includes('GET'); + const hasPost = methods.includes('POST'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getAccessToken } from '@/lib/api-helpers'; +${handlerImport} +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const url = new URL(req.url); + const params = url.searchParams.toString(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, { + headers: { Authorization: \`Bearer \${token}\` }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasPost) { + out += ` +export const POST = ${wrap(`async (req: NextRequest) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, { + method: 'POST', + headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + return out; +} + +function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string { + const hasGet = methods.includes('GET'); + const hasPatch = methods.includes('PATCH'); + const hasDelete = methods.includes('DELETE'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getAccessToken } from '@/lib/api-helpers'; +${handlerImport} +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; + +type RouteContext = { params: Promise<{ id: string }> }; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + headers: { Authorization: \`Bearer \${token}\` }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasPatch) { + out += ` +export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + method: 'PATCH', + headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasDelete) { + out += ` +export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + method: 'DELETE', + headers: { Authorization: \`Bearer \${token}\` }, + }); + if (res.status === 204) return new NextResponse(null, { status: 204 }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + return out; +} + +// ── Direct Mode Templates ─────────────────────────────────────────────────── + +function genSchemaFile(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const createFields = fields + .map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`) + .join('\n'); + const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n'); + + return `import { z } from 'zod'; + +export const Create${P}Schema = z.object({ +${createFields} +}); + +export const Update${P}Schema = z.object({ +${updateFields} +}); + +export type Create${P}Input = z.infer; +export type Update${P}Input = z.infer; +`; +} + +function genRepositoryFile(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n'); + + return `import { randomUUID } from 'node:crypto'; +import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore'; +import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}'; + +export interface ${P}Doc { + id: string; + productId: string; + userId: string; +${docFields} + createdAt: string; + updatedAt: string; +} + +function getContainer() { + return getCosmosContainer('${name}'); +} + +export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> { + const { resources } = await getContainer() + .items.query<${P}Doc>({ + query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim', + parameters: [ + { name: '@uid', value: userId }, + { name: '@pid', value: PRODUCT_ID }, + { name: '@off', value: offset }, + { name: '@lim', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> { + try { + const { resource } = await getContainer().item(id, userId).read<${P}Doc>(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> { + const now = new Date().toISOString(); + const doc: ${P}Doc = { + id: \`${name.slice(0, 3)}_\${randomUUID()}\`, + productId: PRODUCT_ID, + userId, + ...input, + createdAt: now, + updatedAt: now, + }; + await getContainer().items.create(doc); + return doc; +} + +export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> { + const existing = await get${P}(id, userId); + if (!existing) return null; + + const updated: ${P}Doc = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + await getContainer().item(id, userId).replace(updated); + return updated; +} + +export async function delete${P}(id: string, userId: string): Promise { + try { + await getContainer().item(id, userId).delete(); + return true; + } catch { + return false; + } +} +`; +} + +function genDirectListRoute( + name: string, + fields: ParsedField[], + methods: string[], + withHandler: boolean +): string { + const P = pascal(name); + const hasGet = methods.includes('GET'); + const hasPost = methods.includes('POST'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + const imports: string[] = []; + if (hasGet) imports.push(`list${P}`); + if (hasPost) imports.push(`create${P}`); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth-server'; +${handlerImport}`; + + if (imports.length > 0) { + out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; + } + + if (hasPost) { + out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`; + } + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const url = new URL(req.url); + const limit = parseInt(url.searchParams.get('limit') ?? '50'); + const offset = parseInt(url.searchParams.get('offset') ?? '0'); + + const items = await list${P}(user.id, limit, offset); + return NextResponse.json({ items }); +}`)}; +`; + } + + if (hasPost) { + out += ` +export const POST = ${wrap(`async (req: NextRequest) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json(); + const input = Create${P}Schema.parse(body); + const item = await create${P}(user.id, input); + return NextResponse.json(item, { status: 201 }); +}`)}; +`; + } + + return out; +} + +function genDirectDetailRoute( + name: string, + _fields: ParsedField[], + methods: string[], + withHandler: boolean +): string { + const P = pascal(name); + const hasGet = methods.includes('GET'); + const hasPatch = methods.includes('PATCH'); + const hasDelete = methods.includes('DELETE'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + const imports: string[] = []; + if (hasGet) imports.push(`get${P}`); + if (hasPatch) imports.push(`update${P}`); + if (hasDelete) imports.push(`delete${P}`); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth-server'; +${handlerImport}`; + + if (imports.length > 0) { + out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; + } + + if (hasPatch) { + out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`; + } + + out += ` +type RouteContext = { params: Promise<{ id: string }> }; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const item = await get${P}(id, user.id); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json(item); +}`)}; +`; + } + + if (hasPatch) { + out += ` +export const PATCH = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + const updates = Update${P}Schema.parse(body); + const item = await update${P}(id, user.id, updates); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json(item); +}`)}; +`; + } + + if (hasDelete) { + out += ` +export const DELETE = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const deleted = await delete${P}(id, user.id); + if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json({ success: true }); +}`)}; +`; + } + + return out; +} + +// ── Test Template ──────────────────────────────────────────────────────────── + +function genSchemaTest(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const requiredFields = fields.filter(f => !f.optional); + + function sampleValue(f: ParsedField): string { + if (f.enumValues) return `'${f.enumValues[0]}'`; + if (f.tsType === 'string') return `'test'`; + if (f.tsType === 'number') return '42'; + if (f.tsType === 'boolean') return 'true'; + return `'2026-01-01T00:00:00.000Z'`; + } + + const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n'); + + return `import { describe, it, expect } from 'vitest'; +import { Create${P}Schema, Update${P}Schema } from './schemas/${name}'; + +describe('Create${P}Schema', () => { + it('accepts valid input', () => { + const result = Create${P}Schema.parse({ +${validPayload} + }); + expect(result).toBeDefined(); + }); + + it('rejects empty object', () => { + expect(() => Create${P}Schema.parse({})).toThrow(); + }); +}); + +describe('Update${P}Schema', () => { + it('accepts empty object (all optional)', () => { + const result = Update${P}Schema.parse({}); + expect(result).toEqual({}); + }); + + it('accepts partial update', () => { + const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} }); + expect(result).toBeDefined(); + }); +}); +`; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs(); + + console.log(`\n🚀 Generating API routes: ${name}`); + console.log(` Mode: ${mode}`); + console.log(` Methods: ${methods.join(', ')}`); + console.log(` Target: ${target}`); + if (fields) console.log(` Fields: ${fields}`); + if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); + + const parsedFields = parseFields(fields); + + // Determine which files to generate + const files: { path: string; content: string }[] = []; + + if (mode === 'proxy') { + files.push({ + path: `app/api/${name}/route.ts`, + content: genProxyListRoute(name, methods, withHandler), + }); + if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { + files.push({ + path: `app/api/${name}/[id]/route.ts`, + content: genProxyDetailRoute(name, methods, withHandler), + }); + } + } else { + // Direct mode — also generate schema + repository + if (parsedFields.length === 0) { + console.error('Error: --fields is required in direct mode'); + process.exit(1); + } + + files.push({ + path: `lib/schemas/${name}.ts`, + content: genSchemaFile(name, parsedFields), + }); + files.push({ + path: `lib/repositories/${name}.ts`, + content: genRepositoryFile(name, parsedFields), + }); + files.push({ + path: `app/api/${name}/route.ts`, + content: genDirectListRoute(name, parsedFields, methods, withHandler), + }); + if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { + files.push({ + path: `app/api/${name}/[id]/route.ts`, + content: genDirectDetailRoute(name, parsedFields, methods, withHandler), + }); + } + files.push({ + path: `lib/__tests__/${name}.test.ts`, + content: genSchemaTest(name, parsedFields), + }); + } + + if (dryRun) { + console.log('📄 Generated files:\n'); + for (const file of files) { + console.log(`── ${file.path} ──────────────────────────────────────`); + console.log(file.content); + } + console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); + return; + } + + // Write files + for (const file of files) { + const fullPath = path.join(target, file.path); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + + // Check if file already exists + try { + await fs.access(fullPath); + console.log(` ⚠️ SKIP ${file.path} (already exists)`); + continue; + } catch { + // Good — doesn't exist + } + + await fs.writeFile(fullPath, file.content, 'utf-8'); + console.log(` ✅ ${file.path}`); + } + + console.log(`\n✨ API routes generated for "${name}".`); + if (mode === 'direct') { + console.log(`\nPrerequisites (if not already present):`); + console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`); + console.log(` - lib/api-handler.ts — withErrorHandler HOF`); + console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`); + console.log(` - zod installed — npm install zod`); + } else { + console.log(`\nPrerequisites (if not already present):`); + console.log(` - lib/api-helpers.ts — getAccessToken(req) function`); + console.log(` - lib/api-handler.ts — withErrorHandler HOF`); + console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`); + } +} + +main().catch(err => { + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts new file mode 100644 index 00000000..48fc97dc --- /dev/null +++ b/packages/create-app/src/index.ts @@ -0,0 +1,9 @@ +/** + * @bytelyst/create-app — CLI tools for scaffolding ByteLyst product repos. + * + * Generators: + * - api-routes.ts — Next.js App Router API route generator + * - agents-md.ts — AGENTS.md auto-generator from product.json + repo scan + */ + +export {}; diff --git a/packages/create-app/tsconfig.json b/packages/create-app/tsconfig.json new file mode 100644 index 00000000..81f2cd1e --- /dev/null +++ b/packages/create-app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.ts"] +}