Browse Source

feat(cli): Implement "add entity" command

Michael Bromley 1 year ago
parent
commit
ad87531638
33 changed files with 423 additions and 163 deletions
  1. 38 0
      packages/cli/build.ts
  2. 1 1
      packages/cli/package.json
  3. 1 11
      packages/cli/src/cli.ts
  4. 12 4
      packages/cli/src/commands/add/add.ts
  5. 48 0
      packages/cli/src/commands/add/entity/add-entity.ts
  6. 51 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.spec.ts
  7. 39 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.ts
  8. 18 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/existing-entity-prop.expected
  9. 17 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/existing-entity-prop.fixture.ts
  10. 18 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/no-entity-prop.expected
  11. 16 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/no-entity-prop.fixture.ts
  12. 17 0
      packages/cli/src/commands/add/entity/scaffold/entity.template.ts
  13. 8 24
      packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts
  14. 3 3
      packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.spec.ts
  15. 1 1
      packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts
  16. 3 3
      packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.spec.ts
  17. 1 1
      packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.ts
  18. 6 24
      packages/cli/src/commands/new/plugin/new-plugin.ts
  19. 3 3
      packages/cli/src/commands/new/plugin/scaffold/api/admin.resolver.ts
  20. 2 2
      packages/cli/src/commands/new/plugin/scaffold/api/api-extensions.ts
  21. 3 3
      packages/cli/src/commands/new/plugin/scaffold/api/shop.resolver.ts
  22. 2 2
      packages/cli/src/commands/new/plugin/scaffold/constants.ts
  23. 0 18
      packages/cli/src/commands/new/plugin/scaffold/entities/entity.ts
  24. 2 2
      packages/cli/src/commands/new/plugin/scaffold/plugin.ts
  25. 3 3
      packages/cli/src/commands/new/plugin/scaffold/services/service.ts
  26. 2 2
      packages/cli/src/commands/new/plugin/scaffold/types.ts
  27. 1 1
      packages/cli/src/commands/new/plugin/types.ts
  28. 2 1
      packages/cli/src/constants.ts
  29. 43 0
      packages/cli/src/shared/shared-prompts.ts
  30. 21 0
      packages/cli/src/shared/shared-scaffold/entity.ts
  31. 40 28
      packages/cli/src/utilities/ast-utils.ts
  32. 0 23
      packages/cli/src/utilities/logger.ts
  33. 1 3
      packages/cli/src/utilities/package-utils.ts

+ 38 - 0
packages/cli/build.ts

@@ -0,0 +1,38 @@
+import fs from 'fs-extra';
+import path from 'path';
+
+// This build script copies all .template.ts files from the "src" directory to the "dist" directory.
+// This is necessary because the .template.ts files are used to generate the actual source files.
+const templateFiles = findFilesWithSuffix(path.join(__dirname, 'src'), '.template.ts');
+for (const file of templateFiles) {
+    // copy to the equivalent path in the "dist" rather than "src" directory
+    const relativePath = path.relative(path.join(__dirname, 'src'), file);
+    const distPath = path.join(__dirname, 'dist', relativePath);
+    fs.ensureDirSync(path.dirname(distPath));
+    fs.copyFileSync(file, distPath);
+}
+
+function findFilesWithSuffix(directory: string, suffix: string): string[] {
+    const files: string[] = [];
+
+    function traverseDirectory(dir: string) {
+        const dirContents = fs.readdirSync(dir);
+
+        dirContents.forEach(item => {
+            const itemPath = path.join(dir, item);
+            const stats = fs.statSync(itemPath);
+
+            if (stats.isDirectory()) {
+                traverseDirectory(itemPath);
+            } else {
+                if (item.endsWith(suffix)) {
+                    files.push(itemPath);
+                }
+            }
+        });
+    }
+
+    traverseDirectory(directory);
+
+    return files;
+}

+ 1 - 1
packages/cli/package.json

