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
316 lines
12 KiB
JavaScript
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);
|
|
});
|