-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cli): Implement plugin scaffold command
- Loading branch information
1 parent
aad6979
commit a6df4c1
Showing
18 changed files
with
591 additions
and
124 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { Argument, Command } from 'commander'; | ||
|
||
import { newPlugin } from './plugin/new-plugin'; | ||
|
||
export function registerNewCommand(program: Command) { | ||
program | ||
.command('new') | ||
.addArgument(new Argument('<type>', 'type of scaffold').choices(['plugin'])) | ||
.description('Generate scaffold for your Vendure project') | ||
.action(async (type: string) => { | ||
if (type === 'plugin') { | ||
await newPlugin(); | ||
} | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
import { camelCase, constantCase, paramCase, pascalCase } from 'change-case'; | ||
import { Command } from 'commander'; | ||
import * as fs from 'fs-extra'; | ||
import path from 'path'; | ||
|
||
import { renderAdminResolver, renderAdminResolverWithEntity } from './scaffold/api/admin.resolver'; | ||
import { renderApiExtensions } from './scaffold/api/api-extensions'; | ||
import { renderShopResolver, renderShopResolverWithEntity } from './scaffold/api/shop.resolver'; | ||
import { renderConstants } from './scaffold/constants'; | ||
import { renderEntity } from './scaffold/entities/entity'; | ||
import { renderPlugin } from './scaffold/plugin'; | ||
import { renderService, renderServiceWithEntity } from './scaffold/services/service'; | ||
import { renderTypes } from './scaffold/types'; | ||
import { GeneratePluginOptions, TemplateContext } from './types'; | ||
|
||
const cancelledMessage = 'Plugin setup cancelled.'; | ||
|
||
export async function newPlugin() { | ||
const { cancel, confirm, intro, isCancel, multiselect, text } = await import('@clack/prompts'); | ||
const options: GeneratePluginOptions = { name: '', customEntityName: '' } 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'; | ||
} | ||
const proposedPluginDir = getPluginDirName(input); | ||
if (fs.existsSync(proposedPluginDir)) { | ||
return `A directory named "${proposedPluginDir}" already exists. Cannot create plugin in this directory.`; | ||
} | ||
}, | ||
}); | ||
|
||
if (isCancel(name)) { | ||
cancel(cancelledMessage); | ||
process.exit(0); | ||
} else { | ||
options.name = name; | ||
} | ||
} | ||
const features = await multiselect({ | ||
message: 'Which features would you like to include? (use ↑, ↓, space to select)', | ||
options: [ | ||
{ value: 'customEntity', label: 'Custom entity' }, | ||
{ value: 'apiExtensions', label: 'GraphQL API extensions' }, | ||
], | ||
required: false, | ||
}); | ||
if (Array.isArray(features)) { | ||
options.withCustomEntity = features.includes('customEntity'); | ||
options.withApiExtensions = features.includes('apiExtensions'); | ||
} | ||
if (options.withCustomEntity) { | ||
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'; | ||
} | ||
const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/; | ||
if (!pascalCaseRegex.test(input)) { | ||
return 'The custom entity name must be in PascalCase, e.g. "ProductReview"'; | ||
} | ||
}, | ||
}); | ||
if (isCancel(entityName)) { | ||
cancel(cancelledMessage); | ||
process.exit(0); | ||
} else { | ||
options.customEntityName = pascalCase(entityName); | ||
} | ||
} | ||
const pluginDir = getPluginDirName(options.name); | ||
const confirmation = await confirm({ | ||
message: `Create new plugin in ${pluginDir}?`, | ||
}); | ||
|
||
if (isCancel(confirmation)) { | ||
cancel(cancelledMessage); | ||
process.exit(0); | ||
} else { | ||
if (confirmation === true) { | ||
await generatePlugin(options); | ||
} else { | ||
cancel(cancelledMessage); | ||
} | ||
} | ||
} | ||
|
||
export async function generatePlugin(options: GeneratePluginOptions) { | ||
const nameWithoutPlugin = options.name.replace(/-?plugin$/i, ''); | ||
const normalizedName = nameWithoutPlugin + '-plugin'; | ||
const templateContext: TemplateContext = { | ||
...options, | ||
pluginName: pascalCase(normalizedName), | ||
pluginInitOptionsName: constantCase(normalizedName) + '_OPTIONS', | ||
service: { | ||
className: pascalCase(nameWithoutPlugin) + 'Service', | ||
instanceName: camelCase(nameWithoutPlugin) + 'Service', | ||
fileName: paramCase(nameWithoutPlugin) + '.service', | ||
}, | ||
entity: { | ||
className: options.customEntityName, | ||
instanceName: camelCase(options.customEntityName), | ||
fileName: paramCase(options.customEntityName) + '.entity', | ||
}, | ||
}; | ||
|
||
const files: Array<{ render: (context: TemplateContext) => string; path: string }> = [ | ||
{ | ||
render: renderPlugin, | ||
path: paramCase(nameWithoutPlugin) + '.plugin.ts', | ||
}, | ||
{ | ||
render: renderTypes, | ||
path: 'types.ts', | ||
}, | ||
{ | ||
render: renderConstants, | ||
path: 'constants.ts', | ||
}, | ||
]; | ||
|
||
if (options.withApiExtensions) { | ||
files.push({ | ||
render: renderApiExtensions, | ||
path: 'api/api-extensions.ts', | ||
}); | ||
if (options.withCustomEntity) { | ||
files.push({ | ||
render: renderShopResolverWithEntity, | ||
path: 'api/shop.resolver.ts', | ||
}); | ||
files.push({ | ||
render: renderAdminResolverWithEntity, | ||
path: 'api/admin.resolver.ts', | ||
}); | ||
} else { | ||
files.push({ | ||
render: renderShopResolver, | ||
path: 'api/shop.resolver.ts', | ||
}); | ||
files.push({ | ||
render: renderAdminResolver, | ||
path: 'api/admin.resolver.ts', | ||
}); | ||
} | ||
} | ||
|
||
if (options.withCustomEntity) { | ||
files.push({ | ||
render: renderEntity, | ||
path: `entities/${templateContext.entity.fileName}.ts`, | ||
}); | ||
files.push({ | ||
render: renderServiceWithEntity, | ||
path: `services/${templateContext.service.fileName}.ts`, | ||
}); | ||
} else { | ||
files.push({ | ||
render: renderService, | ||
path: `services/${templateContext.service.fileName}.ts`, | ||
}); | ||
} | ||
|
||
const pluginDir = getPluginDirName(options.name); | ||
fs.ensureDirSync(pluginDir); | ||
files.forEach(file => { | ||
const filePath = path.join(pluginDir, file.path); | ||
const rendered = file.render(templateContext).trim(); | ||
fs.ensureFileSync(filePath); | ||
fs.writeFileSync(filePath, rendered); | ||
}); | ||
|
||
const { outro } = await import('@clack/prompts'); | ||
outro('✅ Plugin scaffolding complete!'); | ||
} | ||
|
||
function getPluginDirName(name: string) { | ||
const nameWithoutPlugin = name.replace(/-?plugin$/i, ''); | ||
return path.join(process.cwd(), paramCase(nameWithoutPlugin)); | ||
} |
62 changes: 62 additions & 0 deletions
62
packages/cli/src/commands/new/plugin/scaffold/api/admin.resolver.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { TemplateContext } from '../../types'; | ||
|
||
export function renderAdminResolverWithEntity(context: TemplateContext) { | ||
return /* language=TypeScript */ ` | ||
import { Args, Resolver, Mutation } from '@nestjs/graphql'; | ||
import { Allow, Ctx, RequestContext, Transaction, Permission } from '@vendure/core'; | ||
import { ${context.service.className} } from '../services/${context.service.fileName}'; | ||
import { ${context.entity.className} } from '../entities/${context.entity.fileName}'; | ||
// TODO: Set up graphql-code-generator to generate the types for the following inputs | ||
type Create${context.customEntityName}Input = any; | ||
type Update${context.customEntityName}Input = any; | ||
@Resolver() | ||
export class AdminResolver { | ||
constructor(private ${context.service.instanceName}: ${context.service.className}) { | ||
} | ||
@Transaction() | ||
@Mutation() | ||
@Allow(Permission.SuperAdmin) | ||
create${context.entity.className}(@Ctx() ctx: RequestContext, @Args() args: { input: Create${context.customEntityName}Input }): Promise<${context.entity.className}> { | ||
return this.${context.service.instanceName}.create(ctx, args.input); | ||
} | ||
@Transaction() | ||
@Mutation() | ||
@Allow(Permission.SuperAdmin) | ||
update${context.entity.className}( | ||
@Ctx() ctx: RequestContext, | ||
@Args() args: { input: Update${context.customEntityName}Input }, | ||
): Promise<${context.entity.className} | undefined> { | ||
return this.${context.service.instanceName}.update(ctx, args.input); | ||
} | ||
}`; | ||
} | ||
|
||
export function renderAdminResolver(context: TemplateContext) { | ||
return /* language=TypeScript */ ` | ||
import { Args, Query, Mutation, Resolver } from '@nestjs/graphql'; | ||
import { Ctx, PaginatedList, RequestContext, Transaction } from '@vendure/core'; | ||
import { ${context.service.className} } from '../services/${context.service.fileName}'; | ||
@Resolver() | ||
export class AdminResolver { | ||
constructor(private ${context.service.instanceName}: ${context.service.className}) { | ||
} | ||
@Query() | ||
exampleShopQuery(@Ctx() ctx: RequestContext) { | ||
return this.${context.service.instanceName}.exampleMethod(ctx); | ||
} | ||
@Mutation() | ||
@Transaction() | ||
exampleShopMutation(@Ctx() ctx: RequestContext, @Args() args: { input: string }) { | ||
return this.${context.service.instanceName}.exampleMethod(ctx, args); | ||
} | ||
}`; | ||
} |
80 changes: 80 additions & 0 deletions
80
packages/cli/src/commands/new/plugin/scaffold/api/api-extensions.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { constantCase, pascalCase } from 'change-case'; | ||
|
||
import { TemplateContext } from '../../types'; | ||
|
||
export function renderApiExtensions(context: TemplateContext) { | ||
if (!context.withApiExtensions) { | ||
return ''; | ||
} | ||
if (!context.withCustomEntity) { | ||
return /* language=TypeScript */ ` | ||
import gql from 'graphql-tag'; | ||
export const shopApiExtensions = gql\` | ||
extend type Query { | ||
exampleShopQuery: String! | ||
} | ||
extend type Mutation { | ||
exampleShopMutation(input: String!): String! | ||
} | ||
\`; | ||
export const adminApiExtensions = gql\` | ||
extend type Query { | ||
exampleAdminQuery: String! | ||
} | ||
extend type Mutation { | ||
exampleAdminMutation(input: String!): String! | ||
} | ||
\`; | ||
`; | ||
} else { | ||
const entityName = context.entity.className; | ||
return /* language=TypeScript */ ` | ||
import gql from 'graphql-tag'; | ||
export const commonApiExtensions = gql\` | ||
type ${entityName} implements Node { | ||
id: ID! | ||
createdAt: DateTime! | ||
updatedAt: DateTime! | ||
name: String! | ||
} | ||
type ${entityName}List implements PaginatedList { | ||
items: [${entityName}!]! | ||
totalItems: Int! | ||
} | ||
extend type Query { | ||
${context.entity.instanceName}s(options: ${entityName}ListOptions): ${entityName}List! | ||
${context.entity.instanceName}(id: ID!): ${entityName} | ||
} | ||
# Auto-generated at runtime | ||
input ${entityName}ListOptions | ||
\`; | ||
export const shopApiExtensions = gql\` | ||
\${commonApiExtensions} | ||
\`; | ||
export const adminApiExtensions = gql\` | ||
\${commonApiExtensions} | ||
extend type Mutation { | ||
create${entityName}(input: Create${entityName}Input!): ${entityName}! | ||
update${entityName}(input: Update${entityName}Input!): ${entityName}! | ||
} | ||
input Create${entityName}Input { | ||
name: String! | ||
} | ||
input Update${entityName}Input { | ||
id: ID! | ||
name: String! | ||
} | ||
\`; | ||
`; | ||
} | ||
} |
Oops, something went wrong.