diff --git a/services/platform-service/scripts/gen-module.ts b/services/platform-service/scripts/gen-module.ts index 11b931b4..147e4cc3 100644 --- a/services/platform-service/scripts/gen-module.ts +++ b/services/platform-service/scripts/gen-module.ts @@ -3,41 +3,46 @@ * Module Generator Script * * Generates the standard platform-service module structure: - * types.ts → repository.ts → routes.ts → test.ts + * 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 */ -import { promises as fs } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +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'); -// Parse CLI arguments -function parseArgs() { +// ── CLI ────────────────────────────────────────────────────────────────────── + +interface Options { + name: string; + fields: string; + partition: string; + dryRun: boolean; +} + +function parseArgs(): Options { const args = process.argv.slice(2); - const options = { - name: '', - fields: '', - partition: 'id', - dryRun: false, - }; + 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') { + 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); } @@ -49,274 +54,284 @@ function parseArgs() { 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() { +function showHelp(): void { console.log(` Module Generator — Create platform-service module scaffolding Usage: - pnpm gen:module --name --fields "" --partition [--dry-run] + 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 + --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 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 + 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),metadata:object" --partition userId + 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 `); } -// Parse field definitions -function parseFields(fieldsStr: string): Array<{ +// ── Field Parser ───────────────────────────────────────────────────────────── + +interface ParsedField { 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[]'; +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 { - zodType = 'z.any()'; - tsType = 'unknown'; + 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}`); } - fields.push({ - name, - type, - optional, - zodType, - tsType, - enumValues, - }); + 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; } -// 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'); +// ── Helpers ────────────────────────────────────────────────────────────────── - const interfaceFields = fields.map((f: ReturnType[0]) => - ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};` - ).join('\n'); +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 `/** - * ${pascalName} Types - * - * @module platform-service/src/modules/${name}/types + * ${P} module — types and schemas. */ import { z } from 'zod'; -// ── MongoDB Document ID ───────────────────────────────────── -export const ObjectIdSchema = z.string().regex(/^[0-9a-fA-F]{24}$/, { - message: 'Invalid ObjectId format', +// ── 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} }); -// ── 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 const Update${P}Schema = z.object({ +${updateFields} }); -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; +export type Create${P}Input = z.infer; +export type Update${P}Input = 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); +// ── Template: repository.ts ────────────────────────────────────────────────── + +function genRepo(name: string, fields: ParsedField[], partition: string): string { + const P = pascal(name); + const prefix = name.slice(0, 3); return `/** - * ${pascalName} Repository - * - * @module platform-service/src/modules/${name}/repository + * ${P} repository — Cosmos DB CRUD. */ -import { getContainer } from '../../lib/cosmos.js'; -import { - type ${pascalName}, - type Create${pascalName}Input, - type Update${pascalName}Input, - type Query${pascalName}Params, -} from './types.js'; +import { getRegisteredContainer } from '@bytelyst/cosmos'; +import crypto from 'node:crypto'; +import type { ${P}Doc, Create${P}Input, Update${P}Input } 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}; +function getContainer() { + return getRegisteredContainer('${name}'); } -// ── Read ───────────────────────────────────────────────────── -export async function get${pascalName}( - id: string, - productId: string -): Promise<${pascalName} | null> { - const container = await getContainer(CONTAINER_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 container.item(id, productId).read(); - return resource as ${pascalName} | null; + const { resource } = await getContainer().item(id, ${partition}).read<${P}Doc>(); + return resource ?? null; } catch { return null; } } -// ── List ──────────────────────────────────────────────────── -export async function list${pascalName}s( +export async function list${P}( 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 }; + ${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; } -// ── Update ────────────────────────────────────────────────── -export async function update${pascalName}( +export async function update${P}( id: string, - productId: string, - input: Update${pascalName}Input -): Promise<${pascalName} | null> { - const container = await getContainer(CONTAINER_NAME); - - const existing = await get${pascalName}(id, productId); + ${partition}: string, + updates: Update${P}Input, +): Promise<${P}Doc | null> { + const existing = await get${P}(id, ${partition}); if (!existing) return null; - const doc = { + const updated: ${P}Doc = { ...existing, - ...input, + ...updates, updatedAt: new Date().toISOString(), }; - - const { resource } = await container.items.upsert(doc); - return resource as ${pascalName}; + await getContainer().item(id, ${partition}).replace(updated); + return updated; } -// ── Delete ──────────────────────────────────────────────────── -export async function delete${pascalName}( +export async function delete${P}( id: string, - productId: string + ${partition}: string, ): Promise { - const container = await getContainer(CONTAINER_NAME); - try { - await container.item(id, productId).delete(); + await getContainer().item(id, ${partition}).delete(); return true; } catch { return false; @@ -325,194 +340,327 @@ export async function delete${pascalName}( `; } -// 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); +// ── Template: routes.ts ────────────────────────────────────────────────────── + +function genRoutes(name: string, _fields: ParsedField[], _partition: string): string { + const P = pascal(name); + const c = camel(name); return `/** - * ${pascalName} Routes - * - * @module platform-service/src/modules/${name}/routes + * ${P} routes. */ import type { FastifyInstance } from 'fastify'; -import { NotFoundError, BadRequestError } from '../../lib/errors.js'; +import { UnauthorizedError, NotFoundError } 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'; +import { Create${P}Schema, Update${P}Schema } from './types.js'; +import { create${P}, get${P}, list${P}, update${P}, delete${P} } from './repository.js'; -export async function ${camelName}Routes(app: FastifyInstance) { - // List ${camelName}s - app.get('/', async (req) => { +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); - const params = Query${pascalName}Schema.parse(req.query); - return list${pascalName}s(productId, params); + return list${P}(productId, userId); }); - // 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'); + // ── 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 ${camelName} - app.post('/', async (req) => { + // ── Create ───────────────────────────────────────────────── + app.post('/${name}', async (req, reply) => { + const userId = requireAuth(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'); + const input = Create${P}Schema.parse(req.body); + const item = await create${P}(productId, userId, input); + reply.status(201); 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'); + // ── 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); }); } `; } -// 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); +// ── 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 `/** - * ${pascalName} Module Tests - * - * @module platform-service/src/modules/${name}/${name}.test + * ${P} module — schema tests. */ -import { describe, it, expect, beforeAll } from 'vitest'; -import { createTestApp } from '../../test-helpers.js'; -import { - Create${pascalName}Schema, - Update${pascalName}Schema, -} from './types.js'; +import { describe, it, expect } from 'vitest'; +import { Create${P}Schema, Update${P}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('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')} }); - describe('Update${pascalName}Schema', () => { - it('accepts partial updates', () => { - const result = Update${pascalName}Schema.parse({}); - expect(result).toEqual({}); - }); + it('rejects empty object', () => { + expect(() => Create${P}Schema.parse({})).toThrow(); }); - 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); - }); +${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')}}); - 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(); - }); +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(); }); }); `; } -// 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'); +// ── 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 { - 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); + 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; } -generateModule(); +// ── 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); +});