Browse Source

feat(cli): Add API extension command

Michael Bromley 1 year ago
parent
commit
41675a4dfc

+ 7 - 2
packages/cli/src/commands/add/add.ts

@@ -3,6 +3,7 @@ import { Command } from 'commander';
 
 import { CliCommand } from '../../shared/cli-command';
 
+import { addApiExtensionCommand } from './api-extension/add-api-extension';
 import { addCodegenCommand } from './codegen/add-codegen';
 import { addEntityCommand } from './entity/add-entity';
 import { createNewPluginCommand } from './plugin/create-new-plugin';
@@ -16,10 +17,11 @@ export function registerAddCommand(program: Command) {
         .command('add')
         .description('Add a feature to your Vendure project')
         .action(async () => {
-            const addCommands: Array<CliCommand<any, any>> = [
+            const addCommands: Array<CliCommand<any>> = [
                 createNewPluginCommand,
                 addEntityCommand,
                 addServiceCommand,
+                addApiExtensionCommand,
                 addUiExtensionsCommand,
                 addCodegenCommand,
             ];
@@ -39,8 +41,11 @@ export function registerAddCommand(program: Command) {
                 if (!command) {
                     throw new Error(`Could not find command with id "${featureType as string}"`);
                 }
-                await command.run();
+                const { modifiedSourceFiles } = await command.run();
 
+                for (const sourceFile of modifiedSourceFiles) {
+                    sourceFile.organizeImports();
+                }
                 outro('✅ Done!');
             } catch (e: any) {
                 log.error(e.message as string);

+ 449 - 0
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -0,0 +1,449 @@
+import { select, spinner } from '@clack/prompts';
+import path from 'path';
+import {
+    ClassDeclaration,
+    Node,
+    Project,
+    SourceFile,
+    Type,
+    VariableDeclaration,
+    VariableDeclarationKind,
+} from 'ts-morph';
+
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { EntityRef } from '../../../shared/entity-ref';
+import { ServiceRef } from '../../../shared/service-ref';
+import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
+import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
+import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils';
+import { addServiceCommand } from '../service/add-service';
+
+const cancelledMessage = 'Add API extension cancelled';
+
+export interface AddApiExtensionOptions {
+    plugin?: VendurePluginRef;
+}
+
+export const addApiExtensionCommand = new CliCommand({
+    id: 'add-api-extension',
+    category: 'Plugin: API',
+    description: 'Adds GraphQL API extensions to a plugin',
+    run: options => addApiExtension(options),
+});
+
+async function addApiExtension(
+    options?: AddApiExtensionOptions,
+): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
+    const providedVendurePlugin = options?.plugin;
+    const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
+    const plugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
+    const serviceRef = await selectServiceRef(project, plugin);
+
+    const serviceEntityRef = serviceRef.crudEntityRef;
+    const modifiedSourceFiles: SourceFile[] = [];
+    let resolver: ClassDeclaration | undefined;
+    let apiExtensions: VariableDeclaration | undefined;
+
+    const scaffoldSpinner = spinner();
+    scaffoldSpinner.start('Generating API extension files...');
+    await new Promise(resolve => setTimeout(resolve, 100));
+
+    if (serviceEntityRef) {
+        resolver = createCrudResolver(project, plugin, serviceRef, serviceEntityRef);
+        modifiedSourceFiles.push(resolver.getSourceFile());
+        apiExtensions = createCrudApiExtension(project, plugin, serviceRef);
+        if (apiExtensions) {
+            modifiedSourceFiles.push(apiExtensions.getSourceFile());
+        }
+        plugin.addAdminApiExtensions({
+            schema: apiExtensions,
+            resolvers: [resolver],
+        });
+        addImportsToFile(plugin.getSourceFile(), {
+            namedImports: [resolver.getName() as string],
+            moduleSpecifier: resolver.getSourceFile(),
+        });
+        if (apiExtensions) {
+            addImportsToFile(plugin.getSourceFile(), {
+                namedImports: [apiExtensions.getName()],
+                moduleSpecifier: apiExtensions.getSourceFile(),
+            });
+        }
+    } else {
+        resolver = createSimpleResolver(project, plugin, serviceRef);
+        modifiedSourceFiles.push(resolver.getSourceFile());
+        apiExtensions = createSimpleApiExtension(project, plugin, serviceRef);
+        plugin.addAdminApiExtensions({
+            schema: apiExtensions,
+            resolvers: [resolver],
+        });
+        addImportsToFile(plugin.getSourceFile(), {
+            namedImports: [resolver.getName() as string],
+            moduleSpecifier: resolver.getSourceFile(),
+        });
+        if (apiExtensions) {
+            addImportsToFile(plugin.getSourceFile(), {
+                namedImports: [apiExtensions.getName()],
+                moduleSpecifier: apiExtensions.getSourceFile(),
+            });
+        }
+    }
+
+    scaffoldSpinner.stop(`API extension files generated`);
+
+    await project.save();
+
+    return {
+        project,
+        modifiedSourceFiles: [serviceRef.classDeclaration.getSourceFile(), ...modifiedSourceFiles],
+        serviceRef,
+    };
+}
+
+function createSimpleResolver(project: Project, plugin: VendurePluginRef, serviceRef: ServiceRef) {
+    const resolverSourceFile = createFile(
+        project,
+        path.join(__dirname, 'templates/simple-resolver.template.ts'),
+    );
+    resolverSourceFile.move(
+        path.join(
+            plugin.getPluginDir().getPath(),
+            'api',
+            kebabize(serviceRef.name).replace('-service', '') + '-admin.resolver.ts',
+        ),
+    );
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const resolverClassDeclaration = resolverSourceFile
+        .getClass('SimpleAdminResolver')!
+        .rename(serviceRef.name.replace(/Service$/, '') + 'AdminResolver');
+
+    resolverClassDeclaration
+        .getConstructors()[0]
+        .getParameter('templateService')
+        ?.rename(serviceRef.nameCamelCase)
+        .setType(serviceRef.name);
+
+    resolverSourceFile.getClass('TemplateService')?.remove();
+    addImportsToFile(resolverSourceFile, {
+        namedImports: [serviceRef.name],
+        moduleSpecifier: serviceRef.classDeclaration.getSourceFile(),
+    });
+
+    return resolverClassDeclaration;
+}
+
+function createCrudResolver(
+    project: Project,
+    plugin: VendurePluginRef,
+    serviceRef: ServiceRef,
+    serviceEntityRef: EntityRef,
+) {
+    const resolverSourceFile = createFile(
+        project,
+        path.join(__dirname, 'templates/crud-resolver.template.ts'),
+    );
+    resolverSourceFile.move(
+        path.join(
+            plugin.getPluginDir().getPath(),
+            'api',
+            kebabize(serviceEntityRef.name) + '-admin.resolver.ts',
+        ),
+    );
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const resolverClassDeclaration = resolverSourceFile
+        .getClass('EntityAdminResolver')!
+        .rename(serviceEntityRef.name + 'AdminResolver');
+
+    if (serviceRef.features.findOne) {
+        resolverClassDeclaration.getMethod('entity')?.rename(serviceEntityRef.nameCamelCase);
+    } else {
+        resolverClassDeclaration.getMethod('entity')?.remove();
+    }
+
+    if (serviceRef.features.findAll) {
+        resolverClassDeclaration.getMethod('entities')?.rename(serviceEntityRef.nameCamelCase + 's');
+    } else {
+        resolverClassDeclaration.getMethod('entities')?.remove();
+    }
+
+    if (serviceRef.features.create) {
+        resolverClassDeclaration.getMethod('createEntity')?.rename('create' + serviceEntityRef.name);
+    } else {
+        resolverClassDeclaration.getMethod('createEntity')?.remove();
+    }
+
+    if (serviceRef.features.update) {
+        resolverClassDeclaration.getMethod('updateEntity')?.rename('update' + serviceEntityRef.name);
+    } else {
+        resolverClassDeclaration.getMethod('updateEntity')?.remove();
+    }
+
+    if (serviceRef.features.delete) {
+        resolverClassDeclaration.getMethod('deleteEntity')?.rename('delete' + serviceEntityRef.name);
+    } else {
+        resolverClassDeclaration.getMethod('deleteEntity')?.remove();
+    }
+
+    resolverClassDeclaration
+        .getConstructors()[0]
+        .getParameter('templateService')
+        ?.rename(serviceRef.nameCamelCase)
+        .setType(serviceRef.name);
+    resolverSourceFile.getClass('TemplateEntity')?.rename(serviceEntityRef.name).remove();
+    resolverSourceFile.getClass('TemplateService')?.remove();
+    addImportsToFile(resolverSourceFile, {
+        namedImports: [serviceRef.name],
+        moduleSpecifier: serviceRef.classDeclaration.getSourceFile(),
+    });
+    addImportsToFile(resolverSourceFile, {
+        namedImports: [serviceEntityRef.name],
+        moduleSpecifier: serviceEntityRef.classDeclaration.getSourceFile(),
+    });
+
+    return resolverClassDeclaration;
+}
+
+function createSimpleApiExtension(project: Project, plugin: VendurePluginRef, serviceRef: ServiceRef) {
+    const apiExtensionsFile = getOrCreateApiExtensionsFile(project, plugin);
+    const adminApiExtensionDocuments = apiExtensionsFile.getVariableDeclaration('adminApiExtensionDocuments');
+    const insertAtIndex = adminApiExtensionDocuments?.getParent().getParent().getChildIndex() ?? 2;
+    const schemaVariableName = `${serviceRef.nameCamelCase.replace(/Service$/, '')}AdminApiExtensions`;
+    apiExtensionsFile.insertVariableStatement(insertAtIndex, {
+        declarationKind: VariableDeclarationKind.Const,
+        declarations: [
+            {
+                name: schemaVariableName,
+                initializer: writer => {
+                    writer.writeLine(`gql\``);
+                    writer.indent(() => {
+                        writer.writeLine(`  extend type Query {`);
+                        writer.writeLine(`    exampleQuery(id: ID!): Boolean!`);
+                        writer.writeLine(`  }`);
+                        writer.newLine();
+                        writer.writeLine(`  extend type Mutation {`);
+                        writer.writeLine(`    exampleMutation(id: ID!): Boolean!`);
+                        writer.writeLine(`  }`);
+                    });
+                    writer.write(`\``);
+                },
+            },
+        ],
+    });
+
+    if (adminApiExtensionDocuments) {
+        const initializer = adminApiExtensionDocuments.getInitializer();
+        if (Node.isArrayLiteralExpression(initializer)) {
+            initializer.addElement(schemaVariableName);
+        }
+    }
+
+    const adminApiExtensions = apiExtensionsFile.getVariableDeclaration('adminApiExtensions');
+    return adminApiExtensions;
+}
+
+function createCrudApiExtension(project: Project, plugin: VendurePluginRef, serviceRef: ServiceRef) {
+    const apiExtensionsFile = getOrCreateApiExtensionsFile(project, plugin);
+    const adminApiExtensionDocuments = apiExtensionsFile.getVariableDeclaration('adminApiExtensionDocuments');
+    const insertAtIndex = adminApiExtensionDocuments?.getParent().getParent().getChildIndex() ?? 2;
+    const schemaVariableName = `${serviceRef.nameCamelCase.replace(/Service$/, '')}AdminApiExtensions`;
+    apiExtensionsFile.insertVariableStatement(insertAtIndex, {
+        declarationKind: VariableDeclarationKind.Const,
+        declarations: [
+            {
+                name: schemaVariableName,
+                initializer: writer => {
+                    writer.writeLine(`gql\``);
+                    const entityRef = serviceRef.crudEntityRef;
+                    if (entityRef) {
+                        writer.indent(() => {
+                            writer.writeLine(`  type ${entityRef.name} implements Node {`);
+                            writer.writeLine(`    id: ID!`);
+                            writer.writeLine(`    createdAt: DateTime!`);
+                            writer.writeLine(`    updatedAt: DateTime!`);
+                            for (const prop of entityRef.classDeclaration.getProperties()) {
+                                const { type, nullable } = getEntityPropType(prop.getType());
+                                const graphQlType = getGraphQLType(type);
+                                if (graphQlType) {
+                                    writer.writeLine(
+                                        `  ${prop.getName()}: ${graphQlType}${nullable ? '' : '!'}`,
+                                    );
+                                }
+                            }
+                            writer.writeLine(`  }`);
+                            writer.newLine();
+
+                            writer.writeLine(`  type ${entityRef.name}List implements PaginatedList {`);
+                            writer.writeLine(`    items: [${entityRef.name}!]!`);
+                            writer.writeLine(`    totalItems: Int!`);
+                            writer.writeLine(`  }`);
+                            writer.newLine();
+
+                            writer.writeLine(`  # Generated at run-time by Vendure`);
+                            writer.writeLine(`  input ${entityRef.name}ListOptions`);
+                            writer.newLine();
+
+                            writer.writeLine(`  extend type Query {`);
+
+                            if (serviceRef.features.findOne) {
+                                writer.writeLine(
+                                    `    ${entityRef.nameCamelCase}(id: ID!): ${entityRef.name}`,
+                                );
+                            }
+                            if (serviceRef.features.findAll) {
+                                writer.writeLine(
+                                    `    ${entityRef.nameCamelCase}s(options: ${entityRef.name}ListOptions): ${entityRef.name}List!`,
+                                );
+                            }
+                            writer.writeLine(`  }`);
+                            writer.newLine();
+
+                            if (serviceRef.features.create) {
+                                writer.writeLine(`  input Create${entityRef.name}Input {`);
+                                for (const prop of entityRef.classDeclaration.getProperties()) {
+                                    const { type, nullable } = getEntityPropType(prop.getType());
+                                    const graphQlType = getGraphQLType(type);
+                                    if (graphQlType) {
+                                        writer.writeLine(
+                                            `    ${prop.getName()}: ${graphQlType}${nullable ? '' : '!'}`,
+                                        );
+                                    }
+                                }
+                                writer.writeLine(`  }`);
+                                writer.newLine();
+                            }
+
+                            if (serviceRef.features.update) {
+                                writer.writeLine(`  input Update${entityRef.name}Input {`);
+                                writer.writeLine(`    id: ID!`);
+                                for (const prop of entityRef.classDeclaration.getProperties()) {
+                                    const { type } = getEntityPropType(prop.getType());
+                                    const graphQlType = getGraphQLType(type);
+                                    if (graphQlType) {
+                                        writer.writeLine(`    ${prop.getName()}: ${graphQlType}`);
+                                    }
+                                }
+                                writer.writeLine(`  }`);
+                                writer.newLine();
+                            }
+
+                            if (
+                                serviceRef.features.create ||
+                                serviceRef.features.update ||
+                                serviceRef.features.delete
+                            ) {
+                                writer.writeLine(`  extend type Mutation {`);
+                                if (serviceRef.features.create) {
+                                    writer.writeLine(
+                                        `    create${entityRef.name}(input: Create${entityRef.name}Input!): ${entityRef.name}!`,
+                                    );
+                                }
+                                if (serviceRef.features.update) {
+                                    writer.writeLine(
+                                        `    update${entityRef.name}(input: Update${entityRef.name}Input!): ${entityRef.name}!`,
+                                    );
+                                }
+                                if (serviceRef.features.delete) {
+                                    writer.writeLine(
+                                        `    delete${entityRef.name}(id: ID!): DeletionResponse!`,
+                                    );
+                                }
+                                writer.writeLine(`  }`);
+                            }
+                        });
+                    }
+                    writer.write(`\``);
+                },
+            },
+        ],
+    });
+
+    if (adminApiExtensionDocuments) {
+        const initializer = adminApiExtensionDocuments.getInitializer();
+        if (Node.isArrayLiteralExpression(initializer)) {
+            initializer.addElement(schemaVariableName);
+        }
+    }
+
+    const adminApiExtensions = apiExtensionsFile.getVariableDeclaration('adminApiExtensions');
+    return adminApiExtensions;
+}
+
+function getEntityPropType(propType: Type): { type: Type; nullable: boolean } {
+    if (propType.isUnion()) {
+        // get the non-null part of the union
+        const nonNullType = propType.getUnionTypes().find(t => !t.isNull() && !t.isUndefined());
+        if (!nonNullType) {
+            throw new Error('Could not find non-null type in union');
+        }
+        return { type: nonNullType, nullable: true };
+    }
+    return { type: propType, nullable: false };
+}
+
+function getGraphQLType(type: Type): string | undefined {
+    if (type.isString()) {
+        return 'String';
+    }
+    if (type.isBoolean()) {
+        return 'Boolean';
+    }
+    if (type.isNumber()) {
+        return 'Int';
+    }
+    if (type.isClass() && type.getText() === 'Date') {
+        return 'DateTime';
+    }
+    return;
+}
+
+function getOrCreateApiExtensionsFile(project: Project, plugin: VendurePluginRef): SourceFile {
+    const existingApiExtensionsFile = project.getSourceFiles().find(sf => {
+        return sf.getBaseName() === 'api-extensions.ts' && sf.getDirectory().getPath().endsWith('/api');
+    });
+    if (existingApiExtensionsFile) {
+        return existingApiExtensionsFile;
+    }
+    return createFile(project, path.join(__dirname, 'templates/api-extensions.template.ts')).move(
+        path.join(plugin.getPluginDir().getPath(), 'api', 'api-extensions.ts'),
+    );
+}
+
+async function selectServiceRef(project: Project, plugin: VendurePluginRef): Promise<ServiceRef> {
+    const serviceRefs = getServices(project);
+    const result = await select({
+        message: 'Which service contains the business logic for this API extension?',
+        maxItems: 8,
+        options: [
+            {
+                value: 'new',
+                label: `Create new generic service`,
+            },
+            ...serviceRefs.map(sr => {
+                const features = sr.crudEntityRef
+                    ? `CRUD service for ${sr.crudEntityRef.name}`
+                    : `Generic service`;
+                const label = `${sr.name}: (${features})`;
+                return {
+                    value: sr,
+                    label,
+                };
+            }),
+        ],
+    });
+    if (result === 'new') {
+        return addServiceCommand.run({ type: 'basic', plugin }).then(r => r.serviceRef);
+    } else {
+        return result as ServiceRef;
+    }
+}
+
+function getServices(project: Project): ServiceRef[] {
+    const servicesSourceFiles = project.getSourceFiles().filter(sf => {
+        return sf.getDirectory().getPath().endsWith('/services');
+    });
+
+    return servicesSourceFiles
+        .flatMap(sf => sf.getClasses())
+        .filter(classDeclaration => classDeclaration.getDecorator('Injectable'))
+        .map(classDeclaration => new ServiceRef(classDeclaration));
+}

+ 8 - 0
packages/cli/src/commands/add/api-extension/templates/api-extensions.template.ts

@@ -0,0 +1,8 @@
+import { DocumentNode } from 'graphql/language/index';
+import gql from 'graphql-tag';
+
+const adminApiExtensionDocuments: DocumentNode[] = [];
+
+export const adminApiExtensions = gql`
+    ${adminApiExtensionDocuments.join('\n')}
+`;

+ 65 - 0
packages/cli/src/commands/add/api-extension/templates/crud-resolver.template.ts

@@ -0,0 +1,65 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { DeletionResponse, Permission } from '@vendure/common/lib/generated-types';
+import { Allow, Ctx, PaginatedList, RequestContext, Transaction, VendureEntity } from '@vendure/core';
+
+class TemplateEntity extends VendureEntity {}
+
+class TemplateService {
+    findAll(ctx: RequestContext, options?: any): Promise<PaginatedList<TemplateEntity>> {
+        throw new Error('Method not implemented.');
+    }
+
+    findOne(ctx: RequestContext, id: string): Promise<TemplateEntity | null> {
+        throw new Error('Method not implemented.');
+    }
+
+    create(ctx: RequestContext, input: any): Promise<TemplateEntity> {
+        throw new Error('Method not implemented.');
+    }
+
+    update(ctx: RequestContext, input: any): Promise<TemplateEntity> {
+        throw new Error('Method not implemented.');
+    }
+
+    delete(ctx: RequestContext, id: string): Promise<DeletionResponse> {
+        throw new Error('Method not implemented.');
+    }
+}
+
+@Resolver()
+export class EntityAdminResolver {
+    constructor(private templateService: TemplateService) {}
+
+    @Query()
+    @Allow(Permission.SuperAdmin)
+    async entity(@Ctx() ctx: RequestContext, @Args() args: any): Promise<TemplateEntity | null> {
+        return this.templateService.findOne(ctx, args.id);
+    }
+
+    @Query()
+    @Allow(Permission.SuperAdmin)
+    async entities(@Ctx() ctx: RequestContext, @Args() args: any): Promise<PaginatedList<TemplateEntity>> {
+        return this.templateService.findAll(ctx, args.options || undefined);
+    }
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.SuperAdmin)
+    async createEntity(@Ctx() ctx: RequestContext, @Args() args: any): Promise<TemplateEntity> {
+        return this.templateService.create(ctx, args.input);
+    }
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.SuperAdmin)
+    async updateEntity(@Ctx() ctx: RequestContext, @Args() args: any): Promise<TemplateEntity> {
+        return this.templateService.update(ctx, args.input);
+    }
+
+    @Mutation()
+    @Transaction()
+    @Allow(Permission.SuperAdmin)
+    async deleteEntity(@Ctx() ctx: RequestContext, @Args() args: any): Promise<DeletionResponse> {
+        return this.templateService.delete(ctx, args.id);
+    }
+}

+ 22 - 0
packages/cli/src/commands/add/api-extension/templates/simple-resolver.template.ts

@@ -0,0 +1,22 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Permission } from '@vendure/common/lib/generated-types';
+import { Allow, Ctx, RequestContext } from '@vendure/core';
+
+class TemplateService {}
+
+@Resolver()
+export class SimpleAdminResolver {
+    constructor(private templateService: TemplateService) {}
+
+    @Query()
+    @Allow(Permission.SuperAdmin)
+    async exampleQuery(@Ctx() ctx: RequestContext, @Args() args: { id: string }): Promise<boolean> {
+        return true;
+    }
+
+    @Mutation()
+    @Allow(Permission.SuperAdmin)
+    async exampleMutation(@Ctx() ctx: RequestContext, @Args() args: { id: string }): Promise<boolean> {
+        return true;
+    }
+}

+ 6 - 5
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -2,7 +2,7 @@ import { log, note, outro, spinner } from '@clack/prompts';
 import path from 'path';
 import { StructureKind } from 'ts-morph';
 
-import { CliCommand } from '../../../shared/cli-command';
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectMultiplePluginClasses } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
@@ -21,7 +21,7 @@ export const addCodegenCommand = new CliCommand({
     run: addCodegen,
 });
 
