new-plugin.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { cancel, confirm, intro, isCancel, multiselect, outro, text } from '@clack/prompts';
  2. import { camelCase, constantCase, paramCase, pascalCase } from 'change-case';
  3. import * as fs from 'fs-extra';
  4. import path from 'path';
  5. import { renderAdminResolver, renderAdminResolverWithEntity } from './scaffold/api/admin.resolver';
  6. import { renderApiExtensions } from './scaffold/api/api-extensions';
  7. import { renderShopResolver, renderShopResolverWithEntity } from './scaffold/api/shop.resolver';
  8. import { renderConstants } from './scaffold/constants';
  9. import { renderEntity } from './scaffold/entities/entity';
  10. import { renderPlugin } from './scaffold/plugin';
  11. import { renderService, renderServiceWithEntity } from './scaffold/services/service';
  12. import { renderTypes } from './scaffold/types';
  13. import { GeneratePluginOptions, TemplateContext } from './types';
  14. const cancelledMessage = 'Plugin setup cancelled.';
  15. export async function newPlugin() {
  16. const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
  17. intro('Scaffolding a new Vendure plugin!');
  18. if (!options.name) {
  19. const name = await text({
  20. message: 'What is the name of the bobby plugin?',
  21. initialValue: '',
  22. validate: input => {
  23. if (!/^[a-z][a-z-0-9]+$/.test(input)) {
  24. return 'The plugin name must be lowercase and contain only letters, numbers and dashes';
  25. }
  26. },
  27. });
  28. if (isCancel(name)) {
  29. cancel(cancelledMessage);
  30. process.exit(0);
  31. } else {
  32. options.name = name;
  33. }
  34. }
  35. const features = await multiselect({
  36. message: 'Which features would you like to include? (use ↑, ↓, space to select)',
  37. options: [
  38. { value: 'customEntity', label: 'Custom entity' },
  39. { value: 'apiExtensions', label: 'GraphQL API extensions' },
  40. ],
  41. required: false,
  42. });
  43. if (Array.isArray(features)) {
  44. options.withCustomEntity = features.includes('customEntity');
  45. options.withApiExtensions = features.includes('apiExtensions');
  46. }
  47. if (options.withCustomEntity) {
  48. const entityName = await text({
  49. message: 'What is the name of the custom entity?',
  50. initialValue: '',
  51. placeholder: '',
  52. validate: input => {
  53. if (!input) {
  54. return 'The custom entity name cannot be empty';
  55. }
  56. const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/;
  57. if (!pascalCaseRegex.test(input)) {
  58. return 'The custom entity name must be in PascalCase, e.g. "ProductReview"';
  59. }
  60. },
  61. });
  62. if (isCancel(entityName)) {
  63. cancel(cancelledMessage);
  64. process.exit(0);
  65. } else {
  66. options.customEntityName = pascalCase(entityName);
  67. }
  68. }
  69. const pluginDir = getPluginDirName(options.name);
  70. const confirmation = await text({
  71. message: 'Plugin location',
  72. initialValue: pluginDir,
  73. placeholder: '',
  74. validate: input => {
  75. if (fs.existsSync(input)) {
  76. return `A directory named "${input}" already exists. Please specify a different directory.`;
  77. }
  78. },
  79. });
  80. if (isCancel(confirmation)) {
  81. cancel(cancelledMessage);
  82. process.exit(0);
  83. } else {
  84. options.pluginDir = confirmation;
  85. generatePlugin(options);
  86. }
  87. }
  88. export function generatePlugin(options: GeneratePluginOptions) {
  89. const nameWithoutPlugin = options.name.replace(/-?plugin$/i, '');
  90. const normalizedName = nameWithoutPlugin + '-plugin';
  91. const templateContext: TemplateContext = {
  92. ...options,
  93. pluginName: pascalCase(normalizedName),
  94. pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS',
  95. service: {
  96. className: pascalCase(nameWithoutPlugin) + 'Service',
  97. instanceName: camelCase(nameWithoutPlugin) + 'Service',
  98. fileName: paramCase(nameWithoutPlugin) + '.service',
  99. },
  100. entity: {
  101. className: options.customEntityName,
  102. instanceName: camelCase(options.customEntityName),
  103. fileName: paramCase(options.customEntityName) + '.entity',
  104. },
  105. };
  106. const files: Array<{ render: (context: TemplateContext) => string; path: string }> = [
  107. {
  108. render: renderPlugin,
  109. path: paramCase(nameWithoutPlugin) + '.plugin.ts',
  110. },
  111. {
  112. render: renderTypes,
  113. path: 'types.ts',
  114. },
  115. {
  116. render: renderConstants,
  117. path: 'constants.ts',
  118. },
  119. ];
  120. if (options.withApiExtensions) {
  121. files.push({
  122. render: renderApiExtensions,
  123. path: 'api/api-extensions.ts',
  124. });
  125. if (options.withCustomEntity) {
  126. files.push({
  127. render: renderShopResolverWithEntity,
  128. path: 'api/shop.resolver.ts',
  129. });
  130. files.push({
  131. render: renderAdminResolverWithEntity,
  132. path: 'api/admin.resolver.ts',
  133. });
  134. } else {
  135. files.push({
  136. render: renderShopResolver,
  137. path: 'api/shop.resolver.ts',
  138. });
  139. files.push({
  140. render: renderAdminResolver,
  141. path: 'api/admin.resolver.ts',
  142. });
  143. }
  144. }
  145. if (options.withCustomEntity) {
  146. files.push({
  147. render: renderEntity,
  148. path: `entities/${templateContext.entity.fileName}.ts`,
  149. });
  150. files.push({
  151. render: renderServiceWithEntity,
  152. path: `services/${templateContext.service.fileName}.ts`,
  153. });
  154. } else {
  155. files.push({
  156. render: renderService,
  157. path: `services/${templateContext.service.fileName}.ts`,
  158. });
  159. }
  160. const pluginDir = options.pluginDir;
  161. fs.ensureDirSync(pluginDir);
  162. files.forEach(file => {
  163. const filePath = path.join(pluginDir, file.path);
  164. const rendered = file.render(templateContext).trim();
  165. fs.ensureFileSync(filePath);
  166. fs.writeFileSync(filePath, rendered);
  167. });
  168. outro('✅ Plugin scaffolding complete!');
  169. }
  170. function getPluginDirName(name: string) {
  171. const cwd = process.cwd();
  172. const pathParts = cwd.split(path.sep);
  173. const currentlyInPluginsDir = pathParts[pathParts.length - 1] === 'plugins';
  174. const currentlyInRootDir = fs.pathExistsSync(path.join(cwd, 'package.json'));
  175. const nameWithoutPlugin = name.replace(/-?plugin$/i, '');
  176. if (currentlyInPluginsDir) {
  177. return path.join(cwd, paramCase(nameWithoutPlugin));
  178. }
  179. if (currentlyInRootDir) {
  180. return path.join(cwd, 'src', 'plugins', paramCase(nameWithoutPlugin));
  181. }
  182. return path.join(cwd, paramCase(nameWithoutPlugin));
  183. }