@lenne.tech/cli
Version:
lenne.Tech CLI: lt
549 lines (548 loc) • 29.1 kB
JavaScript
;
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;