From 032a2317825c55a063ec02171cbd4178314092f4 Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Tue, 10 Sep 2024 10:28:49 -0500 Subject: [PATCH] feat: query user for organization in schema:update and schema:create commands --- .changeset/wet-bottles-wink.md | 6 ++ package-lock.json | 29 ++++--- packages/cli/package.json | 2 +- .../__tests__/commands/schema/create.test.ts | 15 ++-- .../__tests__/commands/schema/update.test.ts | 19 +++-- .../lib/commands/schema-util.test.ts | 77 +++++++++++------ packages/cli/src/commands/schema/create.ts | 13 ++- packages/cli/src/commands/schema/update.ts | 19 ++++- packages/cli/src/lib/commands/schema-util.ts | 53 ++++++++++-- packages/edge/package.json | 2 +- packages/lib/package.json | 2 +- .../item-input/command-helpers.test.ts | 6 +- packages/lib/src/item-input/array.ts | 5 +- .../lib/src/item-input/command-helpers.ts | 7 +- packages/lib/src/item-input/index.ts | 1 + packages/lib/src/item-input/misc.ts | 1 + packages/lib/src/item-input/select.ts | 83 +++++++++++++++++++ packages/testlib/package.json | 2 +- 18 files changed, 262 insertions(+), 80 deletions(-) create mode 100644 .changeset/wet-bottles-wink.md create mode 100644 packages/lib/src/item-input/select.ts diff --git a/.changeset/wet-bottles-wink.md b/.changeset/wet-bottles-wink.md new file mode 100644 index 00000000..ab38c2f1 --- /dev/null +++ b/.changeset/wet-bottles-wink.md @@ -0,0 +1,6 @@ +--- +"@smartthings/cli": minor +"@smartthings/cli-lib": minor +--- + +query user for organization in schema:update and schema:create commands diff --git a/package-lock.json b/package-lock.json index 43b0df16..c925d991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4984,10 +4984,9 @@ "link": true }, "node_modules/@smartthings/core-sdk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@smartthings/core-sdk/-/core-sdk-8.2.0.tgz", - "integrity": "sha512-qKhSQOIKfSj8eUWu6eAXdgjy5S4/ih8lMx8kLbH4YJy483OaIDwsnxVm2LQkIQh3XcuwGkuEdjPfod7vvctxaQ==", - "license": "Apache-2.0", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@smartthings/core-sdk/-/core-sdk-8.3.0.tgz", + "integrity": "sha512-m3nqYx8rkjwoXb6ZrZJYplb6BQWye6jqtD9PgyBlA37tullaMJgdcJ/OuRauY9h2i6m4BEVPTISoy6ziec7YIA==", "dependencies": { "async-mutex": "^0.4.0", "axios": "^0.28.1", @@ -20034,7 +20033,7 @@ "@oclif/plugin-not-found": "^2.3.1", "@oclif/plugin-plugins": "^2.1.0", "@smartthings/cli-lib": "^2.2.5", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@smartthings/plugin-cli-edge": "^3.3.3", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", @@ -20089,7 +20088,7 @@ "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.16.3", "@smartthings/cli-lib": "^2.2.4", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "axios": "^0.28.0", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", @@ -20135,7 +20134,7 @@ "dependencies": { "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.16.3", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@types/eventsource": "^1.1.9", "axios": "^0.28.0", "chalk": "^4.1.2", @@ -20201,7 +20200,7 @@ "license": "Apache-2.0", "dependencies": { "@smartthings/cli-lib": "^2.2.4", - "@smartthings/core-sdk": "^8.2.0" + "@smartthings/core-sdk": "^8.3.0" }, "devDependencies": { "@types/jest": "^28.1.5", @@ -24312,7 +24311,7 @@ "@oclif/plugin-plugins": "^2.1.0", "@smartthings/cli-lib": "^2.2.5", "@smartthings/cli-testlib": "^2.0.6", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@smartthings/plugin-cli-edge": "^3.3.3", "@types/inquirer": "^8.2.1", "@types/jest": "^28.1.5", @@ -24353,7 +24352,7 @@ "requires": { "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.16.3", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@types/eventsource": "^1.1.9", "@types/express": "^4.17.13", "@types/inquirer": "^8.2.1", @@ -24407,7 +24406,7 @@ "version": "file:packages/testlib", "requires": { "@smartthings/cli-lib": "^2.2.4", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@types/jest": "^28.1.5", "@types/js-yaml": "^4.0.5", "@types/node": "^18.15.11", @@ -24427,9 +24426,9 @@ } }, "@smartthings/core-sdk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@smartthings/core-sdk/-/core-sdk-8.2.0.tgz", - "integrity": "sha512-qKhSQOIKfSj8eUWu6eAXdgjy5S4/ih8lMx8kLbH4YJy483OaIDwsnxVm2LQkIQh3XcuwGkuEdjPfod7vvctxaQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@smartthings/core-sdk/-/core-sdk-8.3.0.tgz", + "integrity": "sha512-m3nqYx8rkjwoXb6ZrZJYplb6BQWye6jqtD9PgyBlA37tullaMJgdcJ/OuRauY9h2i6m4BEVPTISoy6ziec7YIA==", "requires": { "async-mutex": "^0.4.0", "axios": "^0.28.1", @@ -24457,7 +24456,7 @@ "@oclif/core": "^1.16.3", "@smartthings/cli-lib": "^2.2.4", "@smartthings/cli-testlib": "^2.0.6", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@types/cli-table": "^0.3.0", "@types/eventsource": "^1.1.8", "@types/inquirer": "^8.2.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index ef33fc94..8097820d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -77,7 +77,7 @@ "@oclif/plugin-not-found": "^2.3.1", "@oclif/plugin-plugins": "^2.1.0", "@smartthings/cli-lib": "^2.2.5", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@smartthings/plugin-cli-edge": "^3.3.3", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", diff --git a/packages/cli/src/__tests__/commands/schema/create.test.ts b/packages/cli/src/__tests__/commands/schema/create.test.ts index b5c610dc..cc93a548 100644 --- a/packages/cli/src/__tests__/commands/schema/create.test.ts +++ b/packages/cli/src/__tests__/commands/schema/create.test.ts @@ -2,7 +2,7 @@ import { inputAndOutputItem } from '@smartthings/cli-lib' import SchemaAppCreateCommand from '../../../commands/schema/create' import { addSchemaPermission } from '../../../lib/aws-utils' import { SchemaAppRequest, SchemaCreateResponse, SchemaEndpoint } from '@smartthings/core-sdk' -import { SCHEMA_AWS_PRINCIPAL } from '../../../lib/commands/schema-util' +import { SCHEMA_AWS_PRINCIPAL, SchemaAppWithOrganization } from '../../../lib/commands/schema-util' jest.mock('../../../lib/aws-utils') @@ -38,11 +38,16 @@ describe('SchemaAppCreateCommand', () => { 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, schemaAppRequest)).resolves.toStrictEqual(schemaCreateResponse) - expect(createSpy).toBeCalledWith(schemaAppRequest) + 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 () => { @@ -72,7 +77,7 @@ describe('SchemaAppCreateCommand', () => { 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) + expect(createSpy).toBeCalledWith(schemaAppRequest, undefined) }) it('throws error if authorize flag is used on non-lambda app', async () => { @@ -101,7 +106,7 @@ describe('SchemaAppCreateCommand', () => { await expect(actionFunction(undefined, schemaAppRequest)).resolves.not.toThrow() expect(addSchemaPermissionMock).toBeCalledTimes(0) - expect(createSpy).toBeCalledWith(schemaAppRequest) + expect(createSpy).toBeCalledWith(schemaAppRequest, undefined) }) it('passes principal flag to addSchemaPermission', async () => { diff --git a/packages/cli/src/__tests__/commands/schema/update.test.ts b/packages/cli/src/__tests__/commands/schema/update.test.ts index d3c268ce..8148f7e3 100644 --- a/packages/cli/src/__tests__/commands/schema/update.test.ts +++ b/packages/cli/src/__tests__/commands/schema/update.test.ts @@ -1,7 +1,10 @@ -import { inputItem, IOFormat, selectFromList } from '@smartthings/cli-lib' import { SchemaApp, SchemaAppRequest, SchemaEndpoint } from '@smartthings/core-sdk' -import { addSchemaPermission } from '../../../lib/aws-utils' + +import { inputItem, IOFormat, selectFromList } from '@smartthings/cli-lib' + import SchemaUpdateCommand from '../../../commands/schema/update' +import { addSchemaPermission } from '../../../lib/aws-utils' +import { SchemaAppWithOrganization } from '../../../lib/commands/schema-util' jest.mock('../../../lib/aws-utils') @@ -12,8 +15,12 @@ describe('SchemaUpdateCommand', () => { const listSpy = jest.spyOn(SchemaEndpoint.prototype, 'list') const logSpy = jest.spyOn(SchemaUpdateCommand.prototype, 'log').mockImplementation() - const schemaAppRequest = { appName: 'schemaApp' } as SchemaAppRequest - const inputItemMock = jest.mocked(inputItem).mockResolvedValue([schemaAppRequest, IOFormat.JSON]) + const schemaAppRequest = { appName: 'schemaApp' } as SchemaAppWithOrganization + const schemaAppRequestWithOrganization = { + ...schemaAppRequest, + organizationId: 'organization-id', + } as SchemaAppWithOrganization + const inputItemMock = jest.mocked(inputItem).mockResolvedValue([schemaAppRequestWithOrganization, IOFormat.JSON]) const addSchemaPermissionMock = jest.mocked(addSchemaPermission) const selectFromListMock = jest.mocked(selectFromList).mockResolvedValue('schemaAppId') @@ -54,7 +61,7 @@ describe('SchemaUpdateCommand', () => { it('calls correct update endpoint', async () => { await expect(SchemaUpdateCommand.run([])).resolves.not.toThrow() - expect(updateSpy).toBeCalledWith('schemaAppId', schemaAppRequest) + expect(updateSpy).toBeCalledWith('schemaAppId', schemaAppRequest, 'organization-id') }) it('logs to stdout when updated', async () => { @@ -107,6 +114,6 @@ describe('SchemaUpdateCommand', () => { await expect(SchemaUpdateCommand.run(['--authorize'])).resolves.not.toThrow() expect(addSchemaPermissionMock).toBeCalledTimes(0) - expect(updateSpy).toBeCalledWith('schemaAppId', noArnSchemaRequest) + expect(updateSpy).toBeCalledWith('schemaAppId', noArnSchemaRequest, undefined) }) }) diff --git a/packages/cli/src/__tests__/lib/commands/schema-util.test.ts b/packages/cli/src/__tests__/lib/commands/schema-util.test.ts index f445ce41..1e233955 100644 --- a/packages/cli/src/__tests__/lib/commands/schema-util.test.ts +++ b/packages/cli/src/__tests__/lib/commands/schema-util.test.ts @@ -1,4 +1,10 @@ -import { SchemaApp, SchemaAppRequest, SmartThingsClient, ViperAppLinks } from '@smartthings/core-sdk' +import { + OrganizationResponse, + SchemaApp, + SchemaAppRequest, + SmartThingsClient, + ViperAppLinks, +} from '@smartthings/core-sdk' import { APICommand, @@ -13,7 +19,6 @@ import { optionalDef, optionalStringDef, selectFromList, - SmartThingsCommandInterface, staticDef, stringDef, stringTranslateToId, @@ -45,6 +50,7 @@ jest.mock('@smartthings/cli-lib', () => { clipToMaximum: jest.fn(), listSelectionDef: jest.fn(), objectDef: jest.fn(), + selectDef: jest.fn(), optionalDef: jest.fn(), optionalStringDef: jest.fn(), selectFromList: jest.fn(), @@ -67,7 +73,20 @@ const stringDefMock = jest.mocked(stringDef) const generatedStringDef = { name: 'Generated String Def' } as InputDefinition const lambdaInitialValue = { appName: 'Schema App', hostingType: 'lambda' } as SchemaAppRequest const webhookInitialValue = { appName: 'Schema App', hostingType: 'webhook' } as SchemaAppRequest -const commandMock = { profile: {} } as unknown as SmartThingsCommandInterface +const organizations = [ + { name: 'Organization 1', organizationId: 'organization-id-1' }, + { name: 'Organization 2', organizationId: 'organization-id-2' }, +] as OrganizationResponse[] +const organizationListMock = jest.fn().mockResolvedValue(organizations) +const clientMock = { + organizations: { + list: organizationListMock, + }, +} as unknown as SmartThingsClient +const commandMock = { + profile: {}, + client: clientMock, +} as unknown as APICommand describe('arnDef', () => { const generatedARNDef = { name: 'Generated ARN Def' } as InputDefinition @@ -221,7 +240,10 @@ test.each` describe('buildInputDefinition', () => { const mockARNInputDef = {} as InputDefinition const mockWebHookUrlDef = {} as InputDefinition - const chinaCommandMock = { profile: { clientIdProvider: { baseURL: 'base-url.cn' } } } as unknown as SmartThingsCommandInterface + const chinaCommandMock = { + profile: { clientIdProvider: { baseURL: 'base-url.cn' } }, + client: clientMock, + } as unknown as APICommand const appLinksDefMock = { name: 'Generated App Links Def' } as InputDefinition const generatedDef = { name: 'Final Generated Def' } as InputDefinition @@ -232,24 +254,24 @@ describe('buildInputDefinition', () => { afterEach(() => jest.clearAllMocks()) - it('includes choice of webhook or lambda for global', () => { - expect(buildInputDefinition(commandMock)).toBe(generatedDef) + it('includes choice of webhook or lambda for global', async () => { + expect(await buildInputDefinition(commandMock)).toBe(generatedDef) expect(staticDefMock).toHaveBeenCalledTimes(2) expect(listSelectionDef).toHaveBeenCalledTimes(1) expect(listSelectionDef).toHaveBeenCalledWith('Hosting Type', ['lambda', 'webhook'], { default: 'webhook' }) }) - it('skips webhook for China', () => { - expect(buildInputDefinition(chinaCommandMock)).toBe(generatedDef) + it('skips webhook for China', async () => { + expect(await buildInputDefinition(chinaCommandMock)).toBe(generatedDef) expect(staticDefMock).toHaveBeenCalledTimes(3) expect(staticDefMock).toHaveBeenCalledWith('lambda') expect(listSelectionDefMock).toHaveBeenCalledTimes(0) }) - it('defaults appName to partnerName', () => { - expect(buildInputDefinition(commandMock)).toBe(generatedDef) + it('defaults appName to partnerName', async () => { + expect(await buildInputDefinition(commandMock)).toBe(generatedDef) const defaultFunction = optionalStringDefMock.mock.calls[0][1]?.default as DefaultValueFunction expect(defaultFunction).toBeDefined() @@ -260,11 +282,14 @@ describe('buildInputDefinition', () => { expect(defaultFunction([{ partnerName: 'Partner Name' }])).toBe('Partner Name') }) - it('uses global ARN fields for global', () => { + it('uses global ARN fields for global', async () => { const arnDefSpy = jest.spyOn(schemaUtil, 'arnDef').mockImplementation(() => mockARNInputDef) - const commandMock = { profile: { clientIdProvider: { baseURL: 'api.smartthings.com' } } } as unknown as SmartThingsCommandInterface + const commandMock = { + profile: { clientIdProvider: { baseURL: 'api.smartthings.com' } }, + client: clientMock, + } as unknown as APICommand - expect(buildInputDefinition(commandMock)).toBe(generatedDef) + expect(await buildInputDefinition(commandMock)).toBe(generatedDef) expect(arnDefSpy).toHaveBeenCalledTimes(4) expect(arnDefSpy).toHaveBeenCalledWith('Lambda ARN for US region', false, undefined) @@ -273,10 +298,10 @@ describe('buildInputDefinition', () => { expect(arnDefSpy).toHaveBeenCalledWith('Lambda ARN for CN region', false, undefined, { forChina: true }) }) - it('uses lambdaArnCN fields for China', () => { + it('uses lambdaArnCN fields for China', async () => { const arnDefSpy = jest.spyOn(schemaUtil, 'arnDef').mockImplementation(() => mockARNInputDef) - expect(buildInputDefinition(chinaCommandMock, lambdaInitialValue)).toBe(generatedDef) + expect(await buildInputDefinition(chinaCommandMock, lambdaInitialValue)).toBe(generatedDef) expect(arnDefSpy).toHaveBeenCalledTimes(4) expect(arnDefSpy).toHaveBeenCalledWith('Lambda ARN for US region', true, lambdaInitialValue) @@ -285,11 +310,11 @@ describe('buildInputDefinition', () => { expect(arnDefSpy).toHaveBeenCalledWith('Lambda ARN for CN region', true, lambdaInitialValue, { forChina: true }) }) - it('provides for app links when selected', () => { + it('provides for app links when selected', async () => { // Spy on this function just to reduce calls to optionalDef, making it easier to isolate the one we are testing. jest.spyOn(schemaUtil, 'arnDef').mockImplementation(() => mockARNInputDef) - expect(buildInputDefinition(commandMock)).toBe(generatedDef) + expect(await buildInputDefinition(commandMock)).toBe(generatedDef) expect(optionalDefMock).toHaveBeenCalledTimes(2) expect(optionalDefMock).toHaveBeenCalledWith(appLinksDefMock, expect.any(Function), { initiallyActive: false }) @@ -305,21 +330,21 @@ describe('buildInputDefinition', () => { expect(isActiveFunction([{ includeAppLinks: false }])).toBe(false) }) - it('starts out with viperAppLinks initiallyActive set to false when initialValue has links', () => { + it('starts out with viperAppLinks initiallyActive set to false when initialValue has links', async () => { // Spy on this function just to reduce calls to optionalDef, making it easier to isolate the one we are testing. jest.spyOn(schemaUtil, 'arnDef').mockImplementation(() => mockARNInputDef) - expect(buildInputDefinition(commandMock, { viperAppLinks: undefined } as SchemaAppRequest)).toBe(generatedDef) + expect(await buildInputDefinition(commandMock, { viperAppLinks: undefined } as SchemaAppRequest)).toBe(generatedDef) expect(optionalDefMock).toHaveBeenCalledTimes(2) expect(optionalDefMock).toHaveBeenCalledWith(appLinksDefMock, expect.any(Function), { initiallyActive: false }) }) - it('starts out with viperAppLinks initiallyActive set to true when initialValue has links', () => { + it('starts out with viperAppLinks initiallyActive set to true when initialValue has links', async () => { // Spy on this function just to reduce calls to optionalDef, making it easier to isolate the one we are testing. jest.spyOn(schemaUtil, 'arnDef').mockImplementation(() => mockARNInputDef) - expect(buildInputDefinition(commandMock, { viperAppLinks: {} } as SchemaAppRequest)).toBe(generatedDef) + expect(await buildInputDefinition(commandMock, { viperAppLinks: {} } as SchemaAppRequest)).toBe(generatedDef) expect(optionalDefMock).toHaveBeenCalledTimes(2) expect(optionalDefMock).toHaveBeenCalledWith(appLinksDefMock, expect.any(Function), { initiallyActive: true }) @@ -329,7 +354,7 @@ describe('buildInputDefinition', () => { const webHookUrlDefSpy = jest.spyOn(schemaUtil, 'webHookUrlDef').mockImplementation(() => mockWebHookUrlDef) const initialValue = { appName: 'My Schema App' } as SchemaAppRequest - expect(buildInputDefinition(commandMock, initialValue)).toBe(generatedDef) + expect(await buildInputDefinition(commandMock, initialValue)).toBe(generatedDef) expect(webHookUrlDefSpy).toHaveBeenCalledTimes(1) expect(webHookUrlDefSpy).toHaveBeenCalledWith(false, initialValue) @@ -338,7 +363,7 @@ describe('buildInputDefinition', () => { test('getSchemaAppUpdateFromUser', async () => { const generatedDef = { name: 'Final Generated Def' } as InputDefinition - jest.spyOn(schemaUtil, 'buildInputDefinition').mockImplementation(() => generatedDef) + jest.spyOn(schemaUtil, 'buildInputDefinition').mockImplementation(() => Promise.resolve(generatedDef)) const updateFromUserInputMock = jest.mocked(updateFromUserInput).mockResolvedValueOnce({ appName: 'User-updated App Name', includeAppLinks: true, @@ -349,8 +374,8 @@ test('getSchemaAppUpdateFromUser', async () => { appName: 'User-updated App Name', }) - expect(buildInputDefinition).toHaveBeenCalledTimes(1) - expect(buildInputDefinition).toHaveBeenCalledWith(commandMock, original) + expect(await buildInputDefinition).toHaveBeenCalledTimes(1) + expect(await buildInputDefinition).toHaveBeenCalledWith(commandMock, original) expect(updateFromUserInputMock).toHaveBeenCalledTimes(1) expect(updateFromUserInputMock).toHaveBeenCalledWith(commandMock, generatedDef, @@ -360,7 +385,7 @@ test('getSchemaAppUpdateFromUser', async () => { test('getSchemaAppCreateFromUser', async () => { const generatedDef = { name: 'Final Generated Def' } as InputDefinition - jest.spyOn(schemaUtil, 'buildInputDefinition').mockImplementation(() => generatedDef) + jest.spyOn(schemaUtil, 'buildInputDefinition').mockImplementation(() => Promise.resolve(generatedDef)) const createFromUserInputMock = jest.mocked(createFromUserInput).mockResolvedValueOnce({ appName: 'User-updated App Name', includeAppLinks: false, diff --git a/packages/cli/src/commands/schema/create.ts b/packages/cli/src/commands/schema/create.ts index ca4b29e3..e3156f2d 100644 --- a/packages/cli/src/commands/schema/create.ts +++ b/packages/cli/src/commands/schema/create.ts @@ -1,6 +1,6 @@ import { Flags } from '@oclif/core' -import { SchemaAppRequest, SchemaCreateResponse } from '@smartthings/core-sdk' +import { SchemaCreateResponse } from '@smartthings/core-sdk' import { APIOrganizationCommand, @@ -10,7 +10,11 @@ import { } from '@smartthings/cli-lib' import { addSchemaPermission } from '../../lib/aws-utils' -import { SCHEMA_AWS_PRINCIPAL, getSchemaAppCreateFromUser } from '../../lib/commands/schema-util' +import { + SCHEMA_AWS_PRINCIPAL, + SchemaAppWithOrganization, + getSchemaAppCreateFromUser, +} from '../../lib/commands/schema-util' export default class SchemaAppCreateCommand extends APIOrganizationCommand { @@ -27,7 +31,8 @@ export default class SchemaAppCreateCommand extends APIOrganizationCommand { - const createApp = async (_: void, data: SchemaAppRequest): 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 @@ -49,7 +54,7 @@ export default class SchemaAppCreateCommand extends APIOrganizationCommand { @@ -44,10 +51,13 @@ export default class SchemaUpdateCommand extends APIOrganizationCommand => { const original = await this.client.schema.get(id) + if (original.certificationStatus === 'wwst' || original.certificationStatus === 'cst') { + this.cancel('Schema apps that have already been certified cannot be updated via the CLI.') + } return getSchemaAppUpdateFromUser(this, original, this.flags['dry-run']) } - const [request] = await inputItem(this, userInputProcessor(getInputFromUser)) + const [request] = await inputItem(this, userInputProcessor(getInputFromUser)) if (this.flags.authorize) { if (request.hostingType === 'lambda') { if (request.lambdaArn) { @@ -66,7 +76,8 @@ export default class SchemaUpdateCommand extends APIOrganizationCommand { if ( schemaAppRequest.hostingType === 'lambda' @@ -73,7 +79,29 @@ export const validateFinal = (schemaAppRequest: InputData): true | string => { export const appLinksDefSummarize = (value?: ViperAppLinks): string => clipToMaximum(`android: ${value?.android}, ios: ${value?.ios}`, maxItemValueLength) -export const buildInputDefinition = (command: SmartThingsCommandInterface, initialValue?: SchemaAppRequest): InputDefinition => { +export const organizationDef = (organizations: OrganizationResponse[]): InputDefinition => { + if (organizations.length < 1) { + return undefinedDef + } + if (organizations.length === 1) { + return staticDef(organizations[0].organizationId) + } + + const choices = organizations + .map(organization => ({ + name: organization.name, + value: organization.organizationId, + })) + + const helpText = 'The organization with which the Schema connector should be associated.' + + return selectDef('Organization', choices, { helpText }) +} + +export const buildInputDefinition = async ( + command: APICommand, + initialValue?: SchemaApp, +): Promise> => { // TODO: should do more type checking on this, perhaps using zod or const baseURL = (command.profile.clientIdProvider as SmartThingsURLProvider | undefined)?.baseURL const inChina = typeof baseURL === 'string' && baseURL.endsWith('cn') @@ -89,6 +117,7 @@ export const buildInputDefinition = (command: SmartThingsCommandInterface, initi }, { summarizeForEdit: appLinksDefSummarize }) return objectDef('Schema App', { + organizationId: organizationDef(await command.client.organizations.list()), partnerName: stringDef('Partner Name'), userEmail: stringDef('User email', { validate: emailValidate }), appName: optionalStringDef('App Name', { @@ -116,7 +145,7 @@ export const buildInputDefinition = (command: SmartThingsCommandInterface, initi }, { validateFinal }) } -const stripTempInputFields = (inputData: InputData): SchemaAppRequest => { +const stripTempInputFields = (inputData: InputData): SchemaAppWithOrganization => { // Strip out extra temporary data to make the `InputData` into a `SchemaAppRequest`. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { includeAppLinks, ...result } = inputData @@ -124,8 +153,11 @@ const stripTempInputFields = (inputData: InputData): SchemaAppRequest => { return result } -export const getSchemaAppUpdateFromUser = async (command: SmartThingsCommandInterface, original: SchemaApp, dryRun: boolean): Promise => { - const inputDef = buildInputDefinition(command, original) +export const getSchemaAppUpdateFromUser = async ( + command: APICommand, + original: SchemaApp, dryRun: boolean, +): Promise => { + const inputDef = await buildInputDefinition(command, original) const inputData = await updateFromUserInput(command, inputDef, { ...original, includeAppLinks: !!original.viperAppLinks }, { dryRun }) @@ -133,8 +165,11 @@ export const getSchemaAppUpdateFromUser = async (command: SmartThingsCommandInte return stripTempInputFields(inputData) } -export const getSchemaAppCreateFromUser = async (command: SmartThingsCommandInterface, dryRun: boolean): Promise => { - const inputDef = buildInputDefinition(command) +export const getSchemaAppCreateFromUser = async ( + command: APICommand, + dryRun: boolean, +): Promise => { + const inputDef = await buildInputDefinition(command) const inputData = await createFromUserInput(command, inputDef, { dryRun }) diff --git a/packages/edge/package.json b/packages/edge/package.json index 73e2ec26..a58efa1c 100644 --- a/packages/edge/package.json +++ b/packages/edge/package.json @@ -48,7 +48,7 @@ "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.16.3", "@smartthings/cli-lib": "^2.2.4", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "axios": "^0.28.0", "inquirer": "^8.2.4", "js-yaml": "^4.1.0", diff --git a/packages/lib/package.json b/packages/lib/package.json index 5f76287d..76075c48 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -30,7 +30,7 @@ "dependencies": { "@log4js-node/log4js-api": "^1.0.2", "@oclif/core": "^1.16.3", - "@smartthings/core-sdk": "^8.2.0", + "@smartthings/core-sdk": "^8.3.0", "@types/eventsource": "^1.1.9", "axios": "^0.28.0", "chalk": "^4.1.2", diff --git a/packages/lib/src/__tests__/item-input/command-helpers.test.ts b/packages/lib/src/__tests__/item-input/command-helpers.test.ts index d6860742..9bbdb706 100644 --- a/packages/lib/src/__tests__/item-input/command-helpers.test.ts +++ b/packages/lib/src/__tests__/item-input/command-helpers.test.ts @@ -56,7 +56,7 @@ describe('updateFromUserInput', () => { { name: 'Preview JSON.', value: expect.any(Symbol) }, { name: 'Preview YAML.', value: expect.any(Symbol) }, { name: 'Finish and update Item-to-Input.', value: finishAction }, - { name: 'Cancel creating Item-to-Input.', value: cancelAction }, + { name: 'Cancel update of Item-to-Input.', value: cancelAction }, ], })) @@ -82,13 +82,13 @@ describe('updateFromUserInput', () => { it('uses specified finishVerb', async () => { promptMock.mockResolvedValueOnce({ action: finishAction }) - expect(await updateFromUserInput(commandMock, inputDefMock, 'input value', { dryRun: false, finishVerb: 'run' })) + expect(await updateFromUserInput(commandMock, inputDefMock, 'input value', { dryRun: false, finishVerb: 'create' })) .toBe('input value') expect(promptMock).toHaveBeenCalledTimes(1) expect(promptMock).toHaveBeenCalledWith(expect.objectContaining({ choices: expect.arrayContaining([ - { name: 'Finish and run Item-to-Input.', value: finishAction }, + { name: 'Finish and create Item-to-Input.', value: finishAction }, ]), })) }) diff --git a/packages/lib/src/item-input/array.ts b/packages/lib/src/item-input/array.ts index d6ef48e1..1b13d03b 100644 --- a/packages/lib/src/item-input/array.ts +++ b/packages/lib/src/item-input/array.ts @@ -1,4 +1,5 @@ import inquirer, { ChoiceCollection, DistinctChoice, Separator } from 'inquirer' + import { clipToMaximum, stringFromUnknown } from '../util' import { @@ -51,7 +52,7 @@ export type ArrayDefOptions = { } /** - * InputDefinition for an array of items. + * InputDefinition for an array of items, entered via the supplied `InputDefinition`, `inputDef`. * * @param itemDef Definition used to input or edit a single item. * @param options See definition of `ArrayDefOptions` for more details. @@ -233,7 +234,7 @@ export function checkboxDef(name: string, items: CheckboxDefItem[], option return updatedValues as T[] } - const buildFromUserInput = async (): Promise => editValues(options?.default ?? []) + const buildFromUserInput = (): Promise => editValues(options?.default ?? []) const summarizeForEdit = options?.summarizeForEdit ?? ((value: T[]) => clipToMaximum(value.map(item => stringFromUnknown(item)).join(', '), maxItemValueLength)) diff --git a/packages/lib/src/item-input/command-helpers.ts b/packages/lib/src/item-input/command-helpers.ts index 505496a6..177b5357 100644 --- a/packages/lib/src/item-input/command-helpers.ts +++ b/packages/lib/src/item-input/command-helpers.ts @@ -24,7 +24,7 @@ export type UpdateFromUserInputOptions = { /** * The verb to use when indicating completion. The default is 'update'. */ - finishVerb?: string + finishVerb?: 'create' | 'update' } export const updateFromUserInput = async (command: SmartThingsCommandInterface, inputDefinition: InputDefinition, previousValue: T, options: UpdateFromUserInputOptions): Promise => { @@ -69,7 +69,10 @@ export const updateFromUserInput = async (command: SmartThings name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`, value: finishAction, }, - { name: `Cancel creating ${inputDefinition.name}.`, value: cancelAction }, + { + name: `Cancel ${options.finishVerb === 'create' ? 'creation' : 'update'} of ${inputDefinition.name}.`, + value: cancelAction, + }, ] const action = (await inquirer.prompt({ diff --git a/packages/lib/src/item-input/index.ts b/packages/lib/src/item-input/index.ts index 48245c08..5f4a86de 100644 --- a/packages/lib/src/item-input/index.ts +++ b/packages/lib/src/item-input/index.ts @@ -2,4 +2,5 @@ export * from './defs' export * from './misc' export * from './array' export * from './object' +export * from './select' export * from './command-helpers' diff --git a/packages/lib/src/item-input/misc.ts b/packages/lib/src/item-input/misc.ts index 3746474f..02abf87b 100644 --- a/packages/lib/src/item-input/misc.ts +++ b/packages/lib/src/item-input/misc.ts @@ -1,4 +1,5 @@ import inquirer, { ChoiceCollection } from 'inquirer' + import { askForString, askForOptionalString, diff --git a/packages/lib/src/item-input/select.ts b/packages/lib/src/item-input/select.ts new file mode 100644 index 00000000..1f4eb075 --- /dev/null +++ b/packages/lib/src/item-input/select.ts @@ -0,0 +1,83 @@ +import inquirer, { ChoiceCollection, Separator } from 'inquirer' + +import { CancelAction, cancelOption, helpAction, helpOption, InputDefinition, inquirerPageSize, maxItemValueLength } from './defs' +import { clipToMaximum, stringFromUnknown } from '../util' + + +export type SelectDefOptions = { + /** + * A summary of the chosen item to display to the user in a list. + * + * If the choices are simple strings or numbers, the default is to display the string or + * number. For complex choices, the default function uses the `name` field. + */ + summarizeForEdit?: (item: T, context?: unknown[]) => string + + default?: T + + helpText?: string +} + +export type SelectDefChoice = + | string + | number + | { name: string; value: T } + +/** + * InputDefinition for choosing a single item from a list. + * + * @param choices List of choices to present the user. + * @param options See definition of `SelectDefOptions` for more details. + */ +export function selectDef( + name: string, + choices: SelectDefChoice[], + options?: SelectDefOptions, +): InputDefinition { + const nameOfChoice = (choice: SelectDefChoice): string => + typeof choice === 'object' ? choice.name : choice.toString() + const valueOfChoice = (choice: SelectDefChoice): T => + (typeof choice === 'number' || typeof choice === 'string' ? choice : choice.value) as T + + const namesByValue = new Map(choices.map(choice => [valueOfChoice(choice), nameOfChoice(choice)])) + + const editValue = async (defaultSelection: T | undefined): Promise => { + const inquirerChoices: ChoiceCollection = choices.map(choice => ({ + name: nameOfChoice(choice), + value: valueOfChoice(choice), + })) + + inquirerChoices.push(new Separator()) + if (options?.helpText) { + inquirerChoices.push(helpOption) + } + inquirerChoices.push(cancelOption) + + // eslint-disable-next-line no-constant-condition + while (true) { + const selection = (await inquirer.prompt({ + type: 'list', + name: 'selection', + message: `Select ${name}.`, + choices: inquirerChoices, + default: defaultSelection ?? 0, + pageSize: inquirerPageSize, + })).selection + + if (selection === helpAction) { + console.log(`\n${options?.helpText}\n`) + } else { + return selection + } + } + } + + const buildFromUserInput = (): Promise => editValue(options?.default) + + const summarizeForEdit = options?.summarizeForEdit + ?? ((value: T) => clipToMaximum((namesByValue.get(value)) ?? stringFromUnknown(value), maxItemValueLength)) + + const updateFromUserInput = (original: T): Promise => editValue(original) + + return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput } +} diff --git a/packages/testlib/package.json b/packages/testlib/package.json index c770610d..85aa8b6e 100644 --- a/packages/testlib/package.json +++ b/packages/testlib/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@smartthings/cli-lib": "^2.2.4", - "@smartthings/core-sdk": "^8.2.0" + "@smartthings/core-sdk": "^8.3.0" }, "devDependencies": { "@types/jest": "^28.1.5",