learning_ai_common_plat/services/platform-service/scripts/gen-module.ts
Saravana Achu Mac 2c9dc1870d chore(platform): document script CLI output
What changed:
- Added explicit no-console policy for platform-service CLI/codegen scripts.
- Replaced the remaining migrate-referrals catch any with unknown narrowing.
- Added a TODO for making migrate-referrals --help independent of service env loading.

Warning impact:
- Platform script lint warnings: 78 -> 0.
- Workspace lint: 90 -> 12 warnings.

Verification:
- pnpm --filter @lysnrai/platform-service build
- pnpm --filter @lysnrai/platform-service exec eslint scripts --ext .ts
- pnpm lint

Note:
- migrate-referrals --help still requires service env due eager config import; TODO added for a follow-up behavior-safe refactor.
2026-05-04 16:45:42 -07:00

669 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Module Generator Script
*
* Generates the standard platform-service module structure:
* types.ts → repository.ts → routes.ts → <name>.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 <name> --fields "<field:type,...>" [--partition <key>] [--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: string; ts: string }> = {
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<typeof Create${P}Schema>;
export type Update${P}Input = z.infer<typeof Update${P}Schema>;
`;
}
// ── 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<boolean> {
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<void> {
// ── 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<boolean> {
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<boolean> {
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<void> {
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<string, string> = {
'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);
});