@@ -19,7 +19,7 @@
     "license": "MIT",
     "type": "commonjs",
     "scripts": {
-        "build": "rimraf dist && tsc -p ./tsconfig.cli.json",
+        "build": "rimraf dist && tsc -p ./tsconfig.cli.json && ts-node ./build.ts",
         "watch": "tsc -p ./tsconfig.cli.json --watch",
         "ci": "yarn build",
         "test": "vitest --config ./vitest.config.ts --run"

+ 1 - 11
packages/cli/src/cli.ts

@@ -4,7 +4,6 @@ import { Command } from 'commander';
 
 import { registerAddCommand } from './commands/add/add';
 import { registerNewCommand } from './commands/new/new';
-import { Logger } from './utilities/logger';
 
 const program = new Command();
 
@@ -13,17 +12,8 @@ const version = require('../package.json').version;
 
 program
     .version(version)
-    .description('The Vendure CLI')
-    .option(
-        '--log-level <logLevel>',
-        "Log level, either 'silent', 'info', or 'verbose'. Default: 'info'",
-        'info',
-    );
+    .description('The Vendure CLI');
 
-const options = program.opts();
-if (options.logLevel) {
-    Logger.setLogLevel(options.logLevel);
-}
 registerNewCommand(program);
 registerAddCommand(program);
 

+ 12 - 4
packages/cli/src/commands/add/add.ts

@@ -1,6 +1,7 @@
-import { cancel, isCancel, select } from '@clack/prompts';
+import { cancel, isCancel, log, select } from '@clack/prompts';
 import { Command } from 'commander';
 
+import { addEntity } from './entity/add-entity';
 import { addUiExtensions } from './ui-extensions/add-ui-extensions';
 
 const cancelledMessage = 'Add feature cancelled.';
@@ -14,15 +15,22 @@ export function registerAddCommand(program: Command) {
                 message: 'Which feature would you like to add?',
                 options: [
                     { value: 'uiExtensions', label: 'Set up Admin UI extensions' },
-                    { value: 'other', label: 'Other' },
+                    { value: 'entity', label: 'Add a new entity to a plugin' },
                 ],
             });
             if (isCancel(featureType)) {
                 cancel(cancelledMessage);
                 process.exit(0);
             }
-            if (featureType === 'uiExtensions') {
-                await addUiExtensions();
+            try {
+                if (featureType === 'uiExtensions') {
+                    await addUiExtensions();
+                }
+                if (featureType === 'entity') {
+                    await addEntity();
+                }
+            } catch (e: any) {
+                log.error(e.message as string);
             }
             process.exit(0);
         });

+ 48 - 0
packages/cli/src/commands/add/entity/add-entity.ts

@@ -0,0 +1,48 @@
+import { outro, spinner } from '@clack/prompts';
+import { paramCase } from 'change-case';
+import path from 'path';
+
+import { getCustomEntityName, selectPluginClass } from '../../../shared/shared-prompts';
+import { renderEntity } from '../../../shared/shared-scaffold/entity';
+import { createSourceFileFromTemplate, getTsMorphProject } from '../../../utilities/ast-utils';
+import { Scaffolder } from '../../../utilities/scaffolder';
+
+import { addEntityToPlugin } from './codemods/add-entity-to-plugin/add-entity-to-plugin';
+
+const cancelledMessage = 'Add entity cancelled';
+
+export interface AddEntityTemplateContext {
+    entity: {
+        className: string;
+        fileName: string;
+    };
+}
+
+export async function addEntity() {
+    const projectSpinner = spinner();
+    projectSpinner.start('Analyzing project...');
+    await new Promise(resolve => setTimeout(resolve, 100));
+    const project = getTsMorphProject();
+    projectSpinner.stop('Project analyzed');
+
+    const pluginClass = await selectPluginClass(project, cancelledMessage);
+    const customEntityName = await getCustomEntityName(cancelledMessage);
+    const context: AddEntityTemplateContext = {
+        entity: {
+            className: customEntityName,
+            fileName: paramCase(customEntityName) + '.entity',
+        },
+    };
+
+    const entitiesDir = path.join(pluginClass.getSourceFile().getDirectory().getPath(), 'entities');
+    const entityTemplatePath = path.join(__dirname, 'scaffold/entity.template.ts');
+    const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
+    entityFile.move(path.join(entitiesDir, `${context.entity.fileName}.ts`));
+    entityFile.getClasses()[0].rename(`${context.entity.className}CustomFields`);
+    entityFile.getClasses()[1].rename(context.entity.className);
+
+    addEntityToPlugin(pluginClass, entityFile);
+
+    project.saveSync();
+    outro('✅  Done!');
+}

+ 51 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.spec.ts

