- Add gen-module.ts with full CLI interface - Generate types.ts, repository.ts, routes.ts, and test.ts - Support field types: string, number, boolean, date, datetime, enum, string[], number[] - Add --dry-run flag for preview - Register script in package.json
519 lines
17 KiB
JavaScript
519 lines
17 KiB
JavaScript
#!/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 <name> --fields "<field:type,...>" --partition <key> [--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<typeof parseFields>, partitionKey: string) {
|
|
const pascalName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
const fieldSchemas = fields.map((f: ReturnType<typeof parseFields>[0]) =>
|
|
` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''}`
|
|
).join(',\n');
|
|
|
|
const interfaceFields = fields.map((f: ReturnType<typeof parseFields>[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<typeof ${pascalName}Schema>;
|
|
|
|
// ── Create Input ────────────────────────────────────────────
|
|
export const Create${pascalName}Schema = z.object({
|
|
${fieldSchemas},
|
|
});
|
|
|
|
export type Create${pascalName}Input = z.infer<typeof Create${pascalName}Schema>;
|
|
|
|
// ── 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<typeof Update${pascalName}Schema>;
|
|
|
|
// ── 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<typeof Query${pascalName}Schema>;
|
|
`;
|
|
}
|
|
|
|
// Generate repository.ts content
|
|
function generateRepository(name: string, fields: ReturnType<typeof parseFields>, 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<boolean> {
|
|
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<typeof parseFields>, 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<typeof parseFields>, 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();
|