#!/usr/bin/env node /** * Module Generator Script * * Generates the standard platform-service module structure: * types.ts → repository.ts → routes.ts → test.ts * * Usage: * pnpm gen:module --name tasks --fields "title:string,status:enum(pending,active,done),priority:number" --partition userId * * @module platform-service/scripts/gen-module */ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Parse CLI arguments function parseArgs() { const args = process.argv.slice(2); const options = { name: '', fields: '', partition: 'id', 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); } return options; } function showHelp() { 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", "notifications") --fields, -f Field definitions (e.g., "title:string,status:enum(pending,done)") --partition, -p Partition key (default: "id") --dry-run, -d Preview output without writing files --help, -h Show this help Field Types: string String field number Numeric field boolean Boolean field date Date field (ISO string) datetime DateTime field (ISO string with time) enum(a,b,c) Enum with values a, b, c string[] Array of strings number[] Array of numbers 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),metadata:object" --partition userId `); } // Parse field definitions function parseFields(fieldsStr: string): Array<{ name: string; type: string; optional: boolean; zodType: string; tsType: string; enumValues: string[] | null; }> { const fields = []; const fieldDefs = fieldsStr.split(',').map(f => f.trim()).filter(Boolean); for (const def of fieldDefs) { const match = def.match(/^([a-zA-Z_][a-zA-Z0-9_]*):([a-z]+(?:\([^)]+\))?|\[?\w+\]?)\??$/); if (!match) { throw new Error(`Invalid field definition: ${def}`); } const [, name, typeSpec] = match; const optional = def.endsWith('?'); let type = typeSpec.replace('?', ''); let zodType = ''; let tsType = ''; let enumValues = null; if (type.startsWith('enum(')) { enumValues = type.slice(5, -1).split('|').map((v: string) => v.trim()); zodType = `z.enum([${enumValues.map(v => `"${v}"`).join(', ')}])`; tsType = enumValues.map(v => `"${v}"`).join(' | '); } else if (type === 'string') { zodType = 'z.string()'; tsType = 'string'; } else if (type === 'number') { zodType = 'z.number()'; tsType = 'number'; } else if (type === 'boolean') { zodType = 'z.boolean()'; tsType = 'boolean'; } else if (type === 'date' || type === 'datetime') { zodType = 'z.string().datetime()'; tsType = 'string'; } else if (type === 'string[]') { zodType = 'z.array(z.string())'; tsType = 'string[]'; } else if (type === 'number[]') { zodType = 'z.array(z.number())'; tsType = 'number[]'; } else { zodType = 'z.any()'; tsType = 'unknown'; } fields.push({ name, type, optional, zodType, tsType, enumValues, }); } return fields; } // Generate types.ts content function generateTypes(name: string, fields: ReturnType, partitionKey: string) { const pascalName = name.charAt(0).toUpperCase() + name.slice(1); const fieldSchemas = fields.map((f: ReturnType[0]) => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''}` ).join(',\n'); const interfaceFields = fields.map((f: ReturnType[0]) => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};` ).join('\n'); return `/** * ${pascalName} Types * * @module platform-service/src/modules/${name}/types */ import { z } from 'zod'; // ── MongoDB Document ID ───────────────────────────────────── export const ObjectIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, { message: 'Invalid ObjectId format', }); // ── Core ${pascalName} Schema ───────────────────────────────────── export const ${pascalName}Schema = z.object({ id: ObjectIdSchema, productId: z.string(), ${partitionKey}: z.string(), ${fieldSchemas}, createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type ${pascalName} = z.infer; // ── Create Input ──────────────────────────────────────────── export const Create${pascalName}Schema = z.object({ ${fieldSchemas}, }); export type Create${pascalName}Input = z.infer; // ── Update Input ──────────────────────────────────────────── export const Update${pascalName}Schema = z.object({ ${fields.filter(f => f.name !== partitionKey).map(f => ` ${f.name}: ${f.zodType}.optional()`).join(',\n')}, }); export type Update${pascalName}Input = z.infer; // ── Query Params ───────────────────────────────────────────── export const Query${pascalName}Schema = z.object({ ${partitionKey}: z.string().optional(), limit: z.number().min(1).max(100).default(20).optional(), offset: z.number().min(0).default(0).optional(), }); export type Query${pascalName}Params = z.infer; `; } // Generate repository.ts content function generateRepository(name: string, fields: ReturnType, partitionKey: string) { const pascalName = name.charAt(0).toUpperCase() + name.slice(1); const camelName = name.charAt(0).toLowerCase() + name.slice(1); return `/** * ${pascalName} Repository * * @module platform-service/src/modules/${name}/repository */ import { getContainer } from '../../lib/cosmos.js'; import { type ${pascalName}, type Create${pascalName}Input, type Update${pascalName}Input, type Query${pascalName}Params, } from './types.js'; const CONTAINER_NAME = '${camelName}'; // ── Create ──────────────────────────────────────────────────── export async function create${pascalName}( productId: string, input: Create${pascalName}Input & { ${partitionKey}: string } ): Promise<${pascalName}> { const container = await getContainer(CONTAINER_NAME); const doc = { id: crypto.randomUUID(), productId, ...input, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const { resource } = await container.items.create(doc); return resource as ${pascalName}; } // ── Read ───────────────────────────────────────────────────── export async function get${pascalName}( id: string, productId: string ): Promise<${pascalName} | null> { const container = await getContainer(CONTAINER_NAME); try { const { resource } = await container.item(id, productId).read(); return resource as ${pascalName} | null; } catch { return null; } } // ── List ──────────────────────────────────────────────────── export async function list${pascalName}s( productId: string, params: Query${pascalName}Params ): Promise<{ items: ${pascalName}[]; total: number }> { const container = await getContainer(CONTAINER_NAME); const query = { query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', parameters: [{ name: '@productId', value: productId }], }; const { resources: items } = await container.items.query(query).fetchAll(); const countQuery = { query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', parameters: [{ name: '@productId', value: productId }], }; const { resources: [total] } = await container.items.query(countQuery).fetchAll(); return { items: items as ${pascalName}[], total: total as number }; } // ── Update ────────────────────────────────────────────────── export async function update${pascalName}( id: string, productId: string, input: Update${pascalName}Input ): Promise<${pascalName} | null> { const container = await getContainer(CONTAINER_NAME); const existing = await get${pascalName}(id, productId); if (!existing) return null; const doc = { ...existing, ...input, updatedAt: new Date().toISOString(), }; const { resource } = await container.items.upsert(doc); return resource as ${pascalName}; } // ── Delete ──────────────────────────────────────────────────── export async function delete${pascalName}( id: string, productId: string ): Promise { const container = await getContainer(CONTAINER_NAME); try { await container.item(id, productId).delete(); return true; } catch { return false; } } `; } // Generate routes.ts content function generateRoutes(name: string, fields: ReturnType, partitionKey: string) { const pascalName = name.charAt(0).toUpperCase() + name.slice(1); const camelName = name.charAt(0).toLowerCase() + name.slice(1); return `/** * ${pascalName} Routes * * @module platform-service/src/modules/${name}/routes */ import type { FastifyInstance } from 'fastify'; import { NotFoundError, BadRequestError } from '../../lib/errors.js'; import { getRequestProductId } from '../../lib/request-context.js'; import { Create${pascalName}Schema, Update${pascalName}Schema, Query${pascalName}Schema, } from './types.js'; import { create${pascalName}, get${pascalName}, list${pascalName}s, update${pascalName}, delete${pascalName}, } from './repository.js'; export async function ${camelName}Routes(app: FastifyInstance) { // List ${camelName}s app.get('/', async (req) => { const productId = getRequestProductId(req); const params = Query${pascalName}Schema.parse(req.query); return list${pascalName}s(productId, params); }); // Get ${camelName} by ID app.get<{ Params: { id: string } }>('/:id', async (req) => { const productId = getRequestProductId(req); const item = await get${pascalName}(req.params.id, productId); if (!item) throw new NotFoundError('${pascalName} not found'); return item; }); // Create ${camelName} app.post('/', async (req) => { const productId = getRequestProductId(req); const body = Create${pascalName}Schema.parse(req.body); return create${pascalName}(productId, { ...body, ${partitionKey}: req.user?.id || 'unknown' }); }); // Update ${camelName} app.patch<{ Params: { id: string } }>('/:id', async (req) => { const productId = getRequestProductId(req); const body = Update${pascalName}Schema.parse(req.body); const item = await update${pascalName}(req.params.id, productId, body); if (!item) throw new NotFoundError('${pascalName} not found'); return item; }); // Delete ${camelName} app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => { const productId = getRequestProductId(req); const deleted = await delete${pascalName}(req.params.id, productId); if (!deleted) throw new NotFoundError('${pascalName} not found'); reply.status(204); }); } `; } // Generate test.ts content function generateTest(name: string, fields: ReturnType, partitionKey: string) { const pascalName = name.charAt(0).toUpperCase() + name.slice(1); const camelName = name.charAt(0).toLowerCase() + name.slice(1); return `/** * ${pascalName} Module Tests * * @module platform-service/src/modules/${name}/${name}.test */ import { describe, it, expect, beforeAll } from 'vitest'; import { createTestApp } from '../../test-helpers.js'; import { Create${pascalName}Schema, Update${pascalName}Schema, } from './types.js'; describe('${pascalName} module', () => { const app = createTestApp(); describe('Create${pascalName}Schema', () => { it('accepts valid input', () => { ${fields.filter(f => !f.optional).map(f => ` const result = Create${pascalName}Schema.parse({ ${f.name}: ${f.type === 'string' ? "'test'" : f.type === 'number' ? '123' : f.type === 'boolean' ? 'true' : "'value'"} }); expect(result).toBeDefined();`).join('\n')} }); it('rejects invalid input', () => { expect(() => Create${pascalName}Schema.parse({})).toThrow(); }); }); describe('Update${pascalName}Schema', () => { it('accepts partial updates', () => { const result = Update${pascalName}Schema.parse({}); expect(result).toEqual({}); }); }); describe('API Routes', () => { it('POST /api/${camelName} creates item', async () => { const res = await app.inject({ method: 'POST', url: '/api/${camelName}', payload: { ${fields.filter(f => !f.optional).slice(0, 2).map(f => ` ${f.name}: ${f.type === 'string' ? "'Test ${f.name}'" : f.type === 'number' ? '42' : 'true'}`).join(',\n')} }, }); expect(res.statusCode).toBe(201); }); it('GET /api/${camelName} lists items', async () => { const res = await app.inject({ method: 'GET', url: '/api/${camelName}', }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.payload); expect(body.items).toBeDefined(); expect(body.total).toBeDefined(); }); }); }); `; } // Main generator function async function generateModule() { const options = parseArgs(); const { name, fields, partition, dryRun } = options; console.log(`\\n🚀 Generating module: ${name}`); console.log(` Fields: ${fields}`); console.log(` Partition: ${partition}`); if (dryRun) console.log(' ⚠️ DRY RUN - No files will be written'); try { const parsedFields = parseFields(fields); console.log(` Parsed ${parsedFields.length} fields`); const files = { 'types.ts': generateTypes(name, parsedFields, partition), 'repository.ts': generateRepository(name, parsedFields, partition), 'routes.ts': generateRoutes(name, parsedFields, partition), [`${name}.test.ts`]: generateTest(name, parsedFields, partition), }; if (dryRun) { console.log('\\n📄 Generated files (preview):'); for (const [filename, content] of Object.entries(files)) { console.log(`\\n--- ${filename} ---`); console.log(content.slice(0, 500) + (content.length > 500 ? '\\n...' : '')); } } else { // Create module directory const moduleDir = path.join(__dirname, '..', 'src', 'modules', name); await fs.mkdir(moduleDir, { recursive: true }); // Write files for (const [filename, content] of Object.entries(files)) { const filepath = path.join(moduleDir, filename); await fs.writeFile(filepath, content, 'utf-8'); console.log(` ✅ ${filename}`); } console.log(`\\n✨ Module created: ${moduleDir}`); console.log(`\\nNext steps:`); console.log(` 1. Add container to cosmos-init.ts: { name: '${name}', partitionKey: '/${partition}' }`); console.log(` 2. Import and register routes in server.ts`); console.log(` 3. Run tests: pnpm --filter @lysnrai/platform-service test`); } } catch (error) { console.error('\\n❌ Error:', error instanceof Error ? error.message : String(error)); process.exit(1); } } generateModule();