learning_ai_common_plat/packages/create-app/src/scaffolder.ts
saravanakumardb1 6354711f97 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
2026-03-19 20:31:35 -07:00

316 lines
12 KiB
JavaScript

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