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
This commit is contained in:
saravanakumardb1 2026-03-19 20:17:02 -07:00
parent c3f81cc97a
commit f051942ef6
5 changed files with 1411 additions and 0 deletions

View File

@ -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"
}
}

View File

@ -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 <!-- CUSTOM --> 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 <path> [--dry-run] [--update]
Options:
--repo, -r Path to product repo root (default: ".")
--dry-run, -d Preview without writing files
--update, -u Preserve <!-- CUSTOM --> sections in existing AGENTS.md
--help, -h Show this help
Custom Sections:
Wrap any hand-written content in <!-- CUSTOM:key --> / <!-- /CUSTOM:key -->
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<string, string>;
bundleId?: string;
version?: string;
description?: string;
licensePrefix?: string;
configDirName?: string;
envVarPrefix?: string;
}
async function loadProductJson(repoPath: string): Promise<ProductManifest> {
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<boolean> {
try {
const st = await fs.stat(p);
return st.isDirectory();
} catch {
return false;
}
}
async function fileExists(p: string): Promise<boolean> {
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<string[]> {
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<string[]> {
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<RepoInfo> {
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('<!-- CUSTOM:extra -->');
lines.push('<!-- /CUSTOM:extra -->');
lines.push('');
return lines.join('\n');
}
// ── Custom Section Preservation ──────────────────────────────────────────────
function extractCustomSections(content: string): Map<string, string> {
const sections = new Map<string, string>();
const regex = /<!-- CUSTOM:(\w+) -->\n([\s\S]*?)<!-- \/CUSTOM:\1 -->/g;
let match;
while ((match = regex.exec(content)) !== null) {
sections.set(match[1], match[2]);
}
return sections;
}
function mergeCustomSections(newContent: string, existing: Map<string, string>): string {
let result = newContent;
for (const [key, value] of existing) {
const placeholder = `<!-- CUSTOM:${key} -->\n<!-- /CUSTOM:${key} -->`;
const replacement = `<!-- CUSTOM:${key} -->\n${value}<!-- /CUSTOM:${key} -->`;
result = result.replace(placeholder, replacement);
}
return result;
}
// ── Symlink Manager ──────────────────────────────────────────────────────────
async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise<void> {
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<void> {
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);
});

View File

@ -0,0 +1,768 @@
#!/usr/bin/env node
/**
* API Route Generator Next.js App Router
*
* Generates two files:
* src/app/api/<name>/route.ts GET (list) + POST (create)
* src/app/api/<name>/[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 <name> [--fields "<field:type,...>"] [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/<name>.ts and lib/schemas/<name>.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: string; ts: string }> = {
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<typeof Create${P}Schema>;
export type Update${P}Input = z.infer<typeof Update${P}Schema>;
`;
}
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<boolean> {
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<void> {
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);
});

View File

@ -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 {};

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["dist", "src/**/*.test.ts"]
}