@@ -0,0 +1,51 @@
+import fs from 'fs-extra';
+import path from 'path';
+import { Project } from 'ts-morph';
+import { describe, expect, it } from 'vitest';
+
+import { defaultManipulationSettings } from '../../../../../constants';
+import { createSourceFileFromTemplate, getPluginClasses } from '../../../../../utilities/ast-utils';
+
+import { addEntityToPlugin } from './add-entity-to-plugin';
+
+describe('addEntityToPlugin', () => {
+    it('creates entity prop and imports', () => {
+        const project = new Project({
+            manipulationSettings: defaultManipulationSettings,
+        });
+        project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-entity-prop.fixture.ts'));
+        const pluginClasses = getPluginClasses(project);
+        expect(pluginClasses.length).toBe(1);
+        const entityTemplatePath = path.join(__dirname, '../../scaffold/entity.template.ts');
+        const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
+        entityFile.move(path.join(__dirname, 'fixtures/entity.ts'));
+        addEntityToPlugin(pluginClasses[0], entityFile);
+
+        const result = pluginClasses[0].getSourceFile().getText();
+        const expected = fs.readFileSync(
+            path.join(__dirname, 'fixtures', 'no-entity-prop.expected'),
+            'utf-8',
+        );
+        expect(result).toBe(expected);
+    });
+
+    it('adds to existing entity prop and imports', () => {
+        const project = new Project({
+            manipulationSettings: defaultManipulationSettings,
+        });
+        project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'existing-entity-prop.fixture.ts'));
+        const pluginClasses = getPluginClasses(project);
+        expect(pluginClasses.length).toBe(1);
+        const entityTemplatePath = path.join(__dirname, '../../scaffold/entity.template.ts');
+        const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
+        entityFile.move(path.join(__dirname, 'fixtures/entity.ts'));
+        addEntityToPlugin(pluginClasses[0], entityFile);
+
+        const result = pluginClasses[0].getSourceFile().getText();
+        const expected = fs.readFileSync(
+            path.join(__dirname, 'fixtures', 'existing-entity-prop.expected'),
+            'utf-8',
+        );
+        expect(result).toBe(expected);
+    });
+});

+ 39 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.ts

@@ -0,0 +1,39 @@
+import { ClassDeclaration, Node, SourceFile, SyntaxKind } from 'ts-morph';
+
+import { addImportsToFile } from '../../../../../utilities/ast-utils';
+import { AddEntityTemplateContext } from '../../add-entity';
+
+export function addEntityToPlugin(pluginClass: ClassDeclaration, entitySourceFile: SourceFile) {
+    const pluginDecorator = pluginClass.getDecorator('VendurePlugin');
+    if (!pluginDecorator) {
+        throw new Error('Could not find VendurePlugin decorator');
+    }
+    const pluginOptions = pluginDecorator.getArguments()[0];
+    if (!pluginOptions) {
+        throw new Error('Could not find VendurePlugin options');
+    }
+    const entityClass = entitySourceFile.getClasses().find(c => !c.getName()?.includes('CustomFields'));
+    if (!entityClass) {
+        throw new Error('Could not find entity class');
+    }
+    const entityClassName = entityClass.getName() as string;
+    if (Node.isObjectLiteralExpression(pluginOptions)) {
+        const entityProperty = pluginOptions.getProperty('entities');
+        if (entityProperty) {
+            const entitiesArray = entityProperty.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression);
+            if (entitiesArray) {
+                entitiesArray.addElement(entityClassName);
+            }
+        } else {
+            pluginOptions.addPropertyAssignment({
+                name: 'entities',
+                initializer: `[${entityClassName}]`,
+            });
+        }
+    }
+
+    addImportsToFile(pluginClass.getSourceFile(), {
+        moduleSpecifier: entityClass.getSourceFile(),
+        namedImports: [entityClassName],
+    });
+}

+ 18 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/existing-entity-prop.expected

@@ -0,0 +1,18 @@
+import { PluginCommonModule, Type, VendurePlugin, Product } from '@vendure/core';
+import { ScaffoldEntity } from './entity';
+
+type PluginInitOptions = any;
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [Product, ScaffoldEntity],
+    compatibility: '^2.0.0',
+})
+export class TestOnePlugin {
+    static options: PluginInitOptions;
+
+    static init(options: PluginInitOptions): Type<TestOnePlugin> {
+        this.options = options;
+        return TestOnePlugin;
+    }
+}

+ 17 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/existing-entity-prop.fixture.ts

@@ -0,0 +1,17 @@
+import { PluginCommonModule, Type, VendurePlugin, Product } from '@vendure/core';
+
+type PluginInitOptions = any;
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [Product],
+    compatibility: '^2.0.0',
+})
+export class TestOnePlugin {
+    static options: PluginInitOptions;
+
+    static init(options: PluginInitOptions): Type<TestOnePlugin> {
+        this.options = options;
+        return TestOnePlugin;
+    }
+}

+ 18 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/no-entity-prop.expected