-async function addCodegen(options?: AddCodegenOptions) {
+async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturnVal> {
     const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({
         providedVendurePlugin,
@@ -109,7 +109,8 @@ async function addCodegen(options?: AddCodegenOptions) {
     ];
     note(nextSteps.join('\n'));
 
-    if (!providedVendurePlugin) {
-        outro('✅ Codegen setup complete!');
-    }
+    return {
+        project,
+        modifiedSourceFiles: [codegenFile.sourceFile],
+    };
 }

+ 1 - 1
packages/cli/src/commands/add/codegen/codegen-config-ref.ts

@@ -14,7 +14,7 @@ import { createFile, getTsMorphProject } from '../../../utilities/ast-utils';
 
 export class CodegenConfigRef {
     private readonly tempProject: Project;
-    private readonly sourceFile: SourceFile;
+    public readonly sourceFile: SourceFile;
     private configObject: ObjectLiteralExpression | undefined;
     constructor(rootDir: Directory) {
         this.tempProject = getTsMorphProject({ skipAddingFilesFromTsConfig: true });

+ 9 - 6
packages/cli/src/commands/add/entity/add-entity.ts

@@ -4,7 +4,7 @@ import path from 'path';
 import { ClassDeclaration } from 'ts-morph';
 
 import { pascalCaseRegex } from '../../../constants';
-import { CliCommand } from '../../../shared/cli-command';
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
@@ -32,7 +32,9 @@ export const addEntityCommand = new CliCommand({
     run: options => addEntity(options),
 });
 
-async function addEntity(options?: Partial<AddEntityOptions>) {
+async function addEntity(
+    options?: Partial<AddEntityOptions>,
+): Promise<CliCommandReturnVal<{ entityRef: EntityRef }>> {
     const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
@@ -56,10 +58,11 @@ async function addEntity(options?: Partial<AddEntityOptions>) {
 
     await project.save();
 
-    if (!providedVendurePlugin) {
-        outro('✅  Done!');
-    }
-    return new EntityRef(entityClass);
+    return {
+        project,
+        modifiedSourceFiles: [entityClass.getSourceFile()],
+        entityRef: new EntityRef(entityClass),
+    };
 }
 
 async function getFeatures(options?: Partial<AddEntityOptions>): Promise<AddEntityOptions['features']> {

+ 21 - 6
packages/cli/src/commands/add/plugin/create-new-plugin.ts

@@ -2,8 +2,9 @@ import { cancel, intro, isCancel, outro, select, spinner, text } from '@clack/pr
 import { constantCase, paramCase, pascalCase } from 'change-case';
 import * as fs from 'fs-extra';
 import path from 'path';
+import { SourceFile } from 'ts-morph';
 
-import { CliCommand } from '../../../shared/cli-command';
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
@@ -23,7 +24,7 @@ export const createNewPluginCommand = new CliCommand({
 
 const cancelledMessage = 'Plugin setup cancelled.';
 
-export async function createNewPlugin() {
+export async function createNewPlugin(): Promise<CliCommandReturnVal> {
     const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
     intro('Adding a new Vendure plugin!');
     if (!options.name) {
@@ -62,7 +63,7 @@ export async function createNewPlugin() {
     }
 
     options.pluginDir = confirmation;
-    const plugin = await generatePlugin(options);
+    const { plugin, project, modifiedSourceFiles } = await generatePlugin(options);
 
     const configSpinner = spinner();
     configSpinner.start('Updating VendureConfig...');
@@ -78,6 +79,7 @@ export async function createNewPlugin() {
 
     let done = false;
     const followUpCommands = [addEntityCommand, addServiceCommand, addUiExtensionsCommand, addCodegenCommand];
+    const allModifiedSourceFiles = [...modifiedSourceFiles];
     while (!done) {
         const featureType = await select({
             message: `Add features to ${options.name}?`,
@@ -96,12 +98,21 @@ export async function createNewPlugin() {
             done = true;
         } else {
             const command = followUpCommands.find(c => c.id === featureType);
-            await command?.run({ plugin });
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            const result = await command!.run({ plugin });
+            allModifiedSourceFiles.push(...result.modifiedSourceFiles);
         }
     }
+
+    return {
+        project,
+        modifiedSourceFiles,
+    };
 }
 
-export async function generatePlugin(options: GeneratePluginOptions): Promise<VendurePluginRef> {
+export async function generatePlugin(
+    options: GeneratePluginOptions,
+): Promise<CliCommandReturnVal<{ plugin: VendurePluginRef }>> {
     const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
     const normalizedName = nameWithoutPlugin + '-plugin';
     const templateContext: NewPluginTemplateContext = {
@@ -139,7 +150,11 @@ export async function generatePlugin(options: GeneratePluginOptions): Promise<Ve
 
     projectSpinner.stop('Generated plugin scaffold');
     await project.save();
-    return new VendurePluginRef(pluginClass);
+    return {
+        project,
+        modifiedSourceFiles: [pluginFile, typesFile, constantsFile],
+        plugin: new VendurePluginRef(pluginClass),
+    };
 }
 
 function getPluginDirName(name: string) {

+ 11 - 9
packages/cli/src/commands/add/service/add-service.ts

@@ -1,9 +1,9 @@
-import { cancel, isCancel, outro, select, text } from '@clack/prompts';
+import { cancel, isCancel, select, text } from '@clack/prompts';
 import path from 'path';
 import { ClassDeclaration, SourceFile } from 'ts-morph';
 
 import { pascalCaseRegex } from '../../../constants';
-import { CliCommand } from '../../../shared/cli-command';
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { ServiceRef } from '../../../shared/service-ref';
 import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts';
@@ -19,14 +19,16 @@ interface AddServiceOptions {
     entityRef?: EntityRef;
 }
 
-export const addServiceCommand = new CliCommand<AddServiceOptions, ServiceRef>({
+export const addServiceCommand = new CliCommand({
     id: 'add-service',
     category: 'Plugin: Service',
     description: 'Add a new service to a plugin',
     run: options => addService(options),
 });
 
-async function addService(providedOptions?: Partial<AddServiceOptions>) {
+async function addService(
+    providedOptions?: Partial<AddServiceOptions>,
+): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = providedOptions?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
@@ -133,13 +135,13 @@ async function addService(providedOptions?: Partial<AddServiceOptions>) {
         namedImports: [options.serviceName],
     });
 
-    serviceSourceFile.organizeImports();
     await project.save();
 
-    if (!providedVendurePlugin) {
-        outro('✅  Done!');
-    }
-    return new ServiceRef(serviceClassDeclaration);
+    return {
+        project,
+        modifiedSourceFiles: [serviceSourceFile],
+        serviceRef: new ServiceRef(serviceClassDeclaration),
+    };
 }
 
 function customizeFindOneMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {

+ 4 - 6
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -1,7 +1,7 @@
 import { log, note, outro, spinner } from '@clack/prompts';
 import path from 'path';
 
-import { CliCommand } from '../../../shared/cli-command';
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
@@ -22,7 +22,7 @@ export const addUiExtensionsCommand = new CliCommand<AddUiExtensionsOptions>({
     run: options => addUiExtensions(options),
 });
 
-async function addUiExtensions(options?: AddUiExtensionsOptions) {
+async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCommandReturnVal> {
     const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({ providedVendurePlugin });
     const vendurePlugin =
@@ -31,7 +31,7 @@ async function addUiExtensions(options?: AddUiExtensionsOptions) {
 
     if (vendurePlugin.hasUiExtensions()) {
         outro('This plugin already has UI extensions configured');
-        return;
+        return { project, modifiedSourceFiles: [] };
     }
     addUiExtensionStaticProp(vendurePlugin);
 
@@ -85,7 +85,5 @@ async function addUiExtensions(options?: AddUiExtensionsOptions) {
     }
 
     await project.save();
-    if (!providedVendurePlugin) {
-        outro('✅  Done!');
-    }
+    return { project, modifiedSourceFiles: [vendurePlugin.classDeclaration.getSourceFile()] };
 }

+ 10 - 3
packages/cli/src/shared/cli-command.ts

@@ -1,3 +1,5 @@
+import { Project, SourceFile } from 'ts-morph';
+
 import { VendurePluginRef } from './vendure-plugin-ref';
 
 export type CommandCategory =
@@ -13,14 +15,19 @@ export interface BaseCliCommandOptions {
     plugin?: VendurePluginRef;
 }
 
-export interface CliCommandOptions<T extends BaseCliCommandOptions, R> {
+export type CliCommandReturnVal<T extends Record<string, any> = Record<string, any>> = {
+    project: Project;
+    modifiedSourceFiles: SourceFile[];
+} & T;
+
+export interface CliCommandOptions<T extends BaseCliCommandOptions, R extends CliCommandReturnVal> {
     id: string;
     category: CommandCategory;
     description: string;
     run: (options?: Partial<T>) => Promise<R>;
 }
 
-export class CliCommand<T extends Record<string, any>, R = void> {
+export class CliCommand<T extends Record<string, any>, R extends CliCommandReturnVal = CliCommandReturnVal> {
     constructor(private options: CliCommandOptions<T, R>) {}
 
     get id() {
@@ -35,7 +42,7 @@ export class CliCommand<T extends Record<string, any>, R = void> {
         return this.options.description;
     }
 
-    run(options?: Partial<T>) {
+    run(options?: Partial<T>): Promise<R> {
         return this.options.run(options);
     }
 }

+ 4 - 0
packages/cli/src/shared/entity-ref.ts

@@ -7,6 +7,10 @@ export class EntityRef {
         return this.classDeclaration.getName() as string;
     }
 
+    get nameCamelCase(): string {
+        return this.name.charAt(0).toLowerCase() + this.name.slice(1);
+    }
+
     isTranslatable() {
         return this.classDeclaration.getImplements().some(i => i.getText() === 'Translatable');
     }

+ 4 - 0
packages/cli/src/shared/service-ref.ts

@@ -18,6 +18,10 @@ export class ServiceRef {
         return this.classDeclaration.getName() as string;
     }
 
+    get nameCamelCase(): string {
+        return this.name.charAt(0).toLowerCase() + this.name.slice(1);
+    }
+
     get isCrudService(): boolean {
         return this.crudEntityRef !== undefined;
     }

+ 53 - 1
packages/cli/src/shared/vendure-plugin-ref.ts

@@ -1,4 +1,4 @@
-import { ClassDeclaration, Node, SyntaxKind } from 'ts-morph';
+import { ClassDeclaration, Node, StructureKind, SyntaxKind, VariableDeclaration } from 'ts-morph';
 
 import { AdminUiExtensionTypeName } from '../constants';
 
@@ -63,6 +63,58 @@ export class VendurePluginRef {
         }
     }
 
+    addAdminApiExtensions(extension: {
+        schema: VariableDeclaration | undefined;
+        resolvers: ClassDeclaration[];
+    }) {
+        const pluginOptions = this.getMetadataOptions();
+        const adminApiExtensionsProperty = pluginOptions
+            .getProperty('adminApiExtensions')
+            ?.getType()
+            .getSymbolOrThrow()
+            .getDeclarations()[0];
+        if (
+            extension.schema &&
+            adminApiExtensionsProperty &&
+            Node.isObjectLiteralExpression(adminApiExtensionsProperty)
+        ) {
+            const schemaProp = adminApiExtensionsProperty.getProperty('schema');
+            if (!schemaProp) {
+                adminApiExtensionsProperty.addPropertyAssignment({
+                    name: 'schema',
+                    initializer: extension.schema?.getName(),
+                });
+            }
+            const resolversProp = adminApiExtensionsProperty.getProperty('resolvers');
+            if (resolversProp) {
+                const resolversArray = resolversProp.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression);
+                if (resolversArray) {
+                    for (const resolver of extension.resolvers) {
+                        const resolverName = resolver.getName();
+                        if (resolverName) {
+                            resolversArray.addElement(resolverName);
+                        }
+                    }
+                }
+            } else {
+                adminApiExtensionsProperty.addPropertyAssignment({
+                    name: 'resolvers',
+                    initializer: `[${extension.resolvers.map(r => r.getName()).join(', ')}]`,
+                });
+            }
+        } else if (extension.schema) {
+            pluginOptions
+                .addPropertyAssignment({
+                    name: 'adminApiExtensions',
+                    initializer: `{
+                        schema: ${extension.schema.getName()},
+                        resolvers: [${extension.resolvers.map(r => r.getName()).join(', ')}]
+                    }`,
+                })
+                .formatText();
+        }
+    }
+
     getEntities(): EntityRef[] {
         const metadataOptions = this.getMetadataOptions();
         const entitiesProperty = metadataOptions.getProperty('entities');

+ 2 - 2
packages/cli/src/utilities/ast-utils.ts

@@ -45,10 +45,10 @@ export function addImportsToFile(
         order?: number;
     },
 ) {
+    const moduleSpecifier = getModuleSpecifierString(options.moduleSpecifier, sourceFile);
     const existingDeclaration = sourceFile.getImportDeclaration(
-        declaration => declaration.getModuleSpecifier().getLiteralValue() === options.moduleSpecifier,
+        declaration => declaration.getModuleSpecifier().getLiteralValue() === moduleSpecifier,
     );
-    const moduleSpecifier = getModuleSpecifierString(options.moduleSpecifier, sourceFile);
     if (!existingDeclaration) {
         const importDeclaration = sourceFile.addImportDeclaration({
             moduleSpecifier,