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",
|
"description": "CLI tools for scaffolding ByteLyst product repos and code",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"create-app": "./dist/scaffolder.js",
|
||||||
"gen-api-route": "./dist/generators/api-routes.js",
|
"gen-api-route": "./dist/generators/api-routes.js",
|
||||||
"gen-agents-md": "./dist/generators/agents-md.js"
|
"gen-agents-md": "./dist/generators/agents-md.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"create-app": "tsx src/scaffolder.ts",
|
||||||
"gen:api-route": "tsx src/generators/api-routes.ts",
|
"gen:api-route": "tsx src/generators/api-routes.ts",
|
||||||
"gen:agents-md": "tsx src/generators/agents-md.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