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.
669 lines
22 KiB
JavaScript
669 lines
22 KiB
JavaScript
#!/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);
|
||
});
|