@@ -0,0 +1,18 @@
+import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
+import { ScaffoldEntity } from './entity';
+
+type PluginInitOptions = any;
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    compatibility: '^2.0.0',
+    entities: [ScaffoldEntity],
+})
+export class TestOnePlugin {
+    static options: PluginInitOptions;
+
+    static init(options: PluginInitOptions): Type<TestOnePlugin> {
+        this.options = options;
+        return TestOnePlugin;
+    }
+}

+ 16 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/fixtures/no-entity-prop.fixture.ts

@@ -0,0 +1,16 @@
+import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
+
+type PluginInitOptions = any;
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    compatibility: '^2.0.0',
+})
+export class TestOnePlugin {
+    static options: PluginInitOptions;
+
+    static init(options: PluginInitOptions): Type<TestOnePlugin> {
+        this.options = options;
+        return TestOnePlugin;
+    }
+}

+ 17 - 0
packages/cli/src/commands/add/entity/scaffold/entity.template.ts

@@ -0,0 +1,17 @@
+import { VendureEntity, DeepPartial, HasCustomFields } from '@vendure/core';
+import { Entity, Column } from 'typeorm';
+
+export class ScaffoldEntityCustomFields {}
+
+@Entity()
+export class ScaffoldEntity extends VendureEntity implements HasCustomFields {
+    constructor(input?: DeepPartial<ScaffoldEntity>) {
+        super(input);
+    }
+
+    @Column()
+    name: string;
+
+    @Column(type => ScaffoldEntityCustomFields)
+    customFields: ScaffoldEntityCustomFields;
+}

+ 8 - 24
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -1,11 +1,11 @@
 import { note, outro, spinner, log } from '@clack/prompts';
 import path from 'path';
 import { ClassDeclaration } from 'ts-morph';
-import { Logger } from '../../../utilities/logger';
-import { determineVendureVersion, installRequiredPackages } from '../../../utilities/package-utils';
 
+import { selectPluginClass } from '../../../shared/shared-prompts';
+import { getRelativeImportPath, getTsMorphProject, getVendureConfig } from '../../../utilities/ast-utils';
+import { determineVendureVersion, installRequiredPackages } from '../../../utilities/package-utils';
 import { Scaffolder } from '../../../utilities/scaffolder';
-import { getTsMorphProject, getVendureConfig, selectPluginClass } from '../../../utilities/utils';
 
 import { addUiExtensionStaticProp } from './codemods/add-ui-extension-static-prop/add-ui-extension-static-prop';
 import { updateAdminUiPluginInit } from './codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init';
@@ -40,12 +40,7 @@ export async function addUiExtensions() {
             },
         ]);
     } catch (e: any) {
-        log.error(
-            `Failed to install dependencies: ${
-                e.message as string
-            }. Run with --log-level=verbose to see more details.`,
-        );
-        Logger.verbose(e.stack);
+        log.error(`Failed to install dependencies: ${e.message as string}.`);
     }
     installSpinner.stop('Dependencies installed');
 
