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:
parent
c3f81cc97a
commit
f051942ef6
22
packages/create-app/package.json
Normal file
22
packages/create-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
603
packages/create-app/src/generators/agents-md.ts
Normal file
603
packages/create-app/src/generators/agents-md.ts
Normal 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);
|
||||
});
|
||||
768
packages/create-app/src/generators/api-routes.ts
Normal file
768
packages/create-app/src/generators/api-routes.ts
Normal 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);
|
||||
});
|
||||
9
packages/create-app/src/index.ts
Normal file
9
packages/create-app/src/index.ts
Normal 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 {};
|
||||
9
packages/create-app/tsconfig.json
Normal file
9
packages/create-app/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "src/**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user