Skip to content

Commit

Permalink
feat(cli): Implement plugin scaffold command
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Sep 11, 2023
1 parent aad6979 commit a6df4c1
Show file tree
Hide file tree
Showing 18 changed files with 591 additions and 124 deletions.
5 changes: 1 addition & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,11 @@
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"vendure": "dist/cli.js"
},
"files": [
"dist/**/*",
"cli/**/*"
"dist/**/*"
],
"dependencies": {
"@vendure/common": "2.1.0-next.4",
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import { Command } from 'commander';

import { registerCommand as registerPluginCommand } from './commands/plugin/index';
import { registerNewCommand } from './commands/new/new';

const program = new Command();

// eslint-disable-next-line @typescript-eslint/no-var-requires
const version = require('../package.json').version;

program.version(version).description('The Vendure CLI');
registerPluginCommand(program);
registerNewCommand(program);

program.parse(process.argv);
void program.parseAsync(process.argv);
15 changes: 15 additions & 0 deletions packages/cli/src/commands/new/new.ts
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();
}
});
}
187 changes: 187 additions & 0 deletions packages/cli/src/commands/new/plugin/new-plugin.ts
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));
}
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);
}
}`;
}
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!
}
\`;
`;
}
}
Loading

0 comments on commit a6df4c1

Please sign in to comment.