feat(create-app): add CLI Scaffolder (3.1) — interactive product repo generator

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
This commit is contained in:
saravanakumardb1 2026-03-19 20:31:35 -07:00
parent 43439e9c85
commit 6354711f97
7 changed files with 1028 additions and 0 deletions

View File

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

View File

@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { generateFiles, type ProductManifest } from '../scaffolder.js';
function makeManifest(overrides: Partial<ProductManifest> = {}): 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');
});
});

View File

@ -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');
});
});

View File

@ -0,0 +1,34 @@
/**
* Simple template engine for scaffolding.
* Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks.
*/
export type TemplateVars = Record<string, string | boolean | number>;
/**
* 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;
}

View File

@ -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<JwtPayload | null> {
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<JwtPayload | null> {
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<string, Map<string, unknown>>();
export function getCollection<T = unknown>(name: string): Map<string, T> {
if (!collections.has(name)) {
collections.set(name, new Map());
}
return collections.get(name) as Map<string, T>;
}
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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
`;
export const WEB_PAGE = `export default function Home() {
return (
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto', fontFamily: 'system-ui' }}>
<h1>{{DISPLAY_NAME}}</h1>
<p>{{TAGLINE}}</p>
<p>Edit <code>src/app/page.tsx</code> to get started.</p>
</main>
);
}
`;
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 (
<View style={styles.container}>
<Text style={styles.title}>{{DISPLAY_NAME}}</Text>
<Text style={styles.subtitle}>{{TAGLINE}}</Text>
</View>
);
}
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' },
});
`;

View File

@ -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: ./<productId>)
--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:
<productId>/
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<string> {
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<ProductManifest> {
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<ProductManifest> {
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<void> {
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);
});

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
passWithNoTests: true,
},
});