create-new-plugin.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { cancel, intro, isCancel, log, select, spinner, text } from '@clack/prompts';
  2. import { constantCase, paramCase, pascalCase } from 'change-case';
  3. import * as fs from 'fs-extra';
  4. import path from 'path';
  5. import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
  6. import { VendureConfigRef } from '../../../shared/vendure-config-ref';
  7. import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
  8. import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
  9. import { pauseForPromptDisplay } from '../../../utilities/utils';
  10. import { addApiExtensionCommand } from '../api-extension/add-api-extension';
  11. import { addCodegenCommand } from '../codegen/add-codegen';
  12. import { addEntityCommand } from '../entity/add-entity';
  13. import { addJobQueueCommand } from '../job-queue/add-job-queue';
  14. import { addServiceCommand } from '../service/add-service';
  15. import { addUiExtensionsCommand } from '../ui-extensions/add-ui-extensions';
  16. import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
  17. export const createNewPluginCommand = new CliCommand({
  18. id: 'create-new-plugin',
  19. category: 'Plugin',
  20. description: 'Create a new Vendure plugin',
  21. run: createNewPlugin,
  22. });
  23. const cancelledMessage = 'Plugin setup cancelled.';
  24. export async function createNewPlugin(): Promise<CliCommandReturnVal> {
  25. const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
  26. intro('Adding a new Vendure plugin!');
  27. if (!options.name) {
  28. const name = await text({
  29. message: 'What is the name of the plugin?',
  30. initialValue: 'my-new-feature',
  31. validate: input => {
  32. if (!/^[a-z][a-z-0-9]+$/.test(input)) {
  33. return 'The plugin name must be lowercase and contain only letters, numbers and dashes';
  34. }
  35. },
  36. });
  37. if (isCancel(name)) {
  38. cancel(cancelledMessage);
  39. process.exit(0);
  40. } else {
  41. options.name = name;
  42. }
  43. }
  44. const pluginDir = getPluginDirName(options.name);
  45. const confirmation = await text({
  46. message: 'Plugin location',
  47. initialValue: pluginDir,
  48. placeholder: '',
  49. validate: input => {
  50. if (fs.existsSync(input)) {
  51. return `A directory named "${input}" already exists. Please specify a different directory.`;
  52. }
  53. },
  54. });
  55. if (isCancel(confirmation)) {
  56. cancel(cancelledMessage);
  57. process.exit(0);
  58. }
  59. options.pluginDir = confirmation;
  60. const { plugin, project, modifiedSourceFiles } = await generatePlugin(options);
  61. const configSpinner = spinner();
  62. configSpinner.start('Updating VendureConfig...');
  63. await pauseForPromptDisplay();
  64. const vendureConfig = new VendureConfigRef(project);
  65. vendureConfig.addToPluginsArray(`${plugin.name}.init({})`);
  66. addImportsToFile(vendureConfig.sourceFile, {
  67. moduleSpecifier: plugin.getSourceFile(),
  68. namedImports: [plugin.name],
  69. });
  70. await vendureConfig.sourceFile.getProject().save();
  71. configSpinner.stop('Updated VendureConfig');
  72. let done = false;
  73. const followUpCommands = [
  74. addEntityCommand,
  75. addServiceCommand,
  76. addApiExtensionCommand,
  77. addJobQueueCommand,
  78. addUiExtensionsCommand,
  79. addCodegenCommand,
  80. ];
  81. let allModifiedSourceFiles = [...modifiedSourceFiles];
  82. const pluginClassName = plugin.name;
  83. let workingPlugin = plugin;
  84. let workingProject = project;
  85. while (!done) {
  86. const featureType = await select({
  87. message: `Add features to ${options.name}?`,
  88. options: [
  89. { value: 'no', label: "[Finish] No, I'm done!" },
  90. ...followUpCommands.map(c => ({
  91. value: c.id,
  92. label: `[${c.category}] ${c.description}`,
  93. })),
  94. ],
  95. });
  96. if (isCancel(featureType)) {
  97. done = true;
  98. }
  99. if (featureType === 'no') {
  100. done = true;
  101. } else {
  102. const { project: newProject } = await getTsMorphProject();
  103. workingProject = newProject;
  104. const newPlugin = newProject
  105. .getSourceFile(workingPlugin.getSourceFile().getFilePath())
  106. ?.getClass(pluginClassName);
  107. if (!newPlugin) {
  108. throw new Error(`Could not find class "${pluginClassName}" in the new project`);
  109. }
  110. workingPlugin = new VendurePluginRef(newPlugin);
  111. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  112. const command = followUpCommands.find(c => c.id === featureType)!;
  113. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  114. try {
  115. const result = await command.run({ plugin: new VendurePluginRef(newPlugin) });
  116. allModifiedSourceFiles = result.modifiedSourceFiles;
  117. // We format all modified source files and re-load the
  118. // project to avoid issues with the project state
  119. for (const sourceFile of allModifiedSourceFiles) {
  120. sourceFile.organizeImports();
  121. }
  122. } catch (e: any) {
  123. log.error(`Error adding feature "${command.id}"`);
  124. log.error(e.stack);
  125. }
  126. await workingProject.save();
  127. }
  128. }
  129. return {
  130. project,
  131. modifiedSourceFiles: [],
  132. };
  133. }
  134. export async function generatePlugin(
  135. options: GeneratePluginOptions,
  136. ): Promise<CliCommandReturnVal<{ plugin: VendurePluginRef }>> {
  137. const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
  138. const normalizedName = nameWithoutPlugin + '-plugin';
  139. const templateContext: NewPluginTemplateContext = {
  140. ...options,
  141. pluginName: pascalCase(normalizedName),
  142. pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS',
  143. };
  144. const projectSpinner = spinner();
  145. projectSpinner.start('Generating plugin scaffold...');
  146. await pauseForPromptDisplay();
  147. const { project } = await getTsMorphProject({ skipAddingFilesFromTsConfig: false });
  148. const pluginFile = createFile(
  149. project,
  150. path.join(__dirname, 'templates/plugin.template.ts'),
  151. path.join(options.pluginDir, paramCase(nameWithoutPlugin) + '.plugin.ts'),
  152. );
  153. const pluginClass = pluginFile.getClass('TemplatePlugin');
  154. if (!pluginClass) {
  155. throw new Error('Could not find the plugin class in the generated file');
  156. }
  157. pluginClass.rename(templateContext.pluginName);
  158. const typesFile = createFile(
  159. project,
  160. path.join(__dirname, 'templates/types.template.ts'),
  161. path.join(options.pluginDir, 'types.ts'),
  162. );
  163. const constantsFile = createFile(
  164. project,
  165. path.join(__dirname, 'templates/constants.template.ts'),
  166. path.join(options.pluginDir, 'constants.ts'),
  167. );
  168. constantsFile
  169. .getVariableDeclaration('TEMPLATE_PLUGIN_OPTIONS')
  170. ?.rename(templateContext.pluginInitOptionsName)
  171. .set({ initializer: `Symbol('${templateContext.pluginInitOptionsName}')` });
  172. constantsFile
  173. .getVariableDeclaration('loggerCtx')
  174. ?.set({ initializer: `'${templateContext.pluginName}'` });
  175. projectSpinner.stop('Generated plugin scaffold');
  176. await project.save();
  177. return {
  178. project,
  179. modifiedSourceFiles: [pluginFile, typesFile, constantsFile],
  180. plugin: new VendurePluginRef(pluginClass),
  181. };
  182. }
  183. function getPluginDirName(name: string) {
  184. const cwd = process.cwd();
  185. const pathParts = cwd.split(path.sep);
  186. const currentlyInPluginsDir = pathParts[pathParts.length - 1] === 'plugins';
  187. const currentlyInRootDir = fs.pathExistsSync(path.join(cwd, 'package.json'));
  188. const nameWithoutPlugin = name.replace(/-?plugin$/i, '');
  189. if (currentlyInPluginsDir) {
  190. return path.join(cwd, paramCase(nameWithoutPlugin));
  191. }
  192. if (currentlyInRootDir) {
  193. return path.join(cwd, 'src', 'plugins', paramCase(nameWithoutPlugin));
  194. }
  195. return path.join(cwd, paramCase(nameWithoutPlugin));
  196. }