From 6354711f9742d3ad7c391662862bc7fb75c27264 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 20:31:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(create-app):=20add=20CLI=20Scaffolder=20(3?= =?UTF-8?q?.1)=20=E2=80=94=20interactive=20product=20repo=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolder (scaffolder.ts): - Interactive prompts: product name/ID/tagline/domain, port, platforms, features - --from product.json flag to skip prompts (non-interactive) - --dry-run preview mode - Generates backend (always) + web (Next.js) + mobile (Expo) based on selection - Template engine with {{VARIABLE}} and {{#IF FEATURE}} conditional blocks - Backend scaffold: Fastify 5, Zod config, JWT auth, datastore, server.ts - Web scaffold: Next.js 16 App Router, layout, page, product-config - Mobile scaffold: Expo with app.json, index screen - Root files: product.json, .gitignore, .env.example, README.md Tests: 26 passing (11 template-engine + 15 scaffolder) Tested with ActionTrail product.json dry-run — correct output --- packages/create-app/package.json | 2 + .../src/__tests__/scaffolder.test.ts | 136 ++++++ .../src/__tests__/template-engine.test.ts | 77 +++ .../create-app/src/lib/template-engine.ts | 34 ++ packages/create-app/src/lib/templates.ts | 457 ++++++++++++++++++ packages/create-app/src/scaffolder.ts | 315 ++++++++++++ packages/create-app/vitest.config.ts | 7 + 7 files changed, 1028 insertions(+) create mode 100644 packages/create-app/src/__tests__/scaffolder.test.ts create mode 100644 packages/create-app/src/__tests__/template-engine.test.ts create mode 100644 packages/create-app/src/lib/template-engine.ts create mode 100644 packages/create-app/src/lib/templates.ts create mode 100644 packages/create-app/src/scaffolder.ts create mode 100644 packages/create-app/vitest.config.ts diff --git a/packages/create-app/package.json b/packages/create-app/package.json index aaa1efbd..ecdedd92 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -5,12 +5,14 @@ "description": "CLI tools for scaffolding ByteLyst product repos and code", "type": "module", "bin": { + "create-app": "./dist/scaffolder.js", "gen-api-route": "./dist/generators/api-routes.js", "gen-agents-md": "./dist/generators/agents-md.js" }, "scripts": { "build": "tsc", "test": "vitest run", + "create-app": "tsx src/scaffolder.ts", "gen:api-route": "tsx src/generators/api-routes.ts", "gen:agents-md": "tsx src/generators/agents-md.ts" }, diff --git a/packages/create-app/src/__tests__/scaffolder.test.ts b/packages/create-app/src/__tests__/scaffolder.test.ts new file mode 100644 index 00000000..7d5eac76 --- /dev/null +++ b/packages/create-app/src/__tests__/scaffolder.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { generateFiles, type ProductManifest } from '../scaffolder.js'; + +function makeManifest(overrides: Partial = {}): ProductManifest { + return { + productId: 'testapp', + displayName: 'TestApp', + tagline: 'A test application', + domain: 'testapp.dev', + backendPort: 4050, + primarySurface: 'web', + platforms: ['web'], + features: ['auth', 'telemetry'], + ...overrides, + }; +} + +describe('generateFiles', () => { + it('generates root files', () => { + const files = generateFiles(makeManifest()); + const paths = files.map(f => f.path); + + expect(paths).toContain('shared/product.json'); + expect(paths).toContain('.gitignore'); + expect(paths).toContain('.env.example'); + expect(paths).toContain('README.md'); + }); + + it('always generates backend files', () => { + const files = generateFiles(makeManifest()); + const paths = files.map(f => f.path); + + expect(paths).toContain('backend/package.json'); + expect(paths).toContain('backend/src/server.ts'); + expect(paths).toContain('backend/src/lib/config.ts'); + expect(paths).toContain('backend/src/lib/auth.ts'); + expect(paths).toContain('backend/src/lib/datastore.ts'); + }); + + it('generates web files when platform includes web', () => { + const files = generateFiles(makeManifest({ platforms: ['web'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('web/package.json'); + expect(paths).toContain('web/next.config.ts'); + expect(paths).toContain('web/src/app/layout.tsx'); + expect(paths).toContain('web/src/app/page.tsx'); + expect(paths).toContain('web/src/lib/product-config.ts'); + }); + + it('does not generate web files when platform excludes web', () => { + const files = generateFiles(makeManifest({ platforms: ['mobile'] })); + const paths = files.map(f => f.path); + + expect(paths).not.toContain('web/package.json'); + expect(paths).not.toContain('web/src/app/page.tsx'); + }); + + it('generates mobile files when platform includes mobile', () => { + const files = generateFiles(makeManifest({ platforms: ['mobile'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('mobile/package.json'); + expect(paths).toContain('mobile/app.json'); + expect(paths).toContain('mobile/src/app/index.tsx'); + }); + + it('does not generate mobile files when platform excludes mobile', () => { + const files = generateFiles(makeManifest({ platforms: ['web'] })); + const paths = files.map(f => f.path); + + expect(paths).not.toContain('mobile/package.json'); + }); + + it('replaces product ID in generated content', () => { + const files = generateFiles(makeManifest({ productId: 'myproduct' })); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('"productId": "myproduct"'); + }); + + it('replaces display name in generated content', () => { + const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' })); + const readme = files.find(f => f.path === 'README.md')!; + expect(readme.content).toContain('# AwesomeApp'); + }); + + it('replaces backend port in config', () => { + const files = generateFiles(makeManifest({ backendPort: 4099 })); + const config = files.find(f => f.path === 'backend/src/lib/config.ts')!; + expect(config.content).toContain('4099'); + }); + + it('includes ios bundle ID when ios platform selected', () => { + const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' })); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('com.bytelyst.myapp'); + }); + + it('includes android bundle ID when android platform selected', () => { + const files = generateFiles( + makeManifest({ platforms: ['web', 'android'], productId: 'myapp' }) + ); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('com.myapp.app'); + }); + + it('includes backend env vars in .env.example', () => { + const files = generateFiles(makeManifest({ backendPort: 4050 })); + const env = files.find(f => f.path === '.env.example')!; + expect(env.content).toContain('PORT=4050'); + expect(env.content).toContain('JWT_SECRET'); + }); + + it('generates correct web product-config', () => { + const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 })); + const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!; + expect(webConfig.content).toContain("productId: 'testprod'"); + expect(webConfig.content).toContain('4077'); + }); + + it('generates all platforms when all selected', () => { + const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('web/package.json'); + expect(paths).toContain('mobile/package.json'); + // Backend is always included + expect(paths).toContain('backend/package.json'); + }); + + it('server.ts includes display name in log message', () => { + const files = generateFiles(makeManifest({ displayName: 'CoolProduct' })); + const server = files.find(f => f.path === 'backend/src/server.ts')!; + expect(server.content).toContain('CoolProduct backend listening'); + }); +}); diff --git a/packages/create-app/src/__tests__/template-engine.test.ts b/packages/create-app/src/__tests__/template-engine.test.ts new file mode 100644 index 00000000..2321866f --- /dev/null +++ b/packages/create-app/src/__tests__/template-engine.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { renderTemplate } from '../lib/template-engine.js'; + +describe('renderTemplate', () => { + it('replaces simple variables', () => { + const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('replaces multiple variables', () => { + const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' }); + expect(result).toBe('foo and bar'); + }); + + it('replaces numeric variables', () => { + const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 }); + expect(result).toBe('Port: 4017'); + }); + + it('leaves unknown variables intact', () => { + const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' }); + expect(result).toBe('yes {{UNKNOWN}}'); + }); + + it('includes IF block when truthy', () => { + const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true }); + expect(result).toBe('web here'); + }); + + it('excludes IF block when falsy', () => { + const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', { + HAS_WEB: false, + }); + expect(result).toBe('beforeafter'); + }); + + it('includes UNLESS block when falsy', () => { + const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { + HAS_WEB: false, + }); + expect(result).toBe('no web'); + }); + + it('excludes UNLESS block when truthy', () => { + const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { + HAS_WEB: true, + }); + expect(result).toBe(''); + }); + + it('handles nested variables inside IF blocks', () => { + const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', { + HAS_WEB: true, + PORT: 3000, + }); + expect(result).toBe('Port: 3000'); + }); + + it('handles multiline IF blocks', () => { + const tmpl = `start +{{#IF HAS_BACKEND}}backend line 1 +backend line 2 +{{/IF HAS_BACKEND}}end`; + const result = renderTemplate(tmpl, { HAS_BACKEND: true }); + expect(result).toContain('backend line 1'); + expect(result).toContain('backend line 2'); + expect(result).toContain('start'); + expect(result).toContain('end'); + }); + + it('handles multiple IF blocks', () => { + const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}'; + expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|'); + expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb'); + expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb'); + }); +}); diff --git a/packages/create-app/src/lib/template-engine.ts b/packages/create-app/src/lib/template-engine.ts new file mode 100644 index 00000000..629f5e3b --- /dev/null +++ b/packages/create-app/src/lib/template-engine.ts @@ -0,0 +1,34 @@ +/** + * Simple template engine for scaffolding. + * Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks. + */ + +export type TemplateVars = Record; + +/** + * Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks. + */ +export function renderTemplate(template: string, vars: TemplateVars): string { + let result = template; + + // Process conditional blocks: {{#IF KEY}}...{{/IF KEY}} + const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g; + result = result.replace(ifRegex, (_, key: string, content: string) => { + return vars[key] ? content : ''; + }); + + // Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}} + const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g; + result = result.replace(unlessRegex, (_, key: string, content: string) => { + return !vars[key] ? content : ''; + }); + + // Replace {{VARIABLE}} placeholders + result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { + const val = vars[key]; + if (val === undefined) return match; + return String(val); + }); + + return result; +} diff --git a/packages/create-app/src/lib/templates.ts b/packages/create-app/src/lib/templates.ts new file mode 100644 index 00000000..fd447288 --- /dev/null +++ b/packages/create-app/src/lib/templates.ts @@ -0,0 +1,457 @@ +/** + * Inline templates for product repo scaffolding. + * Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax. + */ + +// ── product.json ───────────────────────────────────────────────────────────── + +export const PRODUCT_JSON = `{ + "productId": "{{PRODUCT_ID}}", + "displayName": "{{DISPLAY_NAME}}", + "tagline": "{{TAGLINE}}", + "domain": "{{DOMAIN}}", + "backendPort": {{BACKEND_PORT}}, + "primarySurface": "{{PRIMARY_SURFACE}}", + "bundleIds": { + "web": "{{DOMAIN}}"{{#IF HAS_IOS}}, + "ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}}, + "android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}} + }, + "appStore": { + "category": "Productivity", + "privacyUrl": "https://{{DOMAIN}}/privacy", + "termsUrl": "https://{{DOMAIN}}/terms", + "supportUrl": "https://{{DOMAIN}}/support" + }, + "version": "0.1.0" +} +`; + +// ── .gitignore ─────────────────────────────────────────────────────────────── + +export const GITIGNORE = `node_modules/ +dist/ +.next/ +.env +.env.local +*.log +.DS_Store +coverage/ +`; + +// ── .env.example ───────────────────────────────────────────────────────────── + +export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables +NODE_ENV=development +{{#IF HAS_BACKEND}} +# Backend +PORT={{BACKEND_PORT}} +HOST=0.0.0.0 +JWT_SECRET=dev-secret-change-me +DB_PROVIDER=memory +COSMOS_ENDPOINT= +COSMOS_KEY= +COSMOS_DATABASE=bytelyst +PLATFORM_SERVICE_URL=http://localhost:4003 +{{/IF HAS_BACKEND}} +{{#IF HAS_WEB}} +# Web +NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}} +{{/IF HAS_WEB}} +`; + +// ── README.md ──────────────────────────────────────────────────────────────── + +export const README = `# {{DISPLAY_NAME}} + +> {{TAGLINE}} + +## Quick Start + +\`\`\`bash +{{#IF HAS_BACKEND}}# Backend +cd backend && npm install && npm run dev +{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web +cd web && npm install && npm run dev +{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile +cd mobile && npm install && npm start +{{/IF HAS_MOBILE}}\`\`\` + +## Architecture + +| Layer | Technology | +|-------|-----------| +{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) | +{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 | +{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) | +{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) | +| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) | + +## Product Identity + +- **Product ID:** \`{{PRODUCT_ID}}\` +- **Domain:** {{DOMAIN}} +- **Backend Port:** {{BACKEND_PORT}} + +See [AGENTS.md](AGENTS.md) for AI agent instructions. +`; + +// ── Backend templates ──────────────────────────────────────────────────────── + +export const BACKEND_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", + "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", + "@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore", + "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", + "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", + "fastify": "^5.3.3", + "jose": "^6.0.11", + "zod": "^3.24.4" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} +`; + +export const BACKEND_TSCONFIG = `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.ts"] +} +`; + +export const BACKEND_CONFIG = `import { z } from 'zod'; +import { PRODUCT_ID } from './product-config.js'; + +const envSchema = z.object({ + PORT: z.coerce.number().default({{BACKEND_PORT}}), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'), + COSMOS_ENDPOINT: z.string().optional(), + COSMOS_KEY: z.string().optional(), + COSMOS_DATABASE: z.string().default('bytelyst'), + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), + PRODUCT_ID: z.string().default(PRODUCT_ID), + PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), +}); + +export const config = envSchema.parse(process.env); +`; + +export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8'); +const manifest = JSON.parse(raw); +export const PRODUCT_ID: string = manifest.productId; +`; + +export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose'; +import type { FastifyRequest } from 'fastify'; +import { config } from './config.js'; + +const secret = new TextEncoder().encode(config.JWT_SECRET); + +export interface JwtPayload { + sub: string; + email?: string; + role?: string; +} + +export async function verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, secret); + return payload as unknown as JwtPayload; + } catch { + return null; + } +} + +export function extractToken(req: FastifyRequest): string | null { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return null; + return auth.slice(7); +} +`; + +export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify'; +import { verifyToken, extractToken, type JwtPayload } from './auth.js'; +import { config } from './config.js'; + +export async function getUserPayload(req: FastifyRequest): Promise { + const token = extractToken(req); + if (!token) return null; + return verifyToken(token); +} + +export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string { + if (!req.user?.sub) throw new Error('Unauthenticated'); + return req.user.sub; +} + +export function getRequestProductId(_req: FastifyRequest): string { + return config.PRODUCT_ID; +} +`; + +export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors'; +`; + +export const BACKEND_DATASTORE = `import { config } from './config.js'; + +const collections = new Map>(); + +export function getCollection(name: string): Map { + if (!collections.has(name)) { + collections.set(name, new Map()); + } + return collections.get(name) as Map; +} + +export const PRODUCT_ID = config.PRODUCT_ID; +export const DB_PROVIDER = config.DB_PROVIDER; +`; + +export const BACKEND_SERVER = `import Fastify from 'fastify'; +import { config } from './lib/config.js'; + +const app = Fastify({ + logger: { + level: config.NODE_ENV === 'test' ? 'silent' : 'info', + }, +}); + +// CORS +if (config.CORS_ORIGIN) { + app.addHook('onRequest', async (request, reply) => { + reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN); + reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (request.method === 'OPTIONS') { + reply.status(204).send(); + } + }); +} + +// Health check +app.get('/health', async () => ({ + status: 'ok', + service: config.SERVICE_NAME, + productId: config.PRODUCT_ID, +})); + +// TODO: Register your route modules here +// import { routes as exampleRoutes } from './modules/example/routes.js'; +// app.register(exampleRoutes, { prefix: '/api' }); + +async function start() { + try { + await app.listen({ port: config.PORT, host: config.HOST }); + app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +start(); + +export { app }; +`; + +// ── Web templates ──────────────────────────────────────────────────────────── + +export const WEB_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build --webpack", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.16.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.7.3" + } +} +`; + +export const WEB_TSCONFIG = `{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +`; + +export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; +`; + +export const WEB_LAYOUT = `import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '{{DISPLAY_NAME}}', + description: '{{TAGLINE}}', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`; + +export const WEB_PAGE = `export default function Home() { + return ( +
+

{{DISPLAY_NAME}}

+

{{TAGLINE}}

+

Edit src/app/page.tsx to get started.

+
+ ); +} +`; + +export const WEB_PRODUCT_CONFIG = `const manifest = { + productId: '{{PRODUCT_ID}}', + displayName: '{{DISPLAY_NAME}}', + domain: '{{DOMAIN}}', + backendPort: {{BACKEND_PORT}}, +}; + +export const PRODUCT_ID = manifest.productId; + +export function getBackendURL(): string { + return process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:{{BACKEND_PORT}}'; +} + +export default manifest; +`; + +// ── Mobile (Expo) templates ────────────────────────────────────────────────── + +export const MOBILE_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "expo": "~55.0.0", + "expo-router": "~5.0.0", + "react": "^19.0.0", + "react-native": "^0.79.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "typescript": "^5.7.3" + } +} +`; + +export const MOBILE_APP_JSON = `{ + "expo": { + "name": "{{DISPLAY_NAME}}", + "slug": "{{PRODUCT_ID}}", + "version": "1.0.0", + "scheme": "{{PRODUCT_ID}}", + "platforms": ["ios", "android"], + "ios": { + "bundleIdentifier": "com.bytelyst.{{PRODUCT_ID}}" + }, + "android": { + "package": "com.{{PRODUCT_ID}}.app" + } + } +} +`; + +export const MOBILE_INDEX = `import { View, Text, StyleSheet } from 'react-native'; + +export default function Home() { + return ( + + {{DISPLAY_NAME}} + {{TAGLINE}} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, + title: { fontSize: 28, fontWeight: '700', marginBottom: 8 }, + subtitle: { fontSize: 16, color: '#666' }, +}); +`; diff --git a/packages/create-app/src/scaffolder.ts b/packages/create-app/src/scaffolder.ts new file mode 100644 index 00000000..e7335062 --- /dev/null +++ b/packages/create-app/src/scaffolder.ts @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * CLI Scaffolder — generates a fully wired ByteLyst product repo. + * + * Usage: + * npx tsx scaffolder.ts # Interactive prompts + * npx tsx scaffolder.ts --from product.json # From existing manifest + * npx tsx scaffolder.ts --from product.json --dry-run # Preview + * + * @module @bytelyst/create-app/scaffolder + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { renderTemplate, type TemplateVars } from './lib/template-engine.js'; +import * as T from './lib/templates.js'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ProductManifest { + productId: string; + displayName: string; + tagline: string; + domain: string; + backendPort: number; + primarySurface: string; + platforms: ('web' | 'mobile' | 'ios' | 'android')[]; + features: string[]; +} + +interface CliOptions { + from: string | null; + outDir: string | null; + dryRun: boolean; +} + +// ── CLI Arg Parsing ────────────────────────────────────────────────────────── + +function parseCliArgs(): CliOptions { + const args = process.argv.slice(2); + const options: CliOptions = { from: null, outDir: null, dryRun: false }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--from' || arg === '-f') options.from = args[++i]; + else if (arg === '--out' || arg === '-o') options.outDir = args[++i]; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +function showHelp(): void { + // eslint-disable-next-line no-console + console.log(` +@bytelyst/create-app — Product Repo Scaffolder + +Generates a fully wired ByteLyst product repo with backend, web, and/or mobile. + +Usage: + npx tsx scaffolder.ts # Interactive prompts + npx tsx scaffolder.ts --from product.json # From existing manifest + npx tsx scaffolder.ts --from product.json -d # Dry run + +Options: + --from, -f Path to existing product.json (skip prompts) + --out, -o Output directory (default: ./) + --dry-run, -d Preview files without writing + --help, -h Show this help + +Interactive Prompts: + 1. Product name + ID + tagline + domain + 2. Backend port + 3. Platform selection: web, mobile (Expo), iOS, Android + 4. Feature selection: auth, billing, telemetry, flags, sync, push + +Output: + / + ├── shared/product.json + ├── backend/ (if selected) + ├── web/ (if selected) + ├── mobile/ (if selected) + ├── .gitignore + ├── .env.example + ├── README.md + └── AGENTS.md +`); +} + +// ── Interactive Prompts ────────────────────────────────────────────────────── + +function createPrompt(): (question: string) => Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return (question: string) => + new Promise(resolve => { + rl.question(question, answer => { + resolve(answer.trim()); + }); + }); +} + +async function gatherManifestInteractively(): Promise { + const ask = createPrompt(); + + // eslint-disable-next-line no-console + console.log('\n📦 ByteLyst Product Scaffolder\n'); + + const displayName = (await ask('Product name (e.g., FlowMonk): ')) || 'MyApp'; + const defaultId = displayName.toLowerCase().replace(/[^a-z0-9]/g, ''); + const productId = (await ask(`Product ID [${defaultId}]: `)) || defaultId; + const tagline = (await ask('Tagline: ')) || `${displayName} — a ByteLyst product`; + const defaultDomain = `${productId}.app`; + const domain = (await ask(`Domain [${defaultDomain}]: `)) || defaultDomain; + const backendPort = parseInt(await ask('Backend port [4020]: ')) || 4020; + + // eslint-disable-next-line no-console + console.log('\nPlatforms (comma-separated: web, mobile, ios, android)'); + const platformStr = (await ask('Platforms [web]: ')) || 'web'; + const platforms = platformStr + .split(',') + .map(p => p.trim().toLowerCase()) as ProductManifest['platforms']; + + const primarySurface = platforms.includes('web') ? 'web' : platforms[0]; + + // eslint-disable-next-line no-console + console.log('\nFeatures (comma-separated: auth, billing, telemetry, flags, sync, push)'); + const featureStr = (await ask('Features [auth,telemetry,flags]: ')) || 'auth,telemetry,flags'; + const features = featureStr.split(',').map(f => f.trim().toLowerCase()); + + // Close readline + process.stdin.unref(); + + return { + productId, + displayName, + tagline, + domain, + backendPort, + primarySurface, + platforms, + features, + }; +} + +async function loadManifestFromFile(filePath: string): Promise { + const raw = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(raw); + + return { + productId: data.productId || 'myapp', + displayName: data.displayName || data.productId || 'MyApp', + tagline: data.tagline || `${data.displayName} — a ByteLyst product`, + domain: data.domain || `${data.productId}.app`, + backendPort: data.backendPort || 4020, + primarySurface: data.primarySurface || 'web', + platforms: data.platforms || ['web'], + features: data.features || ['auth', 'telemetry', 'flags'], + }; +} + +// ── File Generator ─────────────────────────────────────────────────────────── + +interface GeneratedFile { + path: string; + content: string; +} + +function generateFiles(manifest: ProductManifest): GeneratedFile[] { + const vars: TemplateVars = { + PRODUCT_ID: manifest.productId, + DISPLAY_NAME: manifest.displayName, + TAGLINE: manifest.tagline, + DOMAIN: manifest.domain, + BACKEND_PORT: manifest.backendPort, + PRIMARY_SURFACE: manifest.primarySurface, + HAS_BACKEND: true, // Always generate backend + HAS_WEB: manifest.platforms.includes('web'), + HAS_MOBILE: manifest.platforms.includes('mobile'), + HAS_IOS: manifest.platforms.includes('ios'), + HAS_ANDROID: manifest.platforms.includes('android'), + HAS_AUTH: manifest.features.includes('auth'), + HAS_BILLING: manifest.features.includes('billing'), + HAS_TELEMETRY: manifest.features.includes('telemetry'), + HAS_FLAGS: manifest.features.includes('flags'), + HAS_SYNC: manifest.features.includes('sync'), + HAS_PUSH: manifest.features.includes('push'), + }; + + const render = (tmpl: string) => renderTemplate(tmpl, vars); + const files: GeneratedFile[] = []; + + // ── Root files + files.push({ path: 'shared/product.json', content: render(T.PRODUCT_JSON) }); + files.push({ path: '.gitignore', content: render(T.GITIGNORE) }); + files.push({ path: '.env.example', content: render(T.ENV_EXAMPLE) }); + files.push({ path: 'README.md', content: render(T.README) }); + + // ── Backend (always generated) + files.push({ path: 'backend/package.json', content: render(T.BACKEND_PACKAGE_JSON) }); + files.push({ path: 'backend/tsconfig.json', content: render(T.BACKEND_TSCONFIG) }); + files.push({ path: 'backend/src/lib/config.ts', content: render(T.BACKEND_CONFIG) }); + files.push({ + path: 'backend/src/lib/product-config.ts', + content: render(T.BACKEND_PRODUCT_CONFIG), + }); + files.push({ path: 'backend/src/lib/auth.ts', content: render(T.BACKEND_AUTH) }); + files.push({ + path: 'backend/src/lib/request-context.ts', + content: render(T.BACKEND_REQUEST_CONTEXT), + }); + files.push({ path: 'backend/src/lib/errors.ts', content: render(T.BACKEND_ERRORS) }); + files.push({ path: 'backend/src/lib/datastore.ts', content: render(T.BACKEND_DATASTORE) }); + files.push({ path: 'backend/src/server.ts', content: render(T.BACKEND_SERVER) }); + + // ── Web (if selected) + if (manifest.platforms.includes('web')) { + files.push({ path: 'web/package.json', content: render(T.WEB_PACKAGE_JSON) }); + files.push({ path: 'web/tsconfig.json', content: render(T.WEB_TSCONFIG) }); + files.push({ path: 'web/next.config.ts', content: render(T.WEB_NEXT_CONFIG) }); + files.push({ path: 'web/src/app/layout.tsx', content: render(T.WEB_LAYOUT) }); + files.push({ path: 'web/src/app/page.tsx', content: render(T.WEB_PAGE) }); + files.push({ path: 'web/src/lib/product-config.ts', content: render(T.WEB_PRODUCT_CONFIG) }); + } + + // ── Mobile (if selected) + if (manifest.platforms.includes('mobile')) { + files.push({ path: 'mobile/package.json', content: render(T.MOBILE_PACKAGE_JSON) }); + files.push({ path: 'mobile/app.json', content: render(T.MOBILE_APP_JSON) }); + files.push({ path: 'mobile/src/app/index.tsx', content: render(T.MOBILE_INDEX) }); + } + + return files; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const cliOpts = parseCliArgs(); + + const manifest = cliOpts.from + ? await loadManifestFromFile(cliOpts.from) + : await gatherManifestInteractively(); + + const outDir = cliOpts.outDir || manifest.productId; + const outPath = path.resolve(outDir); + + // eslint-disable-next-line no-console + console.log(`\n🚀 Scaffolding ${manifest.displayName}`); + // eslint-disable-next-line no-console + console.log(` Product ID: ${manifest.productId}`); + // eslint-disable-next-line no-console + console.log(` Output: ${outPath}`); + // eslint-disable-next-line no-console + console.log(` Platforms: ${manifest.platforms.join(', ')}`); + // eslint-disable-next-line no-console + console.log(` Features: ${manifest.features.join(', ')}`); + if (cliOpts.dryRun) { + // eslint-disable-next-line no-console + console.log(' ⚠️ DRY RUN\n'); + } + + const files = generateFiles(manifest); + + if (cliOpts.dryRun) { + // eslint-disable-next-line no-console + console.log(`📄 ${files.length} files would be generated:\n`); + for (const file of files) { + // eslint-disable-next-line no-console + console.log(`── ${file.path} ──────────────────────────────────────`); + // eslint-disable-next-line no-console + console.log(file.content); + } + // eslint-disable-next-line no-console + 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(outPath, file.path); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, file.content, 'utf-8'); + // eslint-disable-next-line no-console + console.log(` ✅ ${file.path}`); + } + + // eslint-disable-next-line no-console + console.log(`\n✨ ${manifest.displayName} scaffolded at ${outPath}`); + // eslint-disable-next-line no-console + console.log(`\nNext steps:`); + // eslint-disable-next-line no-console + console.log(` cd ${outDir}/backend && npm install && npm run dev`); + if (manifest.platforms.includes('web')) { + // eslint-disable-next-line no-console + console.log(` cd ${outDir}/web && npm install && npm run dev`); + } +} + +// Export for testing +export { generateFiles, renderTemplate, type ProductManifest, type GeneratedFile }; + +main().catch(err => { + // eslint-disable-next-line no-console + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/packages/create-app/vitest.config.ts b/packages/create-app/vitest.config.ts new file mode 100644 index 00000000..b33bef1c --- /dev/null +++ b/packages/create-app/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + }, +});