#!/usr/bin/env node /** * Module Generator Script * * Generates the standard platform-service module structure: * types.ts → repository.ts → routes.ts → .test.ts * * Also auto-patches: * - cosmos-init.ts — adds container definition * - server.ts — adds route import + registration * * Usage: * pnpm gen:module --name tasks --fields "title:string,status:enum(pending,active,done),priority:number" --partition userId * pnpm gen:module --name tasks --fields "title:string,done:boolean?" --partition userId --dry-run * * @module platform-service/scripts/gen-module */ /* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SRC_DIR = path.join(__dirname, '..', 'src'); // ── CLI ────────────────────────────────────────────────────────────────────── interface Options { name: string; fields: string; partition: string; dryRun: boolean; } function parseArgs(): Options { const args = process.argv.slice(2); const options: Options = { name: '', fields: '', partition: 'userId', dryRun: false }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--name' || arg === '-n') options.name = args[++i]; else if (arg === '--fields' || arg === '-f') options.fields = args[++i]; else if (arg === '--partition' || arg === '-p') options.partition = args[++i]; else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; else if (arg === '--help' || arg === '-h') { showHelp(); process.exit(0); } } if (!options.name || !options.fields) { console.error('Error: --name and --fields are required'); showHelp(); process.exit(1); } // Validate module name: lowercase, no special chars if (!/^[a-z][a-z0-9-]*$/.test(options.name)) { console.error('Error: --name must be lowercase alphanumeric with optional hyphens'); process.exit(1); } return options; } function showHelp(): void { console.log(` Module Generator — Create platform-service module scaffolding Usage: pnpm gen:module --name --fields "" [--partition ] [--dry-run] Options: --name, -n Module name (e.g., "tasks", "alerts") --fields, -f Comma-separated field definitions --partition, -p Partition key field name (default: "userId") --dry-run, -d Preview generated files without writing --help, -h Show this help Field Types: string z.string() number z.number() boolean z.boolean() date z.string().datetime() (ISO string) datetime z.string().datetime() (ISO string with time) enum(a,b,c) z.enum(["a","b","c"]) string[] z.array(z.string()) number[] z.array(z.number()) Append ? to make a field optional: priority:number? Examples: pnpm gen:module --name tasks \\ --fields "title:string,status:enum(pending,active,done),priority:number?" \\ --partition userId pnpm gen:module --name alerts \\ --fields "message:string,level:enum(info,warn,error),read:boolean" \\ --partition userId --dry-run `); } // ── Field Parser ───────────────────────────────────────────────────────────── interface ParsedField { name: string; type: string; optional: boolean; zodType: string; tsType: string; enumValues: string[] | null; } function splitFields(str: string): string[] { const parts: string[] = []; let depth = 0; let current = ''; for (const ch of str) { if (ch === '(') depth++; if (ch === ')') depth--; if (ch === ',' && depth === 0) { parts.push(current.trim()); current = ''; } else { current += ch; } } if (current.trim()) parts.push(current.trim()); return parts; } function parseFields(fieldsStr: string): ParsedField[] { const fields: ParsedField[] = []; const fieldDefs = splitFields(fieldsStr); for (const raw of fieldDefs) { const def = raw.trim(); if (!def) continue; const optional = def.endsWith('?'); const cleaned = optional ? def.slice(0, -1) : def; const colonIdx = cleaned.indexOf(':'); if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`); const name = cleaned.slice(0, colonIdx).trim(); const type = cleaned.slice(colonIdx + 1).trim(); if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { throw new Error(`Invalid field name: ${name}`); } let zodType: string; let tsType: string; let enumValues: string[] | null = null; if (type.startsWith('enum(') && type.endsWith(')')) { enumValues = type .slice(5, -1) .split(',') .map(v => v.trim()) .filter(Boolean); if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`); zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`; tsType = enumValues.map(v => `'${v}'`).join(' | '); } else { const MAP: Record = { string: { zod: 'z.string().min(1)', ts: 'string' }, number: { zod: 'z.number()', ts: 'number' }, boolean: { zod: 'z.boolean()', ts: 'boolean' }, date: { zod: 'z.string().datetime()', ts: 'string' }, datetime: { zod: 'z.string().datetime()', ts: 'string' }, 'string[]': { zod: 'z.array(z.string())', ts: 'string[]' }, 'number[]': { zod: 'z.array(z.number())', ts: 'number[]' }, }; const mapping = MAP[type]; if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`); zodType = mapping.zod; tsType = mapping.ts; } fields.push({ name, type, optional, zodType, tsType, enumValues }); } if (fields.length === 0) throw new Error('No fields defined'); return fields; } // ── Helpers ────────────────────────────────────────────────────────────────── function pascal(s: string): string { return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase()); } function camel(s: string): string { const p = pascal(s); return p.charAt(0).toLowerCase() + p.slice(1); } function sampleValue(f: ParsedField): string { if (f.enumValues) return `'${f.enumValues[0]}'`; if (f.tsType === 'string') return `'Test ${f.name}'`; if (f.tsType === 'number') return '42'; if (f.tsType === 'boolean') return 'true'; if (f.tsType === 'string[]') return `['a']`; if (f.tsType === 'number[]') return '[1]'; return `'2026-01-01T00:00:00.000Z'`; } // ── Template: types.ts ─────────────────────────────────────────────────────── function genTypes(name: string, fields: ParsedField[], partition: string): string { const P = pascal(name); const createFields = fields .map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`) .join('\n'); const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n'); const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n'); return `/** * ${P} module — types and schemas. */ import { z } from 'zod'; // ── Document ───────────────────────────────────────────────── export interface ${P}Doc { id: string; productId: string; ${partition}: string; ${docFields} createdAt: string; updatedAt: string; } // ── Schemas ────────────────────────────────────────────────── export const Create${P}Schema = z.object({ ${createFields} }); export const Update${P}Schema = z.object({ ${updateFields} }); export type Create${P}Input = z.infer; export type Update${P}Input = z.infer; `; } // ── Template: repository.ts ────────────────────────────────────────────────── function genRepo(name: string, fields: ParsedField[], partition: string): string { const P = pascal(name); const prefix = name.slice(0, 3); return `/** * ${P} repository — Cosmos DB CRUD. */ import { getRegisteredContainer } from '@bytelyst/cosmos'; import crypto from 'node:crypto'; import type { ${P}Doc, Create${P}Input, Update${P}Input } from './types.js'; function getContainer() { return getRegisteredContainer('${name}'); } export async function create${P}( productId: string, ${partition}: string, input: Create${P}Input, ): Promise<${P}Doc> { const now = new Date().toISOString(); const doc: ${P}Doc = { id: \`${prefix}_\${crypto.randomUUID()}\`, productId, ${partition}, ...input, createdAt: now, updatedAt: now, }; await getContainer().items.create(doc); return doc; } export async function get${P}( id: string, ${partition}: string, ): Promise<${P}Doc | null> { try { const { resource } = await getContainer().item(id, ${partition}).read<${P}Doc>(); return resource ?? null; } catch { return null; } } export async function list${P}( productId: string, ${partition}: string, ): Promise<${P}Doc[]> { const { resources } = await getContainer() .items.query<${P}Doc>({ query: \`SELECT * FROM c WHERE c.productId = @pid AND c.${partition} = @pk ORDER BY c.createdAt DESC\`, parameters: [ { name: '@pid', value: productId }, { name: '@pk', value: ${partition} }, ], }) .fetchAll(); return resources; } export async function update${P}( id: string, ${partition}: string, updates: Update${P}Input, ): Promise<${P}Doc | null> { const existing = await get${P}(id, ${partition}); if (!existing) return null; const updated: ${P}Doc = { ...existing, ...updates, updatedAt: new Date().toISOString(), }; await getContainer().item(id, ${partition}).replace(updated); return updated; } export async function delete${P}( id: string, ${partition}: string, ): Promise { try { await getContainer().item(id, ${partition}).delete(); return true; } catch { return false; } } `; } // ── Template: routes.ts ────────────────────────────────────────────────────── function genRoutes(name: string, _fields: ParsedField[], _partition: string): string { const P = pascal(name); const c = camel(name); return `/** * ${P} routes. */ import type { FastifyInstance } from 'fastify'; import { UnauthorizedError, NotFoundError } from '../../lib/errors.js'; import { getRequestProductId } from '../../lib/request-context.js'; import { Create${P}Schema, Update${P}Schema } from './types.js'; import { create${P}, get${P}, list${P}, update${P}, delete${P} } from './repository.js'; function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string { if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); return req.jwtPayload.sub; } export async function ${c}Routes(app: FastifyInstance): Promise { // ── List ──────────────────────────────────────────────────── app.get('/${name}', async (req) => { const userId = requireAuth(req); const productId = getRequestProductId(req); return list${P}(productId, userId); }); // ── Get by ID ────────────────────────────────────────────── app.get<{ Params: { id: string } }>('/${name}/:id', async (req) => { const userId = requireAuth(req); const item = await get${P}(req.params.id, userId); if (!item) throw new NotFoundError('${P} not found'); return item; }); // ── Create ───────────────────────────────────────────────── app.post('/${name}', async (req, reply) => { const userId = requireAuth(req); const productId = getRequestProductId(req); const input = Create${P}Schema.parse(req.body); const item = await create${P}(productId, userId, input); reply.status(201); return item; }); // ── Update ───────────────────────────────────────────────── app.patch<{ Params: { id: string } }>('/${name}/:id', async (req) => { const userId = requireAuth(req); const updates = Update${P}Schema.parse(req.body); const item = await update${P}(req.params.id, userId, updates); if (!item) throw new NotFoundError('${P} not found'); return item; }); // ── Delete ───────────────────────────────────────────────── app.delete<{ Params: { id: string } }>('/${name}/:id', async (req, reply) => { const userId = requireAuth(req); const ok = await delete${P}(req.params.id, userId); if (!ok) throw new NotFoundError('${P} not found'); reply.status(204); }); } `; } // ── Template: test ─────────────────────────────────────────────────────────── function genTest(name: string, fields: ParsedField[]): string { const P = pascal(name); const requiredFields = fields.filter(f => !f.optional); const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n'); return `/** * ${P} module — schema tests. */ import { describe, it, expect } from 'vitest'; import { Create${P}Schema, Update${P}Schema } from './types.js'; describe('Create${P}Schema', () => { it('accepts valid input', () => { const result = Create${P}Schema.parse({ ${validPayload} }); expect(result).toBeDefined(); ${requiredFields.map(f => ` expect(result.${f.name}).toBe(${sampleValue(f)});`).join('\n')} }); it('rejects empty object', () => { expect(() => Create${P}Schema.parse({})).toThrow(); }); ${requiredFields .map( f => ` it('rejects missing ${f.name}', () => { const input = { ${requiredFields .filter(r => r.name !== f.name) .map(r => ` ${r.name}: ${sampleValue(r)},`) .join('\n')} }; expect(() => Create${P}Schema.parse(input)).toThrow(); }); ` ) .join('\n')}}); describe('Update${P}Schema', () => { it('accepts empty object (all fields optional)', () => { const result = Update${P}Schema.parse({}); expect(result).toEqual({}); }); it('accepts partial update', () => { const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} }); expect(result).toBeDefined(); }); }); `; } // ── Auto-patch: cosmos-init.ts ─────────────────────────────────────────────── async function patchCosmosInit(name: string, partition: string, dryRun: boolean): Promise { const filePath = path.join(SRC_DIR, 'lib', 'cosmos-init.ts'); let content: string; try { content = await fs.readFile(filePath, 'utf-8'); } catch { console.log(' ⚠️ cosmos-init.ts not found — skipping auto-patch'); return false; } // Check if container already exists if (content.includes(`'${name}'`) || content.includes(`"${name}"`)) { console.log(` ℹ️ Container '${name}' already in cosmos-init.ts`); return false; } // Find the CONTAINER_DEFS object — insert before the closing }; // Pattern: look for the last container entry before a closing brace const insertLine = ` ${name}: { partitionKeyPath: '/${partition}' },`; // Find a good insertion point: before the first comment line after container entries, // or before the closing `};` of the CONTAINER_DEFS const lines = content.split('\n'); let insertIdx = -1; let inDefs = false; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Detect we're inside CONTAINER_DEFS if (line.includes('CONTAINER_DEFS') || line.includes('containerDefs')) inDefs = true; // Look for a line with }: or }; that ends the object if (inDefs && (line === '};' || line === '} as const;' || line === '} satisfies')) { insertIdx = i; break; } } if (insertIdx === -1) { console.log(' ⚠️ Could not find insertion point in cosmos-init.ts — add manually'); console.log(` ${insertLine}`); return false; } if (dryRun) { console.log(` 📝 Would add to cosmos-init.ts line ${insertIdx + 1}:`); console.log(` ${insertLine}`); return true; } lines.splice(insertIdx, 0, insertLine); await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); console.log(` ✅ cosmos-init.ts — added container '${name}'`); return true; } // ── Auto-patch: server.ts ──────────────────────────────────────────────────── async function patchServerTs(name: string, dryRun: boolean): Promise { const filePath = path.join(SRC_DIR, 'server.ts'); let content: string; try { content = await fs.readFile(filePath, 'utf-8'); } catch { console.log(' ⚠️ server.ts not found — skipping auto-patch'); return false; } const c = camel(name); const routeFn = `${c}Routes`; // Check if already registered if (content.includes(routeFn)) { console.log(` ℹ️ ${routeFn} already in server.ts`); return false; } const importLine = `import { ${routeFn} } from './modules/${name}/routes.js';`; const registerLine = `await app.register(${routeFn}, { prefix: '/api' });`; if (dryRun) { console.log(' 📝 Would add to server.ts:'); console.log(` Import: ${importLine}`); console.log(` Register: ${registerLine}`); return true; } const lines = content.split('\n'); // Find last import line to insert import after it let lastImportIdx = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('import ')) lastImportIdx = i; } if (lastImportIdx === -1) { console.log(' ⚠️ Could not find import section in server.ts — add manually'); return false; } // Insert import after last import lines.splice(lastImportIdx + 1, 0, importLine); // Find last app.register line to insert registration after it let lastRegisterIdx = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].includes('app.register(') && lines[i].includes("prefix: '/api'")) { lastRegisterIdx = i; } } if (lastRegisterIdx === -1) { // Fall back: insert before the listen/start call for (let i = 0; i < lines.length; i++) { if (lines[i].includes('app.listen') || lines[i].includes('startService')) { lastRegisterIdx = i - 1; break; } } } if (lastRegisterIdx === -1) { console.log(' ⚠️ Could not find registration point in server.ts — add manually'); console.log(` ${registerLine}`); return false; } lines.splice(lastRegisterIdx + 1, 0, registerLine); await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); console.log(` ✅ server.ts — added import + registration for ${routeFn}`); return true; } // ── Main ───────────────────────────────────────────────────────────────────── async function main(): Promise { const { name, fields, partition, dryRun } = parseArgs(); console.log(`\n🚀 Generating module: ${name}`); console.log(` Fields: ${fields}`); console.log(` Partition: /${partition}`); if (dryRun) console.log(' Mode: DRY RUN — no files will be written\n'); const parsedFields = parseFields(fields); console.log(` Parsed ${parsedFields.length} field(s)\n`); const moduleDir = path.join(SRC_DIR, 'modules', name); // Check if module already exists try { await fs.access(moduleDir); console.error(`❌ Module directory already exists: ${moduleDir}`); console.error(' Delete it first or choose a different name.'); process.exit(1); } catch { // Good — directory doesn't exist } const files: Record = { 'types.ts': genTypes(name, parsedFields, partition), 'repository.ts': genRepo(name, parsedFields, partition), 'routes.ts': genRoutes(name, parsedFields, partition), [`${name}.test.ts`]: genTest(name, parsedFields), }; if (dryRun) { console.log('📄 Generated files (preview):\n'); for (const [filename, content] of Object.entries(files)) { console.log(`── ${filename} ──────────────────────────────────────`); console.log(content); } console.log('── Auto-patches ──────────────────────────────────────'); await patchCosmosInit(name, partition, true); await patchServerTs(name, true); console.log('\n✨ Dry run complete. Re-run without --dry-run to write files.'); return; } // Write module files await fs.mkdir(moduleDir, { recursive: true }); for (const [filename, content] of Object.entries(files)) { await fs.writeFile(path.join(moduleDir, filename), content, 'utf-8'); console.log(` ✅ ${filename}`); } // Auto-patch cosmos-init.ts and server.ts console.log(''); await patchCosmosInit(name, partition, false); await patchServerTs(name, false); console.log(`\n✨ Module '${name}' created at src/modules/${name}/`); console.log(`\n Verify: pnpm --filter @lysnrai/platform-service test`); console.log(` Typecheck: pnpm --filter @lysnrai/platform-service exec tsc --noEmit`); } main().catch(err => { console.error('\n❌ Error:', err instanceof Error ? err.message : String(err)); process.exit(1); });