create-new-plugin.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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 { Project, SourceFile } from 'ts-morph';
  6. import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
  7. import { analyzeProject } from '../../../shared/shared-prompts';
  8. import { VendureConfigRef } from '../../../shared/vendure-config-ref';
  9. import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
  10. import { addImportsToFile, createFile, getPluginClasses } from '../../../utilities/ast-utils';
  11. import { pauseForPromptDisplay } from '../../../utilities/utils';
  12. import { addApiExtensionCommand } from '../api-extension/add-api-extension';
  13. import { addCodegenCommand } from '../codegen/add-codegen';
  14. import { addEntityCommand } from '../entity/add-entity';
  15. import { addJobQueueCommand } from '../job-queue/add-job-queue';
  16. import { addServiceCommand } from '../service/add-service';
  17. import { addUiExtensionsCommand } from '../ui-extensions/add-ui-extensions';
  18. import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
  19. export const createNewPluginCommand = new CliCommand({
  20. id: 'create-new-plugin',
  21. category: 'Plugin',
  22. description: 'Create a new Vendure plugin',
  23. run: createNewPlugin,
  24. });
  25. const cancelledMessage = 'Plugin setup cancelled.';
  26. export async function createNewPlugin(): Promise<CliCommandReturnVal> {
  27. const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
  28. intro('Adding a new Vendure plugin!');
  29. const { project } = await analyzeProject({ cancelledMessage });
  30. if (!options.name) {
  31. const name = await text({
  32. message: 'What is the name of the plugin?',
  33. initialValue: 'my-new-feature',
  34. validate: input => {
  35. if (!/^[a-z][a-z-0-9]+$/.test(input)) {
  36. return 'The plugin name must be lowercase and contain only letters, numbers and dashes';
  37. }
  38. },
  39. });
  40. if (isCancel(name)) {
  41. cancel(cancelledMessage);
  42. process.exit(0);
  43. } else {
  44. options.name = name;
  45. }
  46. }
  47. const existingPluginDir = findExistingPluginsDir(project);
  48. const pluginDir = getPluginDirName(options.name, existingPluginDir);
  49. const confirmation = await text({
  50. message: 'Plugin location',
  51. initialValue: pluginDir,
  52. placeholder: '',
  53. validate: input => {
  54. if (fs.existsSync(input)) {
  55. return `A directory named "${input}" already exists. Please specify a different directory.`;
  56. }
  57. },
  58. });
  59. if (isCancel(confirmation)) {
  60. cancel(cancelledMessage);
  61. process.exit(0);
  62. }
  63. options.pluginDir = confirmation;
  64. const { plugin, modifiedSourceFiles } = await generatePlugin(project, options);
  65. const configSpinner = spinner();
  66. configSpinner.start('Updating VendureConfig...');
  67. await pauseForPromptDisplay();
  68. const vendureConfig = new VendureConfigRef(project);
  69. vendureConfig.addToPluginsArray(`${plugin.name}.init({})`);
  70. addImportsToFile(vendureConfig.sourceFile, {
  71. moduleSpecifier: plugin.getSourceFile(),
  72. namedImports: [plugin.name],
  73. });
  74. await vendureConfig.sourceFile.getProject().save();
  75. configSpinner.stop('Updated VendureConfig');
  76. let done = false;
  77. const followUpCommands = [
  78. addEntityCommand,
  79. addServiceCommand,
  80. addApiExtensionCommand,
  81. addJobQueueCommand,
  82. addUiExtensionsCommand,
  83. addCodegenCommand,
  84. ];
  85. let allModifiedSourceFiles = [...modifiedSourceFiles];
  86. while (!done) {
  87. const featureType = await select({
  88. message: `Add features to ${options.name}?`,
  89. options: [
  90. { value: 'no', label: "[Finish] No, I'm done!" },
  91. ...followUpCommands.map(c => ({
  92. value: c.id,
  93. label: `[${c.category}] ${c.description}`,
  94. })),
  95. ],
  96. });
  97. if (isCancel(featureType)) {
  98. done = true;
  99. }
  100. if (featureType === 'no') {
  101. done = true;
  102. } else {
  103. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  104. const command = followUpCommands.find(c => c.id === featureType)!;
  105. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  106. try {
  107. const result = await command.run({ plugin });
  108. allModifiedSourceFiles = result.modifiedSourceFiles;
  109. // We format all modified source files and re-load the
  110. // project to avoid issues with the project state
  111. for (const sourceFile of allModifiedSourceFiles) {
  112. sourceFile.organizeImports();
  113. }
  114. } catch (e: any) {
  115. log.error(`Error adding feature "${command.id}"`);
  116. log.error(e.stack);
  117. }
  118. }
  119. }
  120. return {
  121. project,
  122. modifiedSourceFiles: [],
  123. };
  124. }
  125. export async function generatePlugin(
  126. project: Project,
  127. options: GeneratePluginOptions,
  128. ): Promise<{ plugin: VendurePluginRef; modifiedSourceFiles: SourceFile[] }> {
  129. const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
  130. const normalizedName = nameWithoutPlugin + '-plugin';
  131. const templateContext: NewPluginTemplateContext = {
  132. ...options,
  133. pluginName: pascalCase(normalizedName),
  134. pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS',
  135. };
  136. const projectSpinner = spinner();
  137. projectSpinner.start('Generating plugin scaffold...');
  138. await pauseForPromptDisplay();
  139. const pluginFile = createFile(
  140. project,
  141. path.join(__dirname, 'templates/plugin.template.ts'),
  142. path.join(options.pluginDir, paramCase(nameWithoutPlugin) + '.plugin.ts'),
  143. );
  144. const pluginClass = pluginFile.getClass('TemplatePlugin');
  145. if (!pluginClass) {
  146. throw new Error('Could not find the plugin class in the generated file');
  147. }
  148. pluginFile.getImportDeclaration('./constants.template')?.setModuleSpecifier('./constants');
  149. pluginFile.getImportDeclaration('./types.template')?.setModuleSpecifier('./types');
  150. pluginClass.rename(templateContext.pluginName);
  151. const typesFile = createFile(
  152. project,
  153. path.join(__dirname, 'templates/types.template.ts'),
  154. path.join(options.pluginDir, 'types.ts'),
  155. );
  156. const constantsFile = createFile(
  157. project,
  158. path.join(__dirname, 'templates/constants.template.ts'),
  159. path.join(options.pluginDir, 'constants.ts'),
  160. );
  161. constantsFile
  162. .getVariableDeclaration('TEMPLATE_PLUGIN_OPTIONS')
  163. ?.rename(templateContext.pluginInitOptionsName)
  164. .set({ initializer: `Symbol('${templateContext.pluginInitOptionsName}')` });
  165. constantsFile
  166. .getVariableDeclaration('loggerCtx')
  167. ?.set({ initializer: `'${templateContext.pluginName}'` });
  168. projectSpinner.stop('Generated plugin scaffold');
  169. await project.save();
  170. return {
  171. modifiedSourceFiles: [pluginFile, typesFile, constantsFile],
  172. plugin: new VendurePluginRef(pluginClass),
  173. };
  174. }
  175. function findExistingPluginsDir(project: Project): { prefix: string; suffix: string } | undefined {
  176. const pluginClasses = getPluginClasses(project);
  177. if (pluginClasses.length === 0) {
  178. return;
  179. }
  180. const pluginDirs = pluginClasses.map(c => {
  181. return c.getSourceFile().getDirectoryPath();
  182. });
  183. const prefix = findCommonPath(pluginDirs);
  184. const suffixStartIndex = prefix.length;
  185. const rest = pluginDirs[0].substring(suffixStartIndex).replace(/^\//, '').split('/');
  186. const suffix = rest.length > 1 ? rest.slice(1).join('/') : '';
  187. return { prefix, suffix };
  188. }
  189. function getPluginDirName(
  190. name: string,
  191. existingPluginDirPattern: { prefix: string; suffix: string } | undefined,
  192. ) {
  193. const cwd = process.cwd();
  194. const nameWithoutPlugin = name.replace(/-?plugin$/i, '');
  195. if (existingPluginDirPattern) {
  196. return path.join(
  197. existingPluginDirPattern.prefix,
  198. paramCase(nameWithoutPlugin),
  199. existingPluginDirPattern.suffix,
  200. );
  201. } else {
  202. return path.join(cwd, 'src', 'plugins', paramCase(nameWithoutPlugin));
  203. }
  204. }
  205. function findCommonPath(paths: string[]): string {
  206. if (paths.length === 0) {
  207. return ''; // If no paths provided, return empty string
  208. }
  209. // Split each path into segments
  210. const pathSegmentsList = paths.map(p => p.split('/'));
  211. // Find the minimum length of path segments (to avoid out of bounds)
  212. const minLength = Math.min(...pathSegmentsList.map(segments => segments.length));
  213. // Initialize the common path
  214. const commonPath: string[] = [];
  215. // Loop through each segment index up to the minimum length
  216. for (let i = 0; i < minLength; i++) {
  217. // Get the segment at the current index from the first path
  218. const currentSegment = pathSegmentsList[0][i];
  219. // Check if this segment is common across all paths
  220. const isCommon = pathSegmentsList.every(segments => segments[i] === currentSegment);
  221. if (isCommon) {
  222. // If it's common, add this segment to the common path
  223. commonPath.push(currentSegment);
  224. } else {
  225. // If it's not common, break out of the loop
  226. break;
  227. }
  228. }
  229. // Join the common path segments back into a string
  230. return commonPath.join('/');
  231. }