diff --git a/packages/cli/src/__tests__/commands/schema/create.test.ts b/packages/cli/src/__tests__/commands/schema/create.test.ts deleted file mode 100644 index 1d7ec5fc..00000000 --- a/packages/cli/src/__tests__/commands/schema/create.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { inputAndOutputItem } from '@smartthings/cli-lib' -import SchemaAppCreateCommand from '../../../commands/schema/create.js' -import { addSchemaPermission } from '../../../lib/aws-utils.js' -import { SchemaAppRequest, SchemaCreateResponse, SchemaEndpoint } from '@smartthings/core-sdk' -import { SCHEMA_AWS_PRINCIPAL, SchemaAppWithOrganization } from '../../../lib/commands/schema-util.js' - - -jest.mock('../../../lib/aws-utils') - -describe('SchemaAppCreateCommand', () => { - const createSpy = jest.spyOn(SchemaEndpoint.prototype, 'create').mockImplementation() - - const inputAndOutputItemMock = jest.mocked(inputAndOutputItem) - const addSchemaPermissionMock = jest.mocked(addSchemaPermission) - - it('calls inputAndOutputItem with correct config', async () => { - await expect(SchemaAppCreateCommand.run([])).resolves.not.toThrow() - - expect(inputAndOutputItemMock).toBeCalledWith( - expect.any(SchemaAppCreateCommand), - expect.objectContaining({ - tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'], - }), - expect.any(Function), - expect.anything(), - ) - }) - - it('calls correct create endpoint', async () => { - await expect(SchemaAppCreateCommand.run([])).resolves.not.toThrow() - - const schemaCreateResponse: SchemaCreateResponse = { - stClientId: 'clientId', - stClientSecret: 'clientSecret', - } - createSpy.mockResolvedValueOnce(schemaCreateResponse) - - const schemaAppRequest = { - appName: 'schemaApp', - } as SchemaAppRequest - const schemaAppRequestWithOrganization = { - ...schemaAppRequest, - organizationId: 'organization-id', - } as SchemaAppWithOrganization - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - await expect(actionFunction(undefined, schemaAppRequestWithOrganization)) - .resolves.toStrictEqual(schemaCreateResponse) - expect(createSpy).toBeCalledWith(schemaAppRequest, 'organization-id') - }) - - it('accepts authorize flag and adds permissions for each lambda app region', async () => { - await expect(SchemaAppCreateCommand.run(['--authorize'])).resolves.not.toThrow() - - const schemaCreateResponse: SchemaCreateResponse = { - stClientId: 'clientId', - stClientSecret: 'clientSecret', - } - createSpy.mockResolvedValueOnce(schemaCreateResponse) - - const schemaAppRequest = { - appName: 'schemaApp', - hostingType: 'lambda', - lambdaArn: 'lambdaArn', - lambdaArnCN: 'lambdaArnCN', - lambdaArnEU: 'lambdaArnEU', - lambdaArnAP: 'lambdaArnAP', - } as SchemaAppRequest - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - await expect(actionFunction(undefined, schemaAppRequest)).resolves.toStrictEqual(schemaCreateResponse) - - expect(addSchemaPermissionMock).toBeCalledTimes(4) - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArn, SCHEMA_AWS_PRINCIPAL, undefined) - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArnCN, SCHEMA_AWS_PRINCIPAL, undefined) - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArnEU, SCHEMA_AWS_PRINCIPAL, undefined) - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArnAP, SCHEMA_AWS_PRINCIPAL, undefined) - expect(createSpy).toBeCalledWith(schemaAppRequest, undefined) - }) - - it('throws error if authorize flag is used on non-lambda app', async () => { - await expect(SchemaAppCreateCommand.run(['--authorize'])).resolves.not.toThrow() - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - const schemaAppRequest = { - hostingType: 'webhook', - } as SchemaAppRequest - - await expect(actionFunction(undefined, schemaAppRequest)).rejects.toThrow('Authorization is not applicable to WebHook schema connectors') - expect(createSpy).not.toBeCalled() - }) - - it('ignores authorize flag for lambda apps with no ARNs', async () => { - await expect(SchemaAppCreateCommand.run(['--authorize'])).resolves.not.toThrow() - - const schemaAppRequest = { - appName: 'schemaApp', - hostingType: 'lambda', - } as SchemaAppRequest - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() - - expect(addSchemaPermissionMock).toBeCalledTimes(0) - expect(createSpy).toBeCalledWith(schemaAppRequest, undefined) - }) - - it('passes principal flag to addSchemaPermission', async () => { - const principal = 'principal' - await expect(SchemaAppCreateCommand.run(['--authorize', `--principal=${principal}`])).resolves.not.toThrow() - - const schemaAppRequest = { - appName: 'schemaApp', - hostingType: 'lambda', - lambdaArn: 'lambdaArn', - } as SchemaAppRequest - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() - - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArn, principal, undefined) - }) - - it('passes statement-id flag to addSchemaPermission', async () => { - const statementId = 'statementId' - await expect(SchemaAppCreateCommand.run(['--authorize', `--statement=${statementId}`])).resolves.not.toThrow() - - const schemaAppRequest = { - appName: 'schemaApp', - hostingType: 'lambda', - lambdaArn: 'lambdaArn', - } as SchemaAppRequest - - const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - - await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() - - expect(addSchemaPermissionMock).toBeCalledWith(schemaAppRequest.lambdaArn, SCHEMA_AWS_PRINCIPAL, statementId) - }) -}) diff --git a/packages/cli/src/commands/schema/create.ts b/packages/cli/src/commands/schema/create.ts deleted file mode 100644 index c005cd0a..00000000 --- a/packages/cli/src/commands/schema/create.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Flags } from '@oclif/core' - -import { SchemaCreateResponse } from '@smartthings/core-sdk' - -import { - APIOrganizationCommand, - inputAndOutputItem, - lambdaAuthFlags, - userInputProcessor, -} from '@smartthings/cli-lib' - -import { addSchemaPermission } from '../../lib/aws-utils.js' -import { - SCHEMA_AWS_PRINCIPAL, - SchemaAppWithOrganization, - getSchemaAppCreateFromUser, -} from '../../lib/commands/schema-util.js' - - -export default class SchemaAppCreateCommand extends APIOrganizationCommand { - static description = 'create an ST Schema connector' + - this.apiDocsURL('postApps') - - static flags = { - ...APIOrganizationCommand.flags, - ...inputAndOutputItem.flags, - authorize: Flags.boolean({ - description: 'authorize connector\'s Lambda functions to be called by SmartThings', - }), - ...lambdaAuthFlags, - } - - async run(): Promise { - const createApp = async (_: void, request: SchemaAppWithOrganization): Promise => { - const { organizationId, ...data } = request - if (this.flags.authorize) { - if (data.hostingType === 'lambda') { - const principal = this.flags.principal ?? SCHEMA_AWS_PRINCIPAL - const statementId = this.flags.statement - - if (data.lambdaArn) { - await addSchemaPermission(data.lambdaArn, principal, statementId) - } - if (data.lambdaArnAP) { - await addSchemaPermission(data.lambdaArnAP, principal, statementId) - } - if (data.lambdaArnCN) { - await addSchemaPermission(data.lambdaArnCN, principal, statementId) - } - if (data.lambdaArnEU) { - await addSchemaPermission(data.lambdaArnEU, principal, statementId) - } - } else { - this.error('Authorization is not applicable to WebHook schema connectors') - } - } - return this.client.schema.create(data, organizationId) - } - await inputAndOutputItem(this, - { tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'] }, - createApp, userInputProcessor(() => getSchemaAppCreateFromUser(this, this.flags['dry-run'])), - ) - } -} diff --git a/src/__tests__/commands/schema/create.test.ts b/src/__tests__/commands/schema/create.test.ts new file mode 100644 index 00000000..e7e443ef --- /dev/null +++ b/src/__tests__/commands/schema/create.test.ts @@ -0,0 +1,287 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import { SchemaAppRequest, SchemaCreateResponse, SchemaEndpoint } from '@smartthings/core-sdk' + +import { CommandArgs } from '../../../commands/schema/create.js' +import { type addSchemaPermission, schemaAWSPrincipal } from '../../../lib/aws-util.js' +import type { fatalError } from '../../../lib/util.js' +import { apiDocsURL } from '../../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, +} from '../../../lib/command/basic-io.js' +import type { lambdaAuthBuilder } from '../../../lib/command/common-flags.js' +import type { InputProcessor, userInputProcessor } from '../../../lib/command/input-processor.js' +import type { + getSchemaAppCreateFromUser, + SchemaAppWithOrganization, +} from '../../../lib/command/util/schema-util.js' +import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' + + +const addSchemaPermissionMock = jest.fn() +jest.unstable_mockModule('../../../lib/aws-util.js', () => ({ + addSchemaPermission: addSchemaPermissionMock, + schemaAWSPrincipal, +})) + +const fatalErrorMock = jest.fn() + .mockImplementation(() => { throw Error('should exit') }) +jest.unstable_mockModule('../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) + +const apiDocsURLMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-command.js', () => ({ + apiDocsURL: apiDocsURLMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const inputAndOutputItemMock = jest.fn() +const inputAndOutputItemBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/basic-io.js', () => ({ + inputAndOutputItem: inputAndOutputItemMock, + inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, +})) + +const lambdaAuthBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/common-flags.js', () => ({ + lambdaAuthBuilder: lambdaAuthBuilderMock, +})) + +const mockInputProcessor = { + ioFormat: 'common', +} as InputProcessor +const userInputProcessorMock = jest.fn() + .mockReturnValue(mockInputProcessor) +jest.unstable_mockModule('../../../lib/command/input-processor.js', () => ({ + userInputProcessor: userInputProcessorMock, +})) + +const getSchemaAppCreateFromUserMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/util/schema-util.js', () => ({ + getSchemaAppCreateFromUser: getSchemaAppCreateFromUserMock, +})) + + +const { default: cmd } = await import('../../../commands/schema/create.js') + + +test('builder', () => { + const yargsMock = buildArgvMockStub() + const apiOrganizationCommandBuilderArgvMock = buildArgvMockStub() + const { + yargsMock: lambdaAuthBuilderArgvMock, + exampleMock, + optionMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) + lambdaAuthBuilderMock.mockReturnValueOnce(lambdaAuthBuilderArgvMock) + inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(lambdaAuthBuilderMock) + .toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) + expect(inputAndOutputItemBuilderMock) + .toHaveBeenCalledExactlyOnceWith(lambdaAuthBuilderArgvMock) + + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +describe('handler', () => { + const apiSchemaCreateMock = jest.fn() + const command = { + client: { + schema: { + create: apiSchemaCreateMock, + }, + }, + } as unknown as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) + + const inputArgv = { profile: 'default', authorize: false } as ArgumentsCamelCase + + it('calls inputAndOutputItem with correct config', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) + expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + { tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'] }, + expect.any(Function), + mockInputProcessor, + ) + }) + + it('calls correct create endpoint', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const schemaCreateResponse: SchemaCreateResponse = { + stClientId: 'clientId', + stClientSecret: 'clientSecret', + } + apiSchemaCreateMock.mockResolvedValueOnce(schemaCreateResponse) + + const schemaAppRequest = { + appName: 'schemaApp', + } as SchemaAppRequest + const schemaAppRequestWithOrganization = { + ...schemaAppRequest, + organizationId: 'organization-id', + } as SchemaAppWithOrganization + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + expect(await actionFunction(undefined, schemaAppRequestWithOrganization)) + .toStrictEqual(schemaCreateResponse) + expect(apiSchemaCreateMock) + .toHaveBeenCalledExactlyOnceWith(schemaAppRequest, 'organization-id') + }) + + it('accepts authorize flag and adds permissions for each lambda app region', async () => { + await expect(cmd.handler({ ...inputArgv, authorize: true })).resolves.not.toThrow() + + const schemaCreateResponse: SchemaCreateResponse = { + stClientId: 'clientId', + stClientSecret: 'clientSecret', + } + apiSchemaCreateMock.mockResolvedValueOnce(schemaCreateResponse) + + const schemaAppRequest = { + appName: 'schemaApp', + hostingType: 'lambda', + lambdaArn: 'lambdaArn', + lambdaArnCN: 'lambdaArnCN', + lambdaArnEU: 'lambdaArnEU', + lambdaArnAP: 'lambdaArnAP', + } as SchemaAppRequest + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + expect(await actionFunction(undefined, schemaAppRequest)).toBe(schemaCreateResponse) + + expect(addSchemaPermissionMock).toHaveBeenCalledTimes(4) + expect(addSchemaPermissionMock) + .toHaveBeenCalledWith(schemaAppRequest.lambdaArn, schemaAWSPrincipal, undefined) + expect(addSchemaPermissionMock) + .toHaveBeenCalledWith(schemaAppRequest.lambdaArnCN, schemaAWSPrincipal, undefined) + expect(addSchemaPermissionMock) + .toHaveBeenCalledWith(schemaAppRequest.lambdaArnEU, schemaAWSPrincipal, undefined) + expect(addSchemaPermissionMock) + .toHaveBeenCalledWith(schemaAppRequest.lambdaArnAP, schemaAWSPrincipal, undefined) + expect(apiSchemaCreateMock).toHaveBeenCalledExactlyOnceWith(schemaAppRequest, undefined) + }) + + it('logs error if authorize flag is used on non-lambda app', async () => { + await expect(cmd.handler({ ...inputArgv, authorize: true })).resolves.not.toThrow() + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + const schemaAppRequest = { hostingType: 'webhook' } as SchemaAppRequest + + await expect(actionFunction(undefined, schemaAppRequest)).rejects.toThrow('should exit') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( + 'Authorization is not applicable to WebHook schema connectors', + ) + expect(apiSchemaCreateMock).not.toHaveBeenCalled() + }) + + it('ignores authorize flag for lambda apps with no ARNs', async () => { + await expect(cmd.handler({ ...inputArgv, authorize: true })).resolves.not.toThrow() + + const schemaAppRequest = { + appName: 'schemaApp', + hostingType: 'lambda', + } as SchemaAppRequest + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() + + expect(addSchemaPermissionMock).not.toHaveBeenCalled() + expect(apiSchemaCreateMock).toHaveBeenCalledExactlyOnceWith(schemaAppRequest, undefined) + }) + + it('passes principal flag to addSchemaPermission', async () => { + await expect(cmd.handler({ ...inputArgv, authorize: true, principal: 'principal' })) + .resolves.not.toThrow() + + const schemaAppRequest = { + appName: 'schemaApp', + hostingType: 'lambda', + lambdaArn: 'lambdaArn', + } as SchemaAppRequest + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() + + expect(addSchemaPermissionMock) + .toHaveBeenCalledExactlyOnceWith(schemaAppRequest.lambdaArn, 'principal', undefined) + }) + + it('passes statement-id flag to addSchemaPermission', async () => { + await expect(cmd.handler({ ...inputArgv, authorize: true, statement: 'statement-id' })) + .resolves.not.toThrow() + + const schemaAppRequest = { + appName: 'schemaApp', + hostingType: 'lambda', + lambdaArn: 'lambdaArn', + } as SchemaAppRequest + + const actionFunction = inputAndOutputItemMock.mock.calls[0][2] + + await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() + + expect(addSchemaPermissionMock).toHaveBeenCalledExactlyOnceWith( + schemaAppRequest.lambdaArn, + schemaAWSPrincipal, + 'statement-id', + ) + }) + + it('uses getSchemaAppCreateFromUser to build schema app from user input', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) + const inputUser = userInputProcessorMock.mock.calls[0][0] + + await inputUser() + expect(getSchemaAppCreateFromUserMock).toHaveBeenCalledExactlyOnceWith(command, false) + }) + + it('passes dryRun to getSchemaAppCreateFromUser', async () => { + await expect(cmd.handler({ ...inputArgv, dryRun: true })).resolves.not.toThrow() + + expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) + const inputUser = userInputProcessorMock.mock.calls[0][0] + + await inputUser() + expect(getSchemaAppCreateFromUserMock).toHaveBeenCalledExactlyOnceWith(command, true) + }) +}) diff --git a/src/commands/index.ts b/src/commands/index.ts index acf7e9cf..1f6ef590 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -11,6 +11,7 @@ import locationsCommand from './locations.js' import locationsCreateCommand from './locations/create.js' import locationsDeleteCommand from './locations/delete.js' import locationsUpdateCommand from './locations/update.js' +import schemaCreateCommand from './schema/create.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const commands: CommandModule[] = [ @@ -25,4 +26,5 @@ export const commands: CommandModule[] = [ locationsCreateCommand, locationsDeleteCommand, locationsUpdateCommand, + schemaCreateCommand, ] diff --git a/src/commands/schema/create.ts b/src/commands/schema/create.ts new file mode 100644 index 00000000..25b32708 --- /dev/null +++ b/src/commands/schema/create.ts @@ -0,0 +1,97 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type SchemaCreateResponse } from '@smartthings/core-sdk' + +import { addSchemaPermission, schemaAWSPrincipal } from '../../lib/aws-util.js' +import { fatalError } from '../../lib/util.js' +import { apiDocsURL } from '../../lib/command/api-command.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + type InputAndOutputItemFlags, +} from '../../lib/command/basic-io.js' +import { lambdaAuthBuilder, type LambdaAuthFlags } from '../../lib/command/common-flags.js' +import { userInputProcessor } from '../../lib/command/input-processor.js' +import { + type SchemaAppWithOrganization, + getSchemaAppCreateFromUser, +} from '../../lib/command/util/schema-util.js' + + +export type CommandArgs = + & LambdaAuthFlags + & APIOrganizationCommandFlags + & InputAndOutputItemFlags + & { + authorize: boolean + } + +const command = 'schema:create' + +const describe = 'create an ST Schema connector' + +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(lambdaAuthBuilder(apiOrganizationCommandBuilder(yargs))) + .example([ + ['$0 schema:create', 'create a schema app from prompted input'], + [ + '$0 schema:create -i my-schema-app.yaml', + 'create a schema app defined in "my-schema-app.yaml"', + ], + ]) + .option( + 'authorize', + { + describe: "authorize connector's Lambda functions to be called by SmartThings", + type: 'boolean', + default: false, + }, + ) + .epilog(apiDocsURL('postApps')) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const createApp = async ( + _: void, + request: SchemaAppWithOrganization, + ): Promise => { + const { organizationId, ...data } = request + if (argv.authorize) { + if (data.hostingType === 'lambda') { + const principal = argv.principal ?? schemaAWSPrincipal + const statementId = argv.statement + + if (data.lambdaArn) { + await addSchemaPermission(data.lambdaArn, principal, statementId) + } + if (data.lambdaArnAP) { + await addSchemaPermission(data.lambdaArnAP, principal, statementId) + } + if (data.lambdaArnCN) { + await addSchemaPermission(data.lambdaArnCN, principal, statementId) + } + if (data.lambdaArnEU) { + await addSchemaPermission(data.lambdaArnEU, principal, statementId) + } + } else { + fatalError('Authorization is not applicable to WebHook schema connectors') + } + } + return command.client.schema.create(data, organizationId) + } + await inputAndOutputItem( + command, + { tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'] }, + createApp, + userInputProcessor(() => getSchemaAppCreateFromUser(command, argv.dryRun ?? false)), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd