Browse Source

refactor(cli): Simplify new plugin command

Michael Bromley 1 year ago
parent
commit
6ba362c98b

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

@@ -2,6 +2,7 @@ import { cancel, isCancel, log, select } from '@clack/prompts';
 import { Command } from 'commander';
 
 import { addEntity } from './entity/add-entity';
+import { createNewPlugin } from './plugin/create-new-plugin';
 import { addUiExtensions } from './ui-extensions/add-ui-extensions';
 
 const cancelledMessage = 'Add feature cancelled.';
@@ -14,8 +15,9 @@ export function registerAddCommand(program: Command) {
             const featureType = await select({
                 message: 'Which feature would you like to add?',
                 options: [
-                    { value: 'uiExtensions', label: 'Set up Admin UI extensions' },
-                    { value: 'entity', label: 'Add a new entity to a plugin' },
+                    { value: 'plugin', label: '[Plugin] Add a new plugin' },
+                    { value: 'entity', label: '[Plugin: Entity] Add a new entity to a plugin' },
+                    { value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
                 ],
             });
             if (isCancel(featureType)) {
@@ -23,6 +25,9 @@ export function registerAddCommand(program: Command) {
                 process.exit(0);
             }
             try {
+                if (featureType === 'plugin') {
+                    await createNewPlugin();
+                }
                 if (featureType === 'uiExtensions') {
                     await addUiExtensions();
                 }

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

@@ -1,11 +1,9 @@
-import { outro, spinner } from '@clack/prompts';
+import { outro, spinner, text } 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 { createFile, getTsMorphProject } from '../../../utilities/ast-utils';
 
 import { addEntityToPlugin } from './codemods/add-entity-to-plugin/add-entity-to-plugin';
 
@@ -35,8 +33,7 @@ export async function addEntity() {
     };
 
     const entitiesDir = path.join(pluginClass.getSourceFile().getDirectory().getPath(), 'entities');
-    const entityTemplatePath = path.join(__dirname, 'templates/entity.template.ts');
-    const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
+    const entityFile = createFile(project, path.join(__dirname, 'templates/entity.template.ts'));
     entityFile.move(path.join(entitiesDir, `${context.entity.fileName}.ts`));
     entityFile.getClasses()[0].rename(`${context.entity.className}CustomFields`);
     entityFile.getClasses()[1].rename(context.entity.className);

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

@@ -3,7 +3,7 @@ import { Project } from 'ts-morph';
 import { describe, expect, it } from 'vitest';
 
 import { defaultManipulationSettings } from '../../../../../constants';
-import { createSourceFileFromTemplate, getPluginClasses } from '../../../../../utilities/ast-utils';
+import { createFile, getPluginClasses } from '../../../../../utilities/ast-utils';
 import { expectSourceFileContentToMatch } from '../../../../../utilities/testing-utils';
 
 import { addEntityToPlugin } from './add-entity-to-plugin';
@@ -17,7 +17,7 @@ describe('addEntityToPlugin', () => {
         const pluginClasses = getPluginClasses(project);
         expect(pluginClasses.length).toBe(1);
         const entityTemplatePath = path.join(__dirname, '../../templates/entity.template.ts');
-        const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
+        const entityFile = createFile(project, entityTemplatePath);
         entityFile.move(path.join(__dirname, 'fixtures', 'entity.ts'));
         addEntityToPlugin(pluginClasses[0], entityFile);
 

+ 105 - 0
packages/cli/src/commands/add/plugin/create-new-plugin.ts

@@ -0,0 +1,105 @@
+import { cancel, intro, isCancel, outro, spinner, text } from '@clack/prompts';
+import { constantCase, paramCase, pascalCase } from 'change-case';
+import * as fs from 'fs-extra';
+import path from 'path';
+
+import { createFile, getTsMorphProject } from '../../../utilities/ast-utils';
+
+import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
+
+const cancelledMessage = 'Plugin setup cancelled.';
+
+export async function createNewPlugin() {
+    const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
+    intro('Scaffolding a new Vendure plugin!');
+    if (!options.name) {
+        const name = await text({
+            message: 'What is the name of the plugin?',
+            initialValue: '',
+            validate: input => {
+                if (!/^[a-z][a-z-0-9]+$/.test(input)) {
+                    return 'The plugin name must be lowercase and contain only letters, numbers and dashes';
+                }
+            },
+        });
+
+        if (isCancel(name)) {
+            cancel(cancelledMessage);
+            process.exit(0);
+        } else {
+            options.name = name;
+        }
+    }
+    const pluginDir = getPluginDirName(options.name);
+    const confirmation = await text({
+        message: 'Plugin location',
+        initialValue: pluginDir,
+        placeholder: '',
+        validate: input => {
+            if (fs.existsSync(input)) {
+                return `A directory named "${input}" already exists. Please specify a different directory.`;
+            }
+        },
+    });
+
+    if (isCancel(confirmation)) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    } else {
+        options.pluginDir = confirmation;
+        await generatePlugin(options);
+    }
+}
+
+export async function generatePlugin(options: GeneratePluginOptions) {
+    const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
+    const normalizedName = nameWithoutPlugin + '-plugin';
+    const templateContext: NewPluginTemplateContext = {
+        ...options,
+        pluginName: pascalCase(normalizedName),
+        pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS',
+    };
+
+    const projectSpinner = spinner();
+    projectSpinner.start('Generating plugin scaffold...');
+    await new Promise(resolve => setTimeout(resolve, 100));
+    const project = getTsMorphProject({ skipAddingFilesFromTsConfig: true });
+
+    const pluginFile = createFile(project, path.join(__dirname, 'templates/plugin.template.ts'));
+    pluginFile.getClass('TemplatePlugin')?.rename(templateContext.pluginName);
+
+    const typesFile = createFile(project, path.join(__dirname, 'templates/types.template.ts'));
+
+    const constantsFile = createFile(project, path.join(__dirname, 'templates/constants.template.ts'));
+    constantsFile
+        .getVariableDeclaration('TEMPLATE_PLUGIN_OPTIONS')
+        ?.rename(templateContext.pluginInitOptionsName)
+        .set({ initializer: `Symbol('${templateContext.pluginInitOptionsName}')` });
+    constantsFile
+        .getVariableDeclaration('loggerCtx')
+        ?.set({ initializer: `'${templateContext.pluginName}'` });
+
+    typesFile.move(path.join(options.pluginDir, 'types.ts'));
+    pluginFile.move(path.join(options.pluginDir, paramCase(nameWithoutPlugin) + '.plugin.ts'));
+    constantsFile.move(path.join(options.pluginDir, 'constants.ts'));
+
+    projectSpinner.stop('Done');
+    project.saveSync();
+    outro('✅ Plugin scaffolding complete!');
+}
+
+function getPluginDirName(name: string) {
+    const cwd = process.cwd();
+    const pathParts = cwd.split(path.sep);
+    const currentlyInPluginsDir = pathParts[pathParts.length - 1] === 'plugins';
+    const currentlyInRootDir = fs.pathExistsSync(path.join(cwd, 'package.json'));
+    const nameWithoutPlugin = name.replace(/-?plugin$/i, '');
+
+    if (currentlyInPluginsDir) {
+        return path.join(cwd, paramCase(nameWithoutPlugin));
+    }
+    if (currentlyInRootDir) {
+        return path.join(cwd, 'src', 'plugins', paramCase(nameWithoutPlugin));
+    }
+    return path.join(cwd, paramCase(nameWithoutPlugin));
+}

+ 2 - 0
packages/cli/src/commands/add/plugin/templates/constants.template.ts

@@ -0,0 +1,2 @@
+export const TEMPLATE_PLUGIN_OPTIONS = Symbol('TEMPLATE_PLUGIN_OPTIONS');
+export const loggerCtx = 'TemplatePlugin';

+ 18 - 0
packages/cli/src/commands/add/plugin/templates/plugin.template.ts

@@ -0,0 +1,18 @@
+import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
+
+import { TEMPLATE_PLUGIN_OPTIONS } from './constants.template';
+import { PluginInitOptions } from './types.template';
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [{ provide: TEMPLATE_PLUGIN_OPTIONS, useFactory: () => TemplatePlugin.options }],
+    compatibility: '^2.0.0',
+})
+export class TemplatePlugin {
+    static options: PluginInitOptions;
+
+    static init(options: PluginInitOptions): Type<TemplatePlugin> {
+        this.options = options;
+        return TemplatePlugin;
+    }
+}

+ 7 - 0
packages/cli/src/commands/add/plugin/templates/types.template.ts

@@ -0,0 +1,7 @@
+/**
+ * @description
+ * The plugin can be configured using the following options:
+ */
+export interface PluginInitOptions {
+    exampleOption: string;
+}

+ 9 - 0
packages/cli/src/commands/add/plugin/types.ts

@@ -0,0 +1,9 @@
+export interface GeneratePluginOptions {
+    name: string;
+    pluginDir: string;
+}
+
+export type NewPluginTemplateContext = GeneratePluginOptions & {
+    pluginName: string;
+    pluginInitOptionsName: string;
+};

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

@@ -1,16 +1,18 @@
-import { note, outro, spinner, log } from '@clack/prompts';
+import { log, note, outro, spinner } from '@clack/prompts';
 import path from 'path';
 import { ClassDeclaration } from 'ts-morph';
 
 import { selectPluginClass } from '../../../shared/shared-prompts';
-import { getRelativeImportPath, getTsMorphProject, getVendureConfig } from '../../../utilities/ast-utils';
+import {
+    createFile,
+    getRelativeImportPath,
+    getTsMorphProject,
+    getVendureConfig,
+} from '../../../utilities/ast-utils';
 import { determineVendureVersion, installRequiredPackages } from '../../../utilities/package-utils';
-import { Scaffolder } from '../../../utilities/scaffolder';
 
 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';
-import { renderProviders } from './scaffold/providers';
-import { renderRoutes } from './scaffold/routes';
 
 export async function addUiExtensions() {
     const projectSpinner = spinner();
@@ -44,16 +46,14 @@ export async function addUiExtensions() {
     }
     installSpinner.stop('Dependencies installed');
 
-    const scaffolder = new Scaffolder();
-    scaffolder.addFile(renderProviders, 'providers.ts');
-    scaffolder.addFile(renderRoutes, 'routes.ts');
+    const pluginDir = pluginClass.getSourceFile().getDirectory().getPath();
+    const providersFile = createFile(project, path.join(__dirname, 'templates/providers.template.ts'));
+    providersFile.move(path.join(pluginDir, 'ui', 'providers.ts'));
+    const routesFile = createFile(project, path.join(__dirname, 'templates/routes.template.ts'));
+    routesFile.move(path.join(pluginDir, 'ui', 'routes.ts'));
+
     log.success('Created UI extension scaffold');
 
-    const pluginDir = pluginClass.getSourceFile().getDirectory().getPath();
-    scaffolder.createScaffold({
-        dir: path.join(pluginDir, 'ui'),
-        context: {},
-    });
     const vendureConfig = getVendureConfig(project);
     if (!vendureConfig) {
         log.warning(

+ 0 - 8
packages/cli/src/commands/add/ui-extensions/scaffold/providers.ts

@@ -1,8 +0,0 @@
-export function renderProviders() {
-    return /* language=TypeScript */ `
-
-export default [
-    // Add your providers here
-];
-    `;
-}

+ 0 - 8
packages/cli/src/commands/add/ui-extensions/scaffold/routes.ts

@@ -1,8 +0,0 @@
-export function renderRoutes() {
-    return /* language=TypeScript */ `
-
-export default [
-    // Add your custom routes here
-];
-`;
-}

+ 3 - 0
packages/cli/src/commands/add/ui-extensions/templates/providers.template.ts

@@ -0,0 +1,3 @@
+export default [
+    // Add your providers here
+];

+ 3 - 0
packages/cli/src/commands/add/ui-extensions/templates/routes.template.ts

@@ -0,0 +1,3 @@
+export default [
+    // Add your custom routes here
+];

+ 1 - 1
packages/cli/src/shared/shared-prompts.ts

@@ -8,7 +8,6 @@ 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';
@@ -34,6 +33,7 @@ export async function selectPluginClass(project: Project, cancelledMessage: stri
             value: c,
             label: c.getName() as string,
         })),
+        maxItems: 10,
     });
     if (isCancel(targetPlugin)) {
         cancel(cancelledMessage);

+ 20 - 4
packages/cli/src/utilities/ast-utils.ts

@@ -1,10 +1,18 @@
+import { log } from '@clack/prompts';
 import fs from 'fs-extra';
 import path from 'node:path';
-import { Node, ObjectLiteralExpression, Project, SourceFile, VariableDeclaration } from 'ts-morph';
+import {
+    Node,
+    ObjectLiteralExpression,
+    Project,
+    ProjectOptions,
+    SourceFile,
+    VariableDeclaration,
+} from 'ts-morph';
 
 import { defaultManipulationSettings } from '../constants';
 
-export function getTsMorphProject() {
+export function getTsMorphProject(options: ProjectOptions = {}) {
     const tsConfigPath = path.join(process.cwd(), 'tsconfig.json');
     if (!fs.existsSync(tsConfigPath)) {
         throw new Error('No tsconfig.json found in current directory');
@@ -15,6 +23,7 @@ export function getTsMorphProject() {
         compilerOptions: {
             skipLibCheck: true,
         },
+        ...options,
     });
 }
 
@@ -110,9 +119,16 @@ export function getRelativeImportPath(locations: { from: SourceFile; to: SourceF
     );
 }
 
-export function createSourceFileFromTemplate(project: Project, templatePath: string) {
+export function createFile(project: Project, templatePath: string) {
     const template = fs.readFileSync(templatePath, 'utf-8');
-    return project.createSourceFile('temp.ts', template);
+    try {
+        return project.createSourceFile(path.join('/.vendure-cli-temp/', templatePath), template, {
+            overwrite: true,
+        });
+    } catch (e: any) {
+        log.error(e.message);
+        process.exit(1);
+    }
 }
 
 export function kebabize(str: string) {