UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

549 lines (548 loc) 29.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const path_1 = require("path"); const ts_morph_1 = require("ts-morph"); const framework_detection_1 = require("../../lib/framework-detection"); const module_1 = __importDefault(require("./module")); const object_1 = __importDefault(require("./object")); /** * Add property to module or object */ const NewCommand = { alias: ['ap'], description: 'Add property to module/object', hidden: false, name: 'addProp', run: (toolbox, options) => __awaiter(void 0, void 0, void 0, function* () { var _a, _b, _c, _d; // Options: const { preventExitProcess } = Object.assign({ preventExitProcess: false }, options); // Retrieve the tools we need const { config, filesystem, parameters, print: { divider, error, info, spin, success }, prompt: { ask, confirm }, server, strings: { pascalCase }, system, } = toolbox; // Handle --help-json flag if (toolbox.tools.helpJson({ aliases: ['ap'], configuration: 'commands.server.addProp.*', description: 'Add property to module/object', name: 'addProp', options: [ { description: 'Target type to update', flag: '--type', required: true, type: 'string', values: ['Module', 'Object'], }, { description: 'Name of the module or object to update', flag: '--element', required: true, type: 'string' }, { default: false, description: 'Skip lint fix after update', flag: '--skipLint', required: false, type: 'boolean', }, ], propertyFlags: { attributes: [ { description: 'Property name', name: 'name', type: 'string' }, { description: 'Property type', name: 'type', type: 'string', values: ['string', 'number', 'boolean', 'bigint', 'Date', 'ObjectId', 'Json'], }, { description: 'Optional field', name: 'nullable', type: 'boolean' }, { description: 'Array of this type', name: 'array', type: 'boolean' }, { description: 'Enum type reference', name: 'enum', type: 'string' }, { description: 'Embedded object/schema reference', name: 'schema', type: 'string' }, { description: 'Reference module for ObjectId fields', name: 'reference', type: 'string' }, ], pattern: '--prop-<attribute>-<index>', }, })) { return; } const argProps = Object.keys(toolbox.parameters.options || {}).filter((key) => key.startsWith('prop')); function getModules() { const cwd = filesystem.cwd(); const path = cwd.substr(0, cwd.lastIndexOf('src')); const moduleDirs = (0, path_1.join)(path, 'src', 'server', 'modules'); return filesystem.subdirectories(moduleDirs, true); } function getObjects() { const cwd = filesystem.cwd(); const path = cwd.substr(0, cwd.lastIndexOf('src')); const objectDirs = (0, path_1.join)(path, 'src', 'server', 'common', 'objects'); return filesystem.subdirectories(objectDirs, true); } // Load configuration const ltConfig = config.loadConfig(); // Parse CLI arguments const { element: cliElement, skipLint: cliSkipLint, type: cliType } = parameters.options; // Parse dry-run flag early const dryRun = parameters.options.dryRun || parameters.options['dry-run']; const objectOrModule = cliType || (yield ask([ { choices: ['Module', 'Object'], message: 'What should be updated', name: 'input', type: 'select', }, ])).input; const elementToEdit = cliElement || (yield ask([ { choices: objectOrModule === 'Module' ? getModules() : getObjects(), message: 'Choose one to update', name: 'input', type: 'select', }, ])).input; // Check if directory const cwd = filesystem.cwd(); const path = cwd.substr(0, cwd.lastIndexOf('src')); if (!filesystem.exists((0, path_1.join)(path, 'src'))) { info(''); error(`No src directory in "${path}".`); return; } // Verify the target module/object directory exists const targetDir = objectOrModule === 'Module' ? (0, path_1.join)(path, 'src', 'server', 'modules', elementToEdit) : (0, path_1.join)(path, 'src', 'server', 'common', 'objects', elementToEdit); if (!filesystem.exists(targetDir)) { info(''); error(`${objectOrModule} directory "${targetDir}" does not exist.`); return; } // Dry-run mode: show what would happen and exit if (dryRun) { // Still parse properties so we can display them const { props: dryRunProps } = yield toolbox.parseProperties({ argProps, objectsToAdd: [], parameters: toolbox.parameters, referencesToAdd: [], server: toolbox.server, }); info(''); info(`Dry run: lt server addProp --type ${objectOrModule} --element ${elementToEdit}`); info(''); info('Files that would be modified:'); if (objectOrModule === 'Module') { info(` src/server/modules/${elementToEdit}/${elementToEdit}.model.ts`); info(` src/server/modules/${elementToEdit}/inputs/${elementToEdit}.input.ts`); info(` src/server/modules/${elementToEdit}/inputs/${elementToEdit}-create.input.ts`); } else { info(` src/server/common/objects/${elementToEdit}/${elementToEdit}.object.ts`); info(` src/server/common/objects/${elementToEdit}/${elementToEdit}.input.ts`); info(` src/server/common/objects/${elementToEdit}/${elementToEdit}-create.input.ts`); } const propKeys = Object.keys(dryRunProps); if (propKeys.length > 0) { info(''); info('Properties that would be added:'); for (const key of propKeys) { const p = dryRunProps[key]; const parts = []; parts.push(p.type || 'string'); if (p.isArray) parts.push('(array)'); if (p.nullable) parts.push('(nullable)'); if (p.enumRef) parts.push(`(enum: ${p.enumRef})`); if (p.reference) parts.push(`(ref: ${p.reference})`); if (p.schema) parts.push(`(schema: ${p.schema})`); info(` ${p.name}: ${parts.join(' ')}`); } } info(''); return `dry-run addProp ${objectOrModule} ${elementToEdit}`; } const { objectsToAdd, props, referencesToAdd, refsSet, schemaSet } = yield toolbox.parseProperties({ argProps, objectsToAdd: [], parameters: toolbox.parameters, referencesToAdd: [], server: toolbox.server, }); const updateSpinner = spin('Updating files...'); const project = new ts_morph_1.Project(); // Prepare model file const modelPath = objectOrModule === 'Module' ? (0, path_1.join)(path, 'src', 'server', 'modules', elementToEdit, `${elementToEdit}.model.ts`) : (0, path_1.join)(path, 'src', 'server', 'common', 'objects', elementToEdit, `${elementToEdit}.object.ts`); const moduleFile = project.addSourceFileAtPath(modelPath); const modelDeclaration = moduleFile.getClasses()[0]; const modelProperties = modelDeclaration .getMembers() .filter((m) => m.getKind() === ts_morph_1.SyntaxKind.PropertyDeclaration); // Prepare input file const inputPath = objectOrModule === 'Module' ? (0, path_1.join)(path, 'src', 'server', 'modules', elementToEdit, 'inputs', `${elementToEdit}.input.ts`) : (0, path_1.join)(path, 'src', 'server', 'common', 'objects', elementToEdit, `${elementToEdit}.input.ts`); const inputFile = project.addSourceFileAtPath(inputPath); const inputDeclaration = inputFile.getClasses()[0]; const inputProperties = inputDeclaration .getMembers() .filter((m) => m.getKind() === ts_morph_1.SyntaxKind.PropertyDeclaration); // Prepare create input file const creatInputPath = objectOrModule === 'Module' ? (0, path_1.join)(path, 'src', 'server', 'modules', elementToEdit, 'inputs', `${elementToEdit}-create.input.ts`) : (0, path_1.join)(path, 'src', 'server', 'common', 'objects', elementToEdit, `${elementToEdit}-create.input.ts`); const createInputFile = project.addSourceFileAtPath(creatInputPath); const createInputDeclaration = createInputFile.getClasses()[0]; const createInputProperties = createInputDeclaration .getMembers() .filter((m) => m.getKind() === ts_morph_1.SyntaxKind.PropertyDeclaration); // Add props for (const prop of Object.keys(props).reverse()) { const propObj = props[prop]; if (modelProperties.some((p) => p.getName() === propObj.name)) { info(''); info(`Property ${propObj.name} already exists`); // Remove the reference for this property from the list const refIndex = referencesToAdd.findIndex((item) => item.property === propObj.name); if (refIndex !== -1) { referencesToAdd.splice(refIndex, 1); } // Remove the object for this property from the list const objIndex = objectsToAdd.findIndex((item) => item.property === propObj.name); if (objIndex !== -1) { objectsToAdd.splice(objIndex, 1); } // Go on continue; } const description = `'${pascalCase(propObj.name)} of ${pascalCase(elementToEdit)}'`; // Use utility function to determine TypeScript type for Model const tsType = server.getModelClassType(propObj); // Build @UnifiedField options; types vary and can't go in standardDeclaration function constructUnifiedFieldOptions(type) { // Use utility functions from server helper const enumConfig = server.getEnumConfig(propObj); // Determine field type based on context let fieldType; if (type === 'model') { fieldType = server.getModelFieldType(propObj); } else { // Input or CreateInput fieldType = server.getInputFieldType(propObj, { create: type === 'create' }); } // Use utility function for type config const typeConfig = server.getTypeConfig(fieldType, propObj.isArray); // Build mongoose configuration for model type let mongooseConfig = ''; if (type === 'model') { if (propObj.type === 'ObjectId' && propObj.reference) { mongooseConfig = `mongoose: ${propObj.isArray ? '[' : ''}{ ref: '${propObj.reference}', type: Schema.Types.ObjectId }${propObj.isArray ? ']' : ''},\n`; } else if (propObj.schema) { mongooseConfig = `mongoose: ${propObj.isArray ? '[' : ''}{ type: ${propObj.schema}Schema }${propObj.isArray ? ']' : ''},\n`; } else if (propObj.enumRef) { mongooseConfig = `mongoose: ${propObj.isArray ? '[' : ''}{ enum: ${propObj.nullable ? `Object.values(${propObj.enumRef}).concat([null])` : propObj.enumRef}, type: String }${propObj.isArray ? ']' : ''},\n`; } else if (propObj.type === 'Json') { mongooseConfig = `mongoose: ${propObj.isArray ? '[' : ''}{ type: Object }${propObj.isArray ? ']' : ''},\n`; } else { mongooseConfig = 'mongoose: true,\n'; } } switch (type) { case 'create': return `{ description: ${description},${propObj.nullable ? '\nisOptional: true,' : '\nisOptional: false,'} roles: RoleEnum.S_EVERYONE, ${enumConfig}${typeConfig} }`; case 'input': return `{ description: ${description}, isOptional: true, roles: RoleEnum.S_EVERYONE, ${enumConfig}${typeConfig} }`; case 'model': return `{ description: ${description},${propObj.nullable ? '\nisOptional: true,' : '\nisOptional: false,'} ${mongooseConfig}roles: RoleEnum.S_EVERYONE, ${enumConfig}${typeConfig} }`; } } // Only use = undefined when useDefineForClassFieldsActivated is false or override keyword is set const useDefineForClassFields = server.useDefineForClassFieldsActivated(); const standardDeclaration = { decorators: [], hasQuestionToken: propObj.nullable, initializer: useDefineForClassFields ? undefined : 'undefined', name: propObj.name, }; // Patch model const newModelProperty = structuredClone(standardDeclaration); newModelProperty.decorators.push({ arguments: [constructUnifiedFieldOptions('model')], name: 'UnifiedField' }); newModelProperty.type = `${tsType}${propObj.isArray ? '[]' : ''}`; let insertedModelProp; if (modelProperties.length > 0) { const lastModelProperty = modelProperties[modelProperties.length - 1]; insertedModelProp = modelDeclaration.insertProperty(lastModelProperty.getChildIndex() + 1, newModelProperty); } else { insertedModelProp = modelDeclaration.addProperty(newModelProperty); } insertedModelProp.prependWhitespace('\n'); insertedModelProp.appendWhitespace('\n'); // Patch input const newInputProperty = structuredClone(standardDeclaration); newInputProperty.decorators.push({ arguments: [constructUnifiedFieldOptions('input')], name: 'UnifiedField' }); // Use utility function to determine TypeScript type for Input const inputTsType = server.getInputClassType(propObj, { create: false }); newInputProperty.type = `${inputTsType}${propObj.isArray ? '[]' : ''}`; let insertedInputProp; if (inputProperties.length > 0) { const lastInputProperty = inputProperties[inputProperties.length - 1]; insertedInputProp = inputDeclaration.insertProperty(lastInputProperty.getChildIndex() + 1, newInputProperty); } else { insertedInputProp = inputDeclaration.addProperty(newInputProperty); } insertedInputProp.prependWhitespace('\n'); insertedInputProp.appendWhitespace('\n'); // Patch create input const newCreateInputProperty = structuredClone(standardDeclaration); // Use override (not declare) when useDefineForClassFieldsActivated is true if (useDefineForClassFields) { newCreateInputProperty.hasOverrideKeyword = true; newCreateInputProperty.initializer = 'undefined'; // Override requires = undefined } newCreateInputProperty.decorators.push({ arguments: [constructUnifiedFieldOptions('create')], name: 'UnifiedField', }); // Use utility function to determine TypeScript type for CreateInput const createTsType = server.getInputClassType(propObj, { create: true }); newCreateInputProperty.type = `${createTsType}${propObj.isArray ? '[]' : ''}`; let insertedCreateInputProp; if (createInputProperties.length > 0) { const lastCreateInputProperty = createInputProperties[createInputProperties.length - 1]; insertedCreateInputProp = createInputDeclaration.insertProperty(lastCreateInputProperty.getChildIndex() + 1, newCreateInputProperty); } else { insertedCreateInputProp = createInputDeclaration.addProperty(newCreateInputProperty); } insertedCreateInputProp.prependWhitespace('\n'); insertedCreateInputProp.appendWhitespace('\n'); } project.manipulationSettings.set({ indentationText: ts_morph_1.IndentationText.TwoSpaces, }); // Update map method with mapClasses for non-native properties const standardTypes = ['boolean', 'string', 'number', 'Date']; const mapMethod = modelDeclaration.getMethod('map'); if (mapMethod) { // Collect all properties that need mapClasses (non-native types) const allModelProps = modelDeclaration .getMembers() .filter((m) => m.getKind() === ts_morph_1.SyntaxKind.PropertyDeclaration); const mappings = {}; for (const prop of allModelProps) { const propName = prop.getName(); const propType = prop.getType().getText(); // Skip if it's a standard type if (standardTypes.some((t) => propType.includes(t))) { continue; } // Skip ObjectId, enum, and JSON types if (propType.includes('string') || propType.includes('ObjectId') || propType.includes('Record<string, unknown>')) { continue; } // Check if this property was in our newly added props and should be mapped const newProp = props[propName]; if (newProp) { const type = newProp.type; const reference = ((_a = newProp.reference) === null || _a === void 0 ? void 0 : _a.trim()) ? pascalCase(newProp.reference.trim()) : ''; const schema = ((_b = newProp.schema) === null || _b === void 0 ? void 0 : _b.trim()) ? pascalCase(newProp.schema.trim()) : ''; if (reference) { mappings[propName] = reference; } else if (schema) { mappings[propName] = schema; } else if (!standardTypes.includes(type) && type !== 'ObjectId' && type !== 'Json') { mappings[propName] = pascalCase(type); } } } // Update the map method's return statement const returnStatement = mapMethod.getStatements().find((s) => s.getKind() === ts_morph_1.SyntaxKind.ReturnStatement); if (returnStatement && Object.keys(mappings).length > 0) { const currentReturn = returnStatement.getText(); // Check if already using mapClasses if (currentReturn.includes('mapClasses')) { // Parse existing mapClasses call to merge with new mappings const match = currentReturn.match(/mapClasses\(input,\s*\{([^}]*)\}/); if (match) { const existingMappings = match[1].trim(); const existingPairs = existingMappings ? existingMappings.split(',').map((p) => p.trim()) : []; // Merge with new mappings const allMappings = {}; for (const pair of existingPairs) { const [key, value] = pair.split(':').map((s) => s.trim()); if (key && value) { allMappings[key] = value; } } // Add new mappings for (const [key, value] of Object.entries(mappings)) { allMappings[key] = value; } // Generate new mapClasses call const mappingPairs = Object.entries(allMappings).map(([k, v]) => `${k}: ${v}`); returnStatement.replaceWithText(`return mapClasses(input, { ${mappingPairs.join(', ')} }, this);`); } } else if (currentReturn.includes('return this')) { // Replace "return this" with mapClasses call const mappingPairs = Object.entries(mappings).map(([k, v]) => `${k}: ${v}`); returnStatement.replaceWithText(`return mapClasses(input, { ${mappingPairs.join(', ')} }, this);`); } // Ensure mapClasses is imported. The import specifier differs by // framework-consumption mode — in npm projects it's // '@lenne.tech/nest-server'; in vendored projects it's a relative // path to src/core whose depth depends on the model file location. // We search for BOTH forms so this works regardless of how the file // was originally generated. const vendoredSpec = (0, framework_detection_1.isVendoredProject)(path) ? (0, framework_detection_1.getFrameworkImportSpecifier)(path, modelPath) : null; let existingImports = moduleFile.getImportDeclaration('@lenne.tech/nest-server'); if (!existingImports && vendoredSpec) { existingImports = moduleFile.getImportDeclaration(vendoredSpec); } if (!existingImports) { // Final fallback: scan all imports and match any that resolves to // the framework path (covers hand-edited files or unusual depths). const allImports = moduleFile.getImportDeclarations(); existingImports = allImports.find((imp) => { const spec = imp.getModuleSpecifierValue(); if (spec === '@lenne.tech/nest-server') return true; if (!vendoredSpec) return false; // Accept any relative path ending with '/core' or '../core' — // covers variations across file depths. return /(^|\/)core(\/.*)?$/.test(spec) && spec.startsWith('.'); }); } if (existingImports) { const namedImports = existingImports.getNamedImports(); const hasMapClasses = namedImports.some((ni) => ni.getName() === 'mapClasses'); if (!hasMapClasses) { existingImports.addNamedImport('mapClasses'); } } } } // Format files moduleFile.formatText(); inputFile.formatText(); createInputFile.formatText(); // Save files yield moduleFile.save(); yield inputFile.save(); yield createInputFile.save(); updateSpinner.succeed('All files updated successfully.'); // Print structured summary const summaryLines = []; summaryLines.push('--- Summary ---'); summaryLines.push(`Added properties to: ${pascalCase(elementToEdit)} (${objectOrModule})`); summaryLines.push(''); summaryLines.push('Modified files:'); if (objectOrModule === 'Module') { summaryLines.push(` ~ src/server/modules/${elementToEdit}/${elementToEdit}.model.ts`); summaryLines.push(` ~ src/server/modules/${elementToEdit}/inputs/${elementToEdit}.input.ts`); summaryLines.push(` ~ src/server/modules/${elementToEdit}/inputs/${elementToEdit}-create.input.ts`); } else { summaryLines.push(` ~ src/server/common/objects/${elementToEdit}/${elementToEdit}.object.ts`); summaryLines.push(` ~ src/server/common/objects/${elementToEdit}/${elementToEdit}.input.ts`); summaryLines.push(` ~ src/server/common/objects/${elementToEdit}/${elementToEdit}-create.input.ts`); } summaryLines.push(''); const addedPropKeys = Object.keys(props); if (addedPropKeys.length > 0) { summaryLines.push('Properties added:'); for (const key of addedPropKeys) { const p = props[key]; const parts = []; if (p.isArray) parts.push('array'); if (p.nullable) parts.push('nullable'); const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : ''; summaryLines.push(` - ${p.name}${p.nullable ? '?' : ''}: ${p.type}${p.isArray ? '[]' : ''}${suffix}`); } summaryLines.push(''); } summaryLines.push('Next steps:'); summaryLines.push(' 1. Add descriptions to new @UnifiedField decorators'); summaryLines.push(' 2. Update tests for new properties'); summaryLines.push('---'); info(summaryLines.join('\n')); info(''); // Add additional references if (referencesToAdd.length > 0) { divider(); const nextRef = referencesToAdd.shift().reference; yield module_1.default.run(toolbox, { currentItem: nextRef, objectsToAdd, preventExitProcess: true, referencesToAdd }); } // Add additional objects if (objectsToAdd.length > 0) { divider(); const nextObj = objectsToAdd.shift().object; yield object_1.default.run(toolbox, { currentItem: nextObj, objectsToAdd, preventExitProcess: true, referencesToAdd }); } // Lint fix with priority: CLI > config > global > default (false) const skipLint = config.getSkipLint({ cliValue: cliSkipLint, commandConfig: (_d = (_c = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _c === void 0 ? void 0 : _c.server) === null || _d === void 0 ? void 0 : _d.addProp, config: ltConfig, }); if (!skipLint) { if (yield confirm('Run lint fix?', true)) { yield system.run(toolbox.pm.run('lint:fix')); } } // We're done, so show what to do next if (!preventExitProcess) { if (refsSet || schemaSet) { success('HINT: References / Schemata have been added, so it is necessary to add the corresponding imports!'); } if (!toolbox.parameters.options.fromGluegunMenu) { process.exit(); } } return `properties updated for ${elementToEdit}`; }), }; exports.default = NewCommand;