#!/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); });