@@ -66,12 +61,10 @@ export async function addUiExtensions() {
         );
     } else {
         const pluginClassName = pluginClass.getName() as string;
-        const pluginPath = convertPathToRelativeImport(
-            path.relative(
-                vendureConfig.getSourceFile().getDirectory().getPath(),
-                pluginClass.getSourceFile().getFilePath(),
-            ),
-        );
+        const pluginPath = getRelativeImportPath({
+            to: vendureConfig.getSourceFile(),
+            from: pluginClass.getSourceFile(),
+        });
         const updated = updateAdminUiPluginInit(vendureConfig, { pluginClassName, pluginPath });
         if (updated) {
             log.success('Updated VendureConfig file');
@@ -98,12 +91,3 @@ function pluginAlreadyHasUiExtensionProp(pluginClass: ClassDeclaration) {
         return true;
     }
 }
-
-function convertPathToRelativeImport(filePath: string) {
-    // Normalize the path separators
-    const normalizedPath = filePath.replace(/\\/g, '/');
-
-    // Remove the file extension
-    const parsedPath = path.parse(normalizedPath);
-    return `./${parsedPath.dir}/${parsedPath.name}`;
-}

+ 3 - 3
packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.spec.ts

@@ -1,10 +1,10 @@
 import fs from 'fs-extra';
 import path from 'path';
-import { Project, QuoteKind } from 'ts-morph';
+import { Project } from 'ts-morph';
 import { describe, expect, it } from 'vitest';
-import { defaultManipulationSettings } from '../../../../../constants';
 
-import { getPluginClasses } from '../../../../../utilities/utils';
+import { defaultManipulationSettings } from '../../../../../constants';
+import { getPluginClasses } from '../../../../../utilities/ast-utils';
 
 import { addUiExtensionStaticProp } from './add-ui-extension-static-prop';
 

+ 1 - 1
packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts

@@ -1,6 +1,6 @@
 import { ClassDeclaration } from 'ts-morph';
 
-import { addImportsToFile, kebabize } from '../../../../../utilities/utils';
+import { addImportsToFile, kebabize } from '../../../../../utilities/ast-utils';
 
 /**
  * @description

+ 3 - 3
packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.spec.ts

@@ -1,10 +1,10 @@
 import fs from 'fs-extra';
 import path from 'path';
-import { Project, QuoteKind } from 'ts-morph';
+import { Project } from 'ts-morph';
 import { describe, expect, it } from 'vitest';
-import { defaultManipulationSettings } from '../../../../../constants';
 
-import { getVendureConfig } from '../../../../../utilities/utils';
+import { defaultManipulationSettings } from '../../../../../constants';
+import { getVendureConfig } from '../../../../../utilities/ast-utils';
 
 import { updateAdminUiPluginInit } from './update-admin-ui-plugin-init';
 

+ 1 - 1
packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.ts

@@ -1,6 +1,6 @@
 import { Node, ObjectLiteralExpression, StructureKind, SyntaxKind } from 'ts-morph';
 
-import { addImportsToFile } from '../../../../../utilities/utils';
+import { addImportsToFile } from '../../../../../utilities/ast-utils';
 
 export function updateAdminUiPluginInit(
     vendureConfig: ObjectLiteralExpression,

+ 6 - 24
packages/cli/src/commands/new/plugin/new-plugin.ts

@@ -3,17 +3,18 @@ import { camelCase, constantCase, paramCase, pascalCase } from 'change-case';
 import * as fs from 'fs-extra';
 import path from 'path';
 
+import { getCustomEntityName } from '../../../shared/shared-prompts';
+import { renderEntity } from '../../../shared/shared-scaffold/entity';
 import { Scaffolder } from '../../../utilities/scaffolder';
 
 import { renderAdminResolver, renderAdminResolverWithEntity } from './scaffold/api/admin.resolver';
 import { renderApiExtensions } from './scaffold/api/api-extensions';
 import { renderShopResolver, renderShopResolverWithEntity } from './scaffold/api/shop.resolver';
 import { renderConstants } from './scaffold/constants';
-import { renderEntity } from './scaffold/entities/entity';
 import { renderPlugin } from './scaffold/plugin';
 import { renderService, renderServiceWithEntity } from './scaffold/services/service';
 import { renderTypes } from './scaffold/types';
-import { GeneratePluginOptions, TemplateContext } from './types';
+import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
 
 const cancelledMessage = 'Plugin setup cancelled.';
 
@@ -51,26 +52,7 @@ export async function newPlugin() {
         options.withApiExtensions = features.includes('apiExtensions');
     }
     if (options.withCustomEntity) {
-        const entityName = await text({
-            message: 'What is the name of the custom entity?',
-            initialValue: '',
-            placeholder: '',
-            validate: input => {
-                if (!input) {
-                    return 'The custom entity name cannot be empty';
-                }
-                const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/;
-                if (!pascalCaseRegex.test(input)) {
-                    return 'The custom entity name must be in PascalCase, e.g. "ProductReview"';
-                }
-            },
-        });
-        if (isCancel(entityName)) {
-            cancel(cancelledMessage);
-            process.exit(0);
-        } else {
-            options.customEntityName = pascalCase(entityName);
-        }
+        options.customEntityName = await getCustomEntityName(cancelledMessage);
     }
     const pluginDir = getPluginDirName(options.name);
     const confirmation = await text({
@@ -96,7 +78,7 @@ export async function newPlugin() {
 export function generatePlugin(options: GeneratePluginOptions) {
     const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
     const normalizedName = nameWithoutPlugin + '-plugin';
-    const templateContext: TemplateContext = {
+    const templateContext: NewPluginTemplateContext = {
         ...options,
         pluginName: pascalCase(normalizedName),
         pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS',
@@ -112,7 +94,7 @@ export function generatePlugin(options: GeneratePluginOptions) {
         },
     };
 
-    const scaffolder = new Scaffolder<TemplateContext>();
+    const scaffolder = new Scaffolder<NewPluginTemplateContext>();
     scaffolder.addFile(renderPlugin, paramCase(nameWithoutPlugin) + '.plugin.ts');
     scaffolder.addFile(renderTypes, 'types.ts');
     scaffolder.addFile(renderConstants, 'constants.ts');

+ 3 - 3
packages/cli/src/commands/new/plugin/scaffold/api/admin.resolver.ts

@@ -1,6 +1,6 @@
-import { TemplateContext } from '../../types';
+import { NewPluginTemplateContext } from '../../types';
 
-export function renderAdminResolverWithEntity(context: TemplateContext) {
+export function renderAdminResolverWithEntity(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Args, Resolver, Mutation } from '@nestjs/graphql';
 import { Allow, Ctx, RequestContext, Transaction, Permission } from '@vendure/core';
@@ -37,7 +37,7 @@ export class AdminResolver {
 }`;
 }
 
-export function renderAdminResolver(context: TemplateContext) {
+export function renderAdminResolver(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Args, Query, Mutation, Resolver } from '@nestjs/graphql';
 import { Ctx, PaginatedList, RequestContext, Transaction } from '@vendure/core';

+ 2 - 2
packages/cli/src/commands/new/plugin/scaffold/api/api-extensions.ts

@@ -1,8 +1,8 @@
 import { constantCase, pascalCase } from 'change-case';
 
-import { TemplateContext } from '../../types';
+import { NewPluginTemplateContext } from '../../types';
 
-export function renderApiExtensions(context: TemplateContext) {
+export function renderApiExtensions(context: NewPluginTemplateContext) {
     if (!context.withApiExtensions) {
         return '';
     }

+ 3 - 3
packages/cli/src/commands/new/plugin/scaffold/api/shop.resolver.ts

@@ -1,6 +1,6 @@
-import { TemplateContext } from '../../types';
+import { NewPluginTemplateContext } from '../../types';
 
-export function renderShopResolverWithEntity(context: TemplateContext) {
+export function renderShopResolverWithEntity(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Args, Query, Resolver } from '@nestjs/graphql';
 import { Ctx, PaginatedList, RequestContext, ListQueryOptions } from '@vendure/core';
@@ -28,7 +28,7 @@ export class ShopResolver {
 }`;
 }
 
-export function renderShopResolver(context: TemplateContext) {
+export function renderShopResolver(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Args, Query, Mutation, Resolver } from '@nestjs/graphql';
 import { Ctx, PaginatedList, RequestContext, Transaction } from '@vendure/core';

+ 2 - 2
packages/cli/src/commands/new/plugin/scaffold/constants.ts

@@ -1,8 +1,8 @@
 import { constantCase, pascalCase } from 'change-case';
 
-import { TemplateContext } from '../types';
+import { NewPluginTemplateContext } from '../types';
 
-export function renderConstants(context: TemplateContext): string {
+export function renderConstants(context: NewPluginTemplateContext): string {
     const { pluginName, pluginInitOptionsName } = context;
     const optionalImports: string[] = [];
     const optionalStatements: string[] = [];

+ 0 - 18
packages/cli/src/commands/new/plugin/scaffold/entities/entity.ts

@@ -1,18 +0,0 @@
-import { TemplateContext } from '../../types';
-
-export function renderEntity(context: TemplateContext): string {
-    return /* language=TypeScript */ `
-import { Entity, Column } from 'typeorm';
-import { VendureEntity, DeepPartial } from '@vendure/core';
-
-@Entity()
-export class ${context.entity.className} extends VendureEntity {
-    constructor(input?: DeepPartial<${context.entity.className}>) {
-        super(input);
-    }
-
-    @Column()
-    name: string;
-}
-`;
-}

