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:
parent
43439e9c85
commit
6354711f97
@ -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"
|
||||
},
|
||||
|
||||
136
packages/create-app/src/__tests__/scaffolder.test.ts
Normal file
136
packages/create-app/src/__tests__/scaffolder.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
77
packages/create-app/src/__tests__/template-engine.test.ts
Normal file
77
packages/create-app/src/__tests__/template-engine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
34
packages/create-app/src/lib/template-engine.ts
Normal file
34
packages/create-app/src/lib/template-engine.ts
Normal 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;
|
||||
}
|
||||
457
packages/create-app/src/lib/templates.ts
Normal file
457
packages/create-app/src/lib/templates.ts
Normal 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' },
|
||||
});
|
||||
`;
|
||||
315
packages/create-app/src/scaffolder.ts
Normal file
315
packages/create-app/src/scaffolder.ts
Normal 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);
|
||||
});
|
||||
7
packages/create-app/vitest.config.ts
Normal file
7
packages/create-app/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user