+ 2 - 2
packages/cli/src/commands/new/plugin/scaffold/plugin.ts

@@ -1,8 +1,8 @@
 import { constantCase, pascalCase } from 'change-case';
 
-import { TemplateContext } from '../types';
+import { NewPluginTemplateContext } from '../types';
 
-export function renderPlugin(context: TemplateContext) {
+export function renderPlugin(context: NewPluginTemplateContext) {
     const { pluginName, pluginInitOptionsName } = context;
     const optionalImports: string[] = [];
     const optionalMetadata: string[] = [];

+ 3 - 3
packages/cli/src/commands/new/plugin/scaffold/services/service.ts

@@ -1,8 +1,8 @@
 import { paramCase } from 'change-case';
 
-import { TemplateContext } from '../../types';
+import { NewPluginTemplateContext } from '../../types';
 
-export function renderService(context: TemplateContext) {
+export function renderService(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Inject, Injectable } from '@nestjs/common';
 import { RequestContext, TransactionalConnection } from '@vendure/core';
@@ -27,7 +27,7 @@ export class ${context.service.className} {
 `;
 }
 
-export function renderServiceWithEntity(context: TemplateContext) {
+export function renderServiceWithEntity(context: NewPluginTemplateContext) {
     return /* language=TypeScript */ `
 import { Inject, Injectable } from '@nestjs/common';
 import { ListQueryBuilder, ListQueryOptions, PaginatedList, RequestContext, TransactionalConnection } from '@vendure/core';

+ 2 - 2
packages/cli/src/commands/new/plugin/scaffold/types.ts

@@ -1,6 +1,6 @@
-import { TemplateContext } from '../types';
+import { NewPluginTemplateContext } from '../types';
 
-export function renderTypes(options: TemplateContext): string {
+export function renderTypes(options: NewPluginTemplateContext): string {
     return /* language=TypeScript */ `
 /**
  * The plugin can be configured using the following options:

+ 1 - 1
packages/cli/src/commands/new/plugin/types.ts

@@ -7,7 +7,7 @@ export interface GeneratePluginOptions {
     pluginDir: string;
 }
 
-export type TemplateContext = GeneratePluginOptions & {
+export type NewPluginTemplateContext = GeneratePluginOptions & {
     pluginName: string;
     pluginInitOptionsName: string;
     service: {

+ 2 - 1
packages/cli/src/constants.ts

@@ -1,6 +1,7 @@
-import { ManipulationSettings, QuoteKind } from 'ts-morph';
+import { ManipulationSettings, NewLineKind, QuoteKind } from 'ts-morph';
 
 export const defaultManipulationSettings: Partial<ManipulationSettings> = {
     quoteKind: QuoteKind.Single,
     useTrailingCommas: true,
+    newLineKind: NewLineKind.LineFeed,
 };

+ 43 - 0
packages/cli/src/shared/shared-prompts.ts

@@ -0,0 +1,43 @@
+import { cancel, isCancel, select, text } from '@clack/prompts';
+import { pascalCase } from 'change-case';
+import { ClassDeclaration, Project } from 'ts-morph';
+
+import { getPluginClasses } from '../utilities/ast-utils';
+
+export async function getCustomEntityName(cancelledMessage: string) {
+    const entityName = await text({
+        message: 'What is the name of the custom entity?',
+        initialValue: '',
+        placeholder: '',
+        validate: input => {
+            if (!input) {
+                return 'The custom entity name cannot be empty';
+            }
+            const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/;
+            if (!pascalCaseRegex.test(input)) {
+                return 'The custom entity name must be in PascalCase, e.g. "ProductReview"';
+            }
+        },
+    });
+    if (isCancel(entityName)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    return pascalCase(entityName);
+}
+
+export async function selectPluginClass(project: Project, cancelledMessage: string) {
+    const pluginClasses = getPluginClasses(project);
+    const targetPlugin = await select({
+        message: 'To which plugin would you like to add the feature?',
+        options: pluginClasses.map(c => ({
+            value: c,
+            label: c.getName() as string,
+        })),
+    });
+    if (isCancel(targetPlugin)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+    return targetPlugin as ClassDeclaration;
+}

+ 21 - 0
packages/cli/src/shared/shared-scaffold/entity.ts

@@ -0,0 +1,21 @@
+export function renderEntity<T extends { entity: { className: string } }>(context: T): string {
+    return /* language=TypeScript */ `
+import { Entity, Column } from 'typeorm';
+import { VendureEntity, DeepPartial, HasCustomFields } from '@vendure/core';
+
+export class ${context.entity.className}CustomFields {}
+
+@Entity()
+export class ${context.entity.className} extends VendureEntity implements HasCustomFields {
+    constructor(input?: DeepPartial<${context.entity.className}>) {
+        super(input);
+    }
+
+    @Column()
+    name: string;
+    
+    @Column(type => ${context.entity.className}CustomFields)
+    customFields: ${context.entity.className}CustomFields;
+}
+`;
+}

+ 40 - 28
packages/cli/src/utilities/utils.ts → packages/cli/src/utilities/ast-utils.ts

@@ -1,32 +1,8 @@
-import { cancel, isCancel, select } from '@clack/prompts';
 import fs from 'fs-extra';
 import path from 'node:path';
-import {
-    ClassDeclaration,
-    Node,
-    ObjectLiteralExpression,
-    Project,
-    QuoteKind,
-    SourceFile,
-    VariableDeclaration,
-} from 'ts-morph';
-import { defaultManipulationSettings } from '../constants';
+import { Node, ObjectLiteralExpression, Project, SourceFile, VariableDeclaration } from 'ts-morph';
 
-export async function selectPluginClass(project: Project, cancelledMessage: string) {
-    const pluginClasses = getPluginClasses(project);
-    const targetPlugin = await select({
-        message: 'To which plugin would you like to add the feature?',
-        options: pluginClasses.map(c => ({
-            value: c,
-            label: c.getName() as string,
-        })),
-    });
-    if (isCancel(targetPlugin)) {
-        cancel(cancelledMessage);
-        process.exit(0);
-    }
-    return targetPlugin as ClassDeclaration;
-}
+import { defaultManipulationSettings } from '../constants';
 
 export function getTsMorphProject() {
     const tsConfigPath = path.join(process.cwd(), 'tsconfig.json');
@@ -79,14 +55,20 @@ export function getVendureConfig(project: Project, options: { checkFileName?: bo
 
 export function addImportsToFile(
     sourceFile: SourceFile,
-    options: { moduleSpecifier: string; namedImports?: string[]; namespaceImport?: string; order?: number },
+    options: {
+        moduleSpecifier: string | SourceFile;
+        namedImports?: string[];
+        namespaceImport?: string;
+        order?: number;
+    },
 ) {
     const existingDeclaration = sourceFile.getImportDeclaration(
         declaration => declaration.getModuleSpecifier().getLiteralValue() === options.moduleSpecifier,
     );
+    const moduleSpecifier = getModuleSpecifierString(options.moduleSpecifier, sourceFile);
     if (!existingDeclaration) {
         const importDeclaration = sourceFile.addImportDeclaration({
-            moduleSpecifier: options.moduleSpecifier,
+            moduleSpecifier,
             ...(options.namespaceImport ? { namespaceImport: options.namespaceImport } : {}),
             ...(options.namedImports ? { namedImports: options.namedImports } : {}),
         });
@@ -112,6 +94,27 @@ export function addImportsToFile(
     }
 }
 
+function getModuleSpecifierString(moduleSpecifier: string | SourceFile, sourceFile: SourceFile): string {
+    if (typeof moduleSpecifier === 'string') {
+        return moduleSpecifier;
+    }
+    return getRelativeImportPath({ from: moduleSpecifier, to: sourceFile });
+}
+
+export function getRelativeImportPath(locations: { from: SourceFile; to: SourceFile }): string {
+    return convertPathToRelativeImport(
+        path.relative(
+            locations.to.getSourceFile().getDirectory().getPath(),
+            locations.from.getSourceFile().getFilePath(),
+        ),
+    );
+}
+
+export function createSourceFileFromTemplate(project: Project, templatePath: string) {
+    const template = fs.readFileSync(templatePath, 'utf-8');
+    return project.createSourceFile('temp.ts', template);
+}
+
 export function kebabize(str: string) {
     return str
         .split('')
@@ -122,3 +125,12 @@ export function kebabize(str: string) {
         })
         .join('');
 }
+
+function convertPathToRelativeImport(filePath: string): string {
+    // Normalize the path separators
+    const normalizedPath = filePath.replace(/\\/g, '/');
+
+    // Remove the file extension
+    const parsedPath = path.parse(normalizedPath);
+    return `./${parsedPath.dir}/${parsedPath.name}`.replace(/\/\//g, '/');
+}

+ 0 - 23
packages/cli/src/utilities/logger.ts

@@ -1,23 +0,0 @@
-import pc from 'picocolors';
-
-export class Logger {
-    static logLevel: 'silent' | 'info' | 'verbose' = 'info';
-
-    static setLogLevel(level: 'silent' | 'info' | 'verbose') {
-        this.logLevel = level;
-    }
-
-    static info(message: string) {
-        if (this.logLevel === 'info' || this.logLevel === 'verbose') {
-            // eslint-disable-next-line no-console
-            console.log(pc.blue(message));
-        }
-    }
-
-    static verbose(message: string) {
-        if (this.logLevel === 'verbose') {
-            // eslint-disable-next-line no-console
-            console.log(pc.cyan(message));
-        }
-    }
-}

+ 1 - 3
packages/cli/src/utilities/package-utils.ts

@@ -3,8 +3,6 @@ import spawn from 'cross-spawn';
 import fs from 'fs-extra';
 import path from 'path';
 
-import { Logger } from './logger';
-
 export interface PackageToInstall {
     pkg: string;
     version?: string;
@@ -59,7 +57,7 @@ export async function installPackages(dependencies: string[], isDev: boolean) {
                 args.push('--save-dev');
             }
         }
-        const child = spawn(command, args, { stdio: Logger.logLevel === 'verbose' ? 'inherit' : 'ignore' });
+        const child = spawn(command, args, { stdio: 'ignore' });
         child.on('close', code => {
             if (code !== 0) {
                 const message = 'An error occurred when installing dependencies.';