diff --git a/.changeset/many-things-enjoy.md b/.changeset/many-things-enjoy.md new file mode 100644 index 00000000..b94d1b2b --- /dev/null +++ b/.changeset/many-things-enjoy.md @@ -0,0 +1,18 @@ +--- +"@smartthings/cli": major +--- + +Updated to use new smartapps endpoints for managing API Access apps. + +* All commands under the old `apps` hierarchy have been renamed to `smartapps`. +* The command to resend a confirmation URL to a Smart App has been renamed from `apps:register` to + `smartapps:confirm`. +* Lambda and Webhook Smart Apps can no longer be created or updated. Related commands have been removed. +* OAuth-In Smart Apps are now referred to as API Access Smart Apps. +* When creating an API Access Smart App, an account/organization id is now associated with the Smart App. + If the user has access to more than one organization, they will be prompted for which to use. +* Two new fields are available for API Access Smart Apps: + * privacyPolicyUrl (required for business accounts) + * corsDomains + +Improved error messages when input or output is non-interactive and user does not specify everything on the command line. diff --git a/README.md b/README.md index f4fca84c..8c04eeb6 100644 --- a/README.md +++ b/README.md @@ -140,17 +140,6 @@ smartthings edge:channels:create --help | Command | Description | | -- | -- | -| apps [id-or-index] | get a specific app or a list of apps | -| apps:authorize | authorize calls to your AWS Lambda function from SmartThings | -| apps:create | create an app | -| apps:delete [id] | delete an app | -| apps:oauth [id-or-index] | get OAuth information for an app | -| apps:oauth:generate [id] | regenerate the OAuth clientId and clientSecret of an app | -| apps:oauth:update [id] | update the OAuth settings of an app | -| apps:register [id] | send request to app target URL to confirm existence and authorize lifecycle events | -| apps:settings [id-or-index] | get the settings of an app | -| apps:settings:update [id] | update the settings of an app | -| apps:update [id] | update the settings of the app | | capabilities [id-or-index] | get a specific capability or a list of capabilities | | capabilities:create | create a capability | | capabilities:delete [id] | delete a capability | @@ -266,6 +255,16 @@ smartthings edge:channels:create --help | schema:delete [id] | unlink a Schema App from smartthings | | schema:regenerate [id] | regenerate the client id and secret of the Schema App link | | schema:update [id] | update a link to a Schema App | +| smartapps [id-or-index] | get a specific Smart App or a list of Smart Apps | +| smartapps:confirm [id] | send request to Smart App target URL to confirm existence and authorize lifecycle events | +| smartapps:create | create a Smart App | +| smartapps:delete [id] | delete a Smart App | +| smartapps:oauth [id-or-index] | get OAuth information for a Smart App | +| smartapps:oauth:generate [id] | regenerate the OAuth clientId and clientSecret of a Smart App | +| smartapps:oauth:update [id] | update the OAuth settings of a Smart App | +| smartapps:settings [id-or-index] | get the settings of a Smart App | +| smartapps:settings:update [id] | update the settings of a Smart App | +| smartapps:update [id] | update a Smart App | | virtualdevices [id-or-index] | list all virtual devices available in a user account or retrieve a single device | | virtualdevices:create | create a virtual device from a device profile id or definition | | virtualdevices:create-standard | create a virtual device from one of the standard prototypes | diff --git a/jest.config.ts b/jest.config.ts index 1e050341..48b6711f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -18,6 +18,7 @@ const jestConfig: JestConfigWithTsJest = { '/src/commands/index.ts', '/src/lib/command/util/st-client-wrapper.ts', '/src/lib/colors.ts', + '/src/build-tools', ], modulePathIgnorePatterns: [ '/dist', diff --git a/src/__tests__/commands/apps.test.ts b/src/__tests__/commands/apps.test.ts deleted file mode 100644 index 17ec7a06..00000000 --- a/src/__tests__/commands/apps.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase, Argv, Options } from 'yargs' - -import { - AppClassification, - type AppResponse, - AppType, - type AppsEndpoint, - type PagedApp, - type SmartThingsClient, -} from '@smartthings/core-sdk' - -import type { buildEpilog } from '../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../lib/command/api-command.js' -import type { outputItemOrList, outputItemOrListBuilder } from '../../lib/command/listing-io.js' -import type { CommandArgs } from '../../commands/apps.js' -import type { ListDataFunction } from '../../lib/command/io-defs.js' -import type { BuildOutputFormatterFlags } from '../../lib/command/output-builder.js' -import type { SmartThingsCommandFlags } from '../../lib/command/smartthings-command.js' -import type { shortARNorURL, verboseApps } from '../../lib/command/util/apps-util.js' -import { apiCommandMocks } from '../test-lib/api-command-mock.js' -import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js' - - -const buildEpilogMock = jest.fn() -jest.unstable_mockModule('../../lib/help.js', () => ({ - buildEpilog: buildEpilogMock, -})) - -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() - -const outputItemOrListMock = jest.fn>() -const outputItemOrListBuilderMock = jest.fn() -jest.unstable_mockModule('../../lib/command/listing-io.js', () => ({ - outputItemOrList: outputItemOrListMock, - outputItemOrListBuilder: outputItemOrListBuilderMock, -})) - -const shortARNorURLMock = jest.fn() -const verboseAppsMock = jest.fn() -jest.unstable_mockModule('../../lib/command/util/apps-util.js', () => ({ - shortARNorURL: shortARNorURLMock, - verboseApps: verboseAppsMock, - tableFieldDefinitions: [], -})) - - -const { default: cmd } = await import('../../commands/apps.js') - - -describe('builder', () => { - const yargsMock = buildArgvMockStub() - const { - yargsMock: apiCommandBuilderArgvMock, - positionalMock, - optionMock, - exampleMock, - epilogMock, - argvMock, - } = buildArgvMock() - - apiCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock) - outputItemOrListBuilderMock.mockReturnValue(argvMock) - - const builder = cmd.builder as (yargs: Argv) => Argv - - it('calls correct parent and yargs functions', () => { - expect(builder(yargsMock)).toBe(argvMock) - - expect(apiCommandBuilderMock).toHaveBeenCalledTimes(1) - expect(apiCommandBuilderMock).toHaveBeenCalledWith(yargsMock) - expect(outputItemOrListBuilderMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListBuilderMock).toHaveBeenCalledWith(apiCommandBuilderArgvMock) - expect(positionalMock).toHaveBeenCalledTimes(1) - expect(optionMock).toHaveBeenCalledTimes(3) - expect(exampleMock).toHaveBeenCalledTimes(1) - expect(buildEpilogMock).toHaveBeenCalledTimes(1) - expect(epilogMock).toHaveBeenCalledTimes(1) - }) - - // A simplified version of the type of the `Argv.option` that matches the way we call it. - type OptionMock = jest.Mock<(key: string, options?: Options) => Argv> - - it('accepts upper or lowercase types', () => { - expect(builder(yargsMock)).toBe(argvMock) - - const typeCoerce = (optionMock as OptionMock).mock.calls[0][1]?.coerce - expect(typeCoerce).toBeDefined() - expect(typeCoerce?.('LAMBDA_SMART_APP')).toBe(AppType.LAMBDA_SMART_APP) - expect(typeCoerce?.('lambda_smart_app')).toBe(AppType.LAMBDA_SMART_APP) - }) - - it('accepts upper or lowercase classifications', () => { - expect(builder(yargsMock)).toBe(argvMock) - - const typeCoerce = (optionMock as OptionMock).mock.calls[1][1]?.coerce - expect(typeCoerce).toBeDefined() - expect(typeCoerce?.(undefined)).toBe(undefined) - expect(typeCoerce?.([])).toStrictEqual([]) - expect(typeCoerce?.(['automation'])).toStrictEqual([AppClassification.AUTOMATION]) - expect(typeCoerce?.(['automation', 'SERVICE', 'Device'])) - .toStrictEqual([AppClassification.AUTOMATION, AppClassification.SERVICE, AppClassification.DEVICE]) - }) -}) - -describe('handler', () => { - const app = { appId: 'app-id', webhookSmartApp: { targetUrl: 'targetUrl' } } as AppResponse - const appList = [{ appId: 'paged-app-id' }] as PagedApp[] - - const apiAppsListMock = jest.fn() - .mockResolvedValue(appList) - const apiAppsGetMock = jest.fn() - .mockResolvedValue(app) - const clientMock = { - apps: { - list: apiAppsListMock, - get: apiAppsGetMock, - }, - } as unknown as SmartThingsClient - const command = { - client: clientMock, - } as APICommand - apiCommandMock.mockResolvedValue(command) - - const defaultInputArgv = { - profile: 'default', - verbose: false, - } as ArgumentsCamelCase - - it('lists apps without args', async () => { - await expect(cmd.handler(defaultInputArgv)).resolves.not.toThrow() - - expect(apiCommandMock).toHaveBeenCalledTimes(1) - expect(apiCommandMock).toHaveBeenCalledWith(defaultInputArgv) - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - command, - expect.objectContaining({ primaryKeyName: 'appId' }), - undefined, - expect.any(Function), - expect.any(Function), - ) - - apiAppsListMock.mockResolvedValueOnce(appList) - const listFunction = outputItemOrListMock.mock.calls[0][3] - - expect(await listFunction()).toStrictEqual(appList) - - expect(apiAppsListMock).toHaveBeenCalledTimes(1) - expect(apiAppsListMock).toHaveBeenCalledWith({}) - }) - - it('lists details of a specified app', async () => { - const inputArgv = { - ...defaultInputArgv, - idOrIndex: 'app-from-arg', - } as ArgumentsCamelCase - - await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - - expect(apiCommandMock).toHaveBeenCalledTimes(1) - expect(apiCommandMock).toHaveBeenCalledWith(inputArgv) - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - command, - expect.objectContaining({ primaryKeyName: 'appId' }), - 'app-from-arg', - expect.any(Function), - expect.any(Function), - ) - - const getFunction = outputItemOrListMock.mock.calls[0][4] - - expect(await getFunction('chosen-app-id')).toStrictEqual(app) - - expect(apiAppsGetMock).toHaveBeenCalledTimes(1) - expect(apiAppsGetMock).toHaveBeenCalledWith('chosen-app-id') - }) - - const listAppsForArgs = async (args: Partial>): Promise> => { - await expect(cmd.handler({ ...defaultInputArgv, ...args })).resolves.not.toThrow() - - return outputItemOrListMock.mock.calls[0][3] - } - - describe('listApps', () => { - const appType = AppType.LAMBDA_SMART_APP - const automation = AppClassification.AUTOMATION - const service = AppClassification.SERVICE - - it('takes an app type to filter by via flags', async () => { - const listItems = await listAppsForArgs({ type: appType }) - - expect(await listItems()).toBe(appList) - - expect(apiAppsListMock).toHaveBeenCalledTimes(1) - expect(apiAppsListMock).toHaveBeenCalledWith({ appType }) - expect(verboseAppsMock).toHaveBeenCalledTimes(0) - }) - - it('accepts a single classification for filtering', async () => { - const listItems = await listAppsForArgs({ classification: [automation] }) - - expect(await listItems()).toBe(appList) - - expect(apiAppsListMock).toHaveBeenCalledTimes(1) - expect(apiAppsListMock).toHaveBeenCalledWith({ classification: [automation] }) - expect(verboseAppsMock).toHaveBeenCalledTimes(0) - }) - - it('accepts multiple classifications for filtering', async () => { - const classifications = [automation, service] - const listItems = await listAppsForArgs({ classification: classifications }) - - expect(await listItems()).toBe(appList) - - expect(apiAppsListMock).toHaveBeenCalledTimes(1) - expect(apiAppsListMock).toHaveBeenCalledWith({ classification: expect.arrayContaining(classifications) }) - expect(verboseAppsMock).toHaveBeenCalledTimes(0) - }) - }) - - it('includes URLs and ARNs in output when verbose flag is used', async () => { - const listItems = await listAppsForArgs({ verbose: true }) - - expect(outputItemOrListMock).toHaveBeenCalledWith( - command, - expect.objectContaining({ - listTableFieldDefinitions: expect.arrayContaining([{ label: 'ARN/URL', value: shortARNorURLMock }]), - }), - undefined, - expect.any(Function), - expect.any(Function), - ) - - const verboseAppList = [{ appId: 'verbose-app-id' }] as AppResponse[] - verboseAppsMock.mockResolvedValue(verboseAppList) - - expect(await listItems()).toBe(verboseAppList) - - expect(apiAppsListMock).toHaveBeenCalledTimes(0) - expect(verboseAppsMock).toHaveBeenCalledTimes(1) - expect(verboseAppsMock).toHaveBeenCalledWith(clientMock, {}) - }) -}) diff --git a/src/__tests__/commands/apps/authorize.test.ts b/src/__tests__/commands/apps/authorize.test.ts deleted file mode 100644 index f43786f7..00000000 --- a/src/__tests__/commands/apps/authorize.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase, Argv } from 'yargs' - -import type { CommandArgs } from '../../../commands/apps/authorize.js' -import type { addPermission } from '../../../lib/aws-util.js' -import type { buildEpilog } from '../../../lib/help.js' -import type { lambdaAuthBuilder, LambdaAuthFlags } from '../../../lib/command/common-flags.js' -import type { - smartThingsCommand, - smartThingsCommandBuilder, - SmartThingsCommandFlags, -} from '../../../lib/command/smartthings-command.js' -import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' - - -const addPermissionMock = jest.fn() - .mockResolvedValue('permissions updated') -jest.unstable_mockModule('../../../lib/aws-util.js', () => ({ - addPermission: addPermissionMock, -})) - -const buildEpilogMock = jest.fn() -jest.unstable_mockModule('../../../lib/help.js', () => ({ - buildEpilog: buildEpilogMock, -})) - -const lambdaAuthBuilderMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/common-flags.js', () => ({ - lambdaAuthBuilder: lambdaAuthBuilderMock, -})) - -const smartThingsCommandMock = jest.fn() -const smartThingsCommandBuilderMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/smartthings-command.js', () => ({ - smartThingsCommand: smartThingsCommandMock, - smartThingsCommandBuilder: smartThingsCommandBuilderMock, -})) - -const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) - - -const { default: cmd } = await import('../../../commands/apps/authorize.js') - - -test('builder', () => { - const yargsMock = buildArgvMockStub() - const { - yargsMock: smartThingsCommandBuilderArgvMock, - positionalMock, - epilogMock, - exampleMock, - argvMock, - } = buildArgvMock() - - smartThingsCommandBuilderMock.mockReturnValue(smartThingsCommandBuilderArgvMock) - lambdaAuthBuilderMock.mockReturnValueOnce(argvMock) - - const builder = cmd.builder as (yargs: Argv) => Argv - expect(builder(yargsMock)).toBe(argvMock) - - expect(positionalMock).toHaveBeenCalledTimes(1) - expect(exampleMock).toHaveBeenCalledTimes(1) - expect(buildEpilogMock).toHaveBeenCalledTimes(1) - expect(epilogMock).toHaveBeenCalledTimes(1) -}) - -describe('handler', () => { - const inputArgv = - { profile: 'default', arn: 'arn' } as ArgumentsCamelCase - - it('calls addPermission with specified ARN', async () => { - await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - - expect(addPermissionMock) - .toHaveBeenCalledExactlyOnceWith('arn', undefined, undefined) - expect(consoleLogSpy).toHaveBeenCalledWith('permissions updated') - }) - - it('passes on principal and statement', async () => { - await expect(cmd.handler( - { ...inputArgv, principal: 'principal', statement: 'statement' }, - )).resolves.not.toThrow() - - expect(addPermissionMock) - .toHaveBeenCalledExactlyOnceWith('arn', 'principal', 'statement') - }) -}) diff --git a/src/__tests__/commands/apps/delete.test.ts b/src/__tests__/commands/apps/delete.test.ts deleted file mode 100644 index b382859b..00000000 --- a/src/__tests__/commands/apps/delete.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase, Argv } from 'yargs' - -import type { AppsEndpoint, SmartThingsClient } from '@smartthings/core-sdk' - -import type { CommandArgs } from '../../../commands/apps/delete.js' -import type { buildEpilog } from '../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' -import type { chooseApp } from '../../../lib/command/util/apps-util.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' -import { buildArgvMock } from '../../test-lib/builder-mock.js' - - -const buildEpilogMock = jest.fn() -jest.unstable_mockModule('../../../lib/help.js', () => ({ - buildEpilog: buildEpilogMock, -})) - -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() - -const chooseAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - chooseApp: chooseAppMock, -})) - -const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) - - -const { default: cmd } = await import('../../../commands/apps/delete.js') - - -test('builder', () => { - const { - yargsMock, - positionalMock, - exampleMock, - epilogMock, - argvMock, - } = buildArgvMock() - - apiCommandBuilderMock.mockReturnValue(argvMock) - - const builder = cmd.builder as (yargs: Argv) => Argv - expect(builder(yargsMock)).toBe(argvMock) - - expect(apiCommandBuilderMock).toHaveBeenCalledTimes(1) - expect(apiCommandBuilderMock).toHaveBeenCalledWith(yargsMock) - - expect(positionalMock).toHaveBeenCalledTimes(1) - expect(exampleMock).toHaveBeenCalledTimes(1) - expect(buildEpilogMock).toHaveBeenCalledTimes(1) - expect(epilogMock).toHaveBeenCalledTimes(1) -}) - -test('handler', async () => { - const apiAppsDeleteMock = jest.fn() - chooseAppMock.mockResolvedValueOnce('chosen-app-id') - const clientMock = { - apps: { - delete: apiAppsDeleteMock, - }, - } as unknown as SmartThingsClient - const command = { - client: clientMock, - } as APICommand - apiCommandMock.mockResolvedValue(command) - const inputArgv = { profile: 'default', id: 'command-line-id' } as ArgumentsCamelCase - - await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - - expect(apiCommandMock).toHaveBeenCalledTimes(1) - expect(apiCommandMock).toHaveBeenCalledWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledTimes(1) - expect(chooseAppMock).toHaveBeenCalledWith(command, 'command-line-id') - expect(apiAppsDeleteMock).toHaveBeenCalledTimes(1) - expect(apiAppsDeleteMock).toHaveBeenCalledWith('chosen-app-id') - - expect(consoleLogSpy).toHaveBeenLastCalledWith('App chosen-app-id deleted.') -}) diff --git a/src/__tests__/commands/apps/oauth.test.ts b/src/__tests__/commands/apps/oauth.test.ts deleted file mode 100644 index b136f890..00000000 --- a/src/__tests__/commands/apps/oauth.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase, Argv } from 'yargs' - -import type { AppOAuthResponse, AppsEndpoint } from '@smartthings/core-sdk' - -import type { buildEpilog } from '../../../lib/help.js' -import type { CommandArgs } from '../../../commands/apps/oauth.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' -import type { OutputItemOrListFlags } from '../../../lib/command/listing-io.js' -import type { outputItem, outputItemBuilder } from '../../../lib/command/output-item.js' -import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js' -import { type chooseApp, oauthTableFieldDefinitions } from '../../../lib/command/util/apps-util.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' -import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' - - -const buildEpilogMock = jest.fn() -jest.unstable_mockModule('../../../lib/help.js', () => ({ - buildEpilog: buildEpilogMock, -})) - -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() - -const outputItemMock = jest.fn() -const outputItemBuilderMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/output-item.js', () => ({ - outputItem: outputItemMock, - outputItemBuilder: outputItemBuilderMock, -})) - -const chooseAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - chooseApp: chooseAppMock, - oauthTableFieldDefinitions, -})) - - -const { default: cmd } = await import('../../../commands/apps/oauth.js') - - -test('builder', () => { - const yargsMock = buildArgvMockStub() - const { - yargsMock: apiCommandBuilderArgvMock, - positionalMock, - exampleMock, - epilogMock, - argvMock, - } = buildArgvMock() - - apiCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock) - outputItemBuilderMock.mockReturnValue(argvMock) - - const builder = cmd.builder as (yargs: Argv) => Argv - - expect(builder(yargsMock)).toBe(argvMock) - - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(outputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) - expect(positionalMock).toHaveBeenCalledTimes(1) - expect(exampleMock).toHaveBeenCalledTimes(1) - expect(buildEpilogMock).toHaveBeenCalledTimes(1) - expect(epilogMock).toHaveBeenCalledTimes(1) -}) - -test('handler', async () => { - const apiAppsGetOauthMock = jest.fn() - const command = { - client: { - apps: { - getOauth: apiAppsGetOauthMock, - }, - }, - } as unknown as APICommand - - apiCommandMock.mockResolvedValueOnce(command) - chooseAppMock.mockResolvedValueOnce('chosen-app-id') - - const argv = { profile: 'default', idOrIndex: 'cmd-line-id' } as ArgumentsCamelCase - - await expect(cmd.handler(argv)).resolves.not.toThrow() - - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(argv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-id', { allowIndex: true }) - expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( - command, - { tableFieldDefinitions: oauthTableFieldDefinitions }, - expect.any(Function), - ) - - const getFunction = outputItemMock.mock.calls[0][2] - const oauthResponse = { clientName: 'my-client-name' } as AppOAuthResponse - apiAppsGetOauthMock.mockResolvedValueOnce(oauthResponse) - - expect(await getFunction()).toBe(oauthResponse) - - expect(apiAppsGetOauthMock).toHaveBeenCalledExactlyOnceWith('chosen-app-id') -}) diff --git a/src/__tests__/commands/apps/register.test.ts b/src/__tests__/commands/apps/register.test.ts deleted file mode 100644 index ecb8b096..00000000 --- a/src/__tests__/commands/apps/register.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase, Argv } from 'yargs' - -import { type AppsEndpoint, AppType, PagedApp } from '@smartthings/core-sdk' - -import type { CommandArgs } from '../../../commands/apps/register.js' -import type { buildEpilog } from '../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' -import type { chooseApp } from '../../../lib/command/util/apps-util.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' -import { buildArgvMock } from '../../test-lib/builder-mock.js' - - -const buildEpilogMock = jest.fn() -jest.unstable_mockModule('../../../lib/help.js', () => ({ - buildEpilog: buildEpilogMock, -})) - -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() - -const chooseAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - chooseApp: chooseAppMock, -})) - -const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) - - -const { default: cmd } = await import('../../../commands/apps/register.js') - - -test('builder', () => { - const { - yargsMock, - positionalMock, - exampleMock, - epilogMock, - argvMock, - } = buildArgvMock() - - apiCommandBuilderMock.mockReturnValue(argvMock) - - const builder = cmd.builder as (yargs: Argv) => Argv - expect(builder(yargsMock)).toBe(argvMock) - - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - - expect(positionalMock).toHaveBeenCalledTimes(1) - expect(exampleMock).toHaveBeenCalledTimes(1) - expect(buildEpilogMock).toHaveBeenCalledTimes(1) - expect(epilogMock).toHaveBeenCalledTimes(1) -}) - -test('handler', async () => { - const apiAppsRegisterMock = jest.fn() - const command = { - client: { apps: { register: apiAppsRegisterMock } }, - } as unknown as APICommand> - apiCommandMock.mockResolvedValueOnce(command) - chooseAppMock.mockResolvedValueOnce('chosen-id') - const inputArgv = { profile: 'default', id: 'id-from-cmd-line' } as ArgumentsCamelCase - - await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith( - command, - 'id-from-cmd-line', - expect.objectContaining({ listFilter: expect.any(Function) }), - ) - expect(apiAppsRegisterMock).toHaveBeenCalledExactlyOnceWith('chosen-id') - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Registration request sent')) - - const listFilter = chooseAppMock.mock.calls[0][2]?.listFilter - expect(listFilter?.({ appType: AppType.WEBHOOK_SMART_APP } as PagedApp, 0, [])).toBeTrue() - expect(listFilter?.({ appType: AppType.API_ONLY } as PagedApp, 0, [])).toBeTrue() - expect(listFilter?.({ appType: AppType.LAMBDA_SMART_APP } as PagedApp, 0, [])).toBeFalse() -}) diff --git a/src/__tests__/commands/edge/drivers.test.ts b/src/__tests__/commands/edge/drivers.test.ts index 9d2a00a7..147fa7ae 100644 --- a/src/__tests__/commands/edge/drivers.test.ts +++ b/src/__tests__/commands/edge/drivers.test.ts @@ -22,7 +22,6 @@ import type { } from '../../../lib/command/common-flags.js' import type { CustomCommonOutputProducer } from '../../../lib/command/format.js' import type { outputItemOrList, outputItemOrListBuilder } from '../../../lib/command/listing-io.js' -import type { shortARNorURL, verboseApps } from '../../../lib/command/util/apps-util.js' import { type buildTableOutput, type listDrivers, @@ -59,14 +58,6 @@ jest.unstable_mockModule('../../../lib/command/listing-io.js', () => ({ outputItemOrListBuilder: outputItemOrListBuilderMock, })) -const shortARNorURLMock = jest.fn() -const verboseAppsMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - shortARNorURL: shortARNorURLMock, - verboseApps: verboseAppsMock, - tableFieldDefinitions: [], -})) - const buildTableOutputMock = jest.fn() const listDriversMock = jest.fn() jest.unstable_mockModule('../../../lib/command/util/edge-drivers.js', () => ({ diff --git a/src/__tests__/commands/smartapps.test.ts b/src/__tests__/commands/smartapps.test.ts new file mode 100644 index 00000000..2f8f682c --- /dev/null +++ b/src/__tests__/commands/smartapps.test.ts @@ -0,0 +1,341 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv, Options } from 'yargs' + +import { + type HttpClientHeaders, + type OrganizationResponse, + type PagedSmartApp, + type SmartAppResponse, + type SmartAppsEndpoint, + type SmartThingsClient, +} from '@smartthings/core-sdk' + +import type { forAllOrganizations } from '../../lib/api-helpers.js' +import type { buildEpilog } from '../../lib/help.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../lib/command/api-organization-command.js' +import type { AllOrganizationFlags, allOrganizationsBuilder } from '../../lib/command/common-flags.js' +import type { outputItemOrList, outputItemOrListBuilder } from '../../lib/command/listing-io.js' +import type { CommandArgs } from '../../commands/smartapps.js' +import type { ListDataFunction } from '../../lib/command/io-defs.js' +import type { BuildOutputFormatterFlags } from '../../lib/command/output-builder.js' +import type { shortARNorURL, verboseSmartApps } from '../../lib/command/util/smartapps-util.js' +import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js' + + +const forAllOrganizationsMock = jest.fn() +jest.unstable_mockModule('../../lib/api-helpers.js', () => ({ + forAllOrganizations: forAllOrganizationsMock, +})) + +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const allOrganizationsBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/common-flags.js', () => ({ + allOrganizationsBuilder: allOrganizationsBuilderMock, +})) + +const outputItemOrListMock = jest.fn>() +const outputItemOrListBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/listing-io.js', () => ({ + outputItemOrList: outputItemOrListMock, + outputItemOrListBuilder: outputItemOrListBuilderMock, +})) + +const shortARNorURLMock = jest.fn() +const verboseSmartAppsMock = jest.fn() +jest.unstable_mockModule('../../lib/command/util/smartapps-util.js', () => ({ + shortARNorURL: shortARNorURLMock, + verboseSmartApps: verboseSmartAppsMock, +})) + + +const { default: cmd } = await import('../../commands/smartapps.js') + + +describe('builder', () => { + const yargsMock = buildArgvMockStub() + const apiCommandBuilderArgvMock = buildArgvMockStub() + const { + yargsMock: allOrganizationsBuilderArgvMock, + positionalMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock) + allOrganizationsBuilderMock.mockReturnValue(allOrganizationsBuilderArgvMock) + outputItemOrListBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + it('calls correct parent and yargs functions', () => { + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledTimes(1) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledWith(yargsMock) + expect(allOrganizationsBuilderMock).toHaveBeenCalledTimes(1) + expect(allOrganizationsBuilderMock).toHaveBeenCalledWith(apiCommandBuilderArgvMock) + expect(outputItemOrListBuilderMock).toHaveBeenCalledTimes(1) + expect(outputItemOrListBuilderMock).toHaveBeenCalledWith(allOrganizationsBuilderArgvMock) + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(3) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) + }) + + // A simplified version of the type of the `Argv.option` that matches the way we call it. + type OptionMock = jest.Mock<(key: string, options?: Options) => Argv> + + it('accepts upper or lowercase types', () => { + expect(builder(yargsMock)).toBe(argvMock) + + const typeCoerce = (optionMock as OptionMock).mock.calls[0][1]?.coerce + expect(typeCoerce).toBeDefined() + expect(typeCoerce?.('API_ONLY')).toBe('API_ONLY') + expect(typeCoerce?.('api_only')).toBe('API_ONLY') + }) + + it('accepts upper or lowercase classifications', () => { + expect(builder(yargsMock)).toBe(argvMock) + + const typeCoerce = (optionMock as OptionMock).mock.calls[1][1]?.coerce + expect(typeCoerce).toBeDefined() + expect(typeCoerce?.(undefined)).toBe(undefined) + expect(typeCoerce?.([])).toStrictEqual([]) + expect(typeCoerce?.(['automation'])).toStrictEqual(['AUTOMATION']) + expect(typeCoerce?.(['automation', 'SERVICE', 'Device'])) + .toStrictEqual(['AUTOMATION', 'SERVICE', 'DEVICE']) + }) +}) + +describe('handler', () => { + const app = { appId: 'app-id', webhookSmartApp: { targetUrl: 'targetUrl' } } as SmartAppResponse + const appList = [{ appId: 'paged-app-id' }] as PagedSmartApp[] + + const apiAppsListMock = jest.fn() + .mockResolvedValue(appList) + const apiAppsGetMock = jest.fn() + .mockResolvedValue(app) + const clientMock = { + smartapps: { + list: apiAppsListMock, + get: apiAppsGetMock, + }, + config: { headers: {} as HttpClientHeaders }, + } as unknown as SmartThingsClient + const command = { + client: clientMock, + } as unknown as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) + + const defaultInputArgv = { + profile: 'default', + verbose: false, + } as ArgumentsCamelCase + + it('lists apps without args', async () => { + await expect(cmd.handler(defaultInputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledTimes(1) + expect(apiOrganizationCommandMock).toHaveBeenCalledWith(defaultInputArgv) + expect(outputItemOrListMock).toHaveBeenCalledTimes(1) + expect(outputItemOrListMock).toHaveBeenCalledWith( + command, + expect.objectContaining({ primaryKeyName: 'appId' }), + undefined, + expect.any(Function), + expect.any(Function), + ) + + apiAppsListMock.mockResolvedValueOnce(appList) + const listFunction = outputItemOrListMock.mock.calls[0][3] + + expect(await listFunction()).toStrictEqual(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({}) + }) + + it('lists details of a specified app', async () => { + const inputArgv = { + ...defaultInputArgv, + idOrIndex: 'app-from-arg', + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledTimes(1) + expect(apiOrganizationCommandMock).toHaveBeenCalledWith(inputArgv) + expect(outputItemOrListMock).toHaveBeenCalledTimes(1) + expect(outputItemOrListMock).toHaveBeenCalledWith( + command, + expect.objectContaining({ primaryKeyName: 'appId' }), + 'app-from-arg', + expect.any(Function), + expect.any(Function), + ) + + const getFunction = outputItemOrListMock.mock.calls[0][4] + + expect(await getFunction('chosen-app-id')).toStrictEqual(app) + + expect(apiAppsGetMock).toHaveBeenCalledTimes(1) + expect(apiAppsGetMock).toHaveBeenCalledWith('chosen-app-id') + }) + + const listAppsForArgs = async ( + args: Partial>, + ): Promise> => { + await expect(cmd.handler({ ...defaultInputArgv, ...args })).resolves.not.toThrow() + + return outputItemOrListMock.mock.calls[0][3] + } + + describe('listApps', () => { + const appType = 'LAMBDA_SMART_APP' + const automation = 'AUTOMATION' as const + const service = 'SERVICE' as const + + it('takes an app type to filter by via flags', async () => { + const listItems = await listAppsForArgs({ type: appType }) + + expect(await listItems()).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({ appType }) + expect(verboseSmartAppsMock).toHaveBeenCalledTimes(0) + }) + + it('accepts a single classification for filtering', async () => { + const listItems = await listAppsForArgs({ classification: [automation] }) + + expect(await listItems()).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({ classification: [automation] }) + expect(verboseSmartAppsMock).toHaveBeenCalledTimes(0) + }) + + it('accepts multiple classifications for filtering', async () => { + const classifications = [automation, service] + const listItems = await listAppsForArgs({ classification: classifications }) + + expect(await listItems()).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({ classification: expect.arrayContaining(classifications) }) + expect(verboseSmartAppsMock).toHaveBeenCalledTimes(0) + }) + + it('passes the organization set on the client headers as accountId', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + clientMock.config.headers = { 'X-ST-Organization': 'organization-id' } + + const listItems = await listAppsForArgs({}) + + expect(await listItems()).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({ accountId: 'organization-id' }) + expect(verboseSmartAppsMock).toHaveBeenCalledTimes(0) + }) + + it('omits accountId when no organization header is set', async () => { + clientMock.config.headers = {} + + const listItems = await listAppsForArgs({}) + + expect(await listItems()).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(1) + expect(apiAppsListMock).toHaveBeenCalledWith({}) + }) + + describe('with --all-organizations', () => { + const orgClient = { + smartapps: { list: apiAppsListMock }, + } as unknown as SmartThingsClient + const organization = { organizationId: 'org-id', name: 'Org' } as OrganizationResponse + + beforeEach(() => { + forAllOrganizationsMock.mockImplementationOnce(async (_client, query) => { + const items = await query(orgClient, organization) + return items.map(item => ({ ...item, organization: organization.name })) + }) + }) + + it('lists apps from each organization and adds an organization column', async () => { + const listItems = await listAppsForArgs({ allOrganizations: true, type: appType }) + + expect(await listItems()).toStrictEqual([{ appId: 'paged-app-id', organization: 'Org' }]) + + expect(forAllOrganizationsMock).toHaveBeenCalledExactlyOnceWith(clientMock, expect.any(Function)) + expect(apiAppsListMock).toHaveBeenCalledExactlyOnceWith({ appType, accountId: 'org-id' }) + expect(outputItemOrListMock).toHaveBeenCalledWith( + command, + expect.objectContaining({ + listTableFieldDefinitions: expect.arrayContaining(['organization']), + }), + undefined, + expect.any(Function), + expect.any(Function), + ) + }) + + it('uses the organization client for verbose listing', async () => { + verboseSmartAppsMock.mockResolvedValueOnce([{ appId: 'verbose-app-id' }] as SmartAppResponse[]) + + const listItems = await listAppsForArgs({ allOrganizations: true, verbose: true }) + + expect(await listItems()).toStrictEqual([{ appId: 'verbose-app-id', organization: 'Org' }]) + + expect(apiAppsListMock).not.toHaveBeenCalled() + expect(verboseSmartAppsMock).toHaveBeenCalledExactlyOnceWith(orgClient, { accountId: 'org-id' }) + }) + }) + }) + + it('includes URLs and ARNs in output when verbose flag is used', async () => { + const listItems = await listAppsForArgs({ verbose: true }) + + expect(outputItemOrListMock).toHaveBeenCalledWith( + command, + expect.objectContaining({ + listTableFieldDefinitions: expect.arrayContaining([{ + label: 'Target URL or ARN', value: shortARNorURLMock, + }]), + }), + undefined, + expect.any(Function), + expect.any(Function), + ) + + const verboseAppList = [{ appId: 'verbose-app-id' }] as SmartAppResponse[] + verboseSmartAppsMock.mockResolvedValue(verboseAppList) + + expect(await listItems()).toBe(verboseAppList) + + expect(apiAppsListMock).toHaveBeenCalledTimes(0) + expect(verboseSmartAppsMock).toHaveBeenCalledTimes(1) + expect(verboseSmartAppsMock).toHaveBeenCalledWith(clientMock, {}) + }) +}) diff --git a/src/__tests__/commands/smartapps/confirm.test.ts b/src/__tests__/commands/smartapps/confirm.test.ts new file mode 100644 index 00000000..17f892ff --- /dev/null +++ b/src/__tests__/commands/smartapps/confirm.test.ts @@ -0,0 +1,90 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import { type PagedSmartApp, type SmartAppsEndpoint } from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../commands/smartapps/confirm.js' +import type { buildEpilog } from '../../../lib/help.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' +import type { ChooseFunction } from '../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { buildArgvMock } from '../../test-lib/builder-mock.js' + + +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const chooseSmartAppMock = jest.fn>() +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, +})) + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) + + +const { default: cmd } = await import('../../../commands/smartapps/confirm.js') + + +test('builder', () => { + const { + yargsMock, + positionalMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +test('handler', async () => { + const apiAppsRegisterMock = jest.fn() + const command = { + client: { + smartapps: { register: apiAppsRegisterMock }, + // eslint-disable-next-line @typescript-eslint/naming-convention + config: { headers: { 'X-ST-Organization': 'organization-id' } }, + }, + } as unknown as APIOrganizationCommand> + apiOrganizationCommandMock.mockResolvedValueOnce(command) + chooseSmartAppMock.mockResolvedValueOnce('chosen-id') + const inputArgv = { + profile: 'default', + id: 'id-from-cmd-line', + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseSmartAppFnMock) + .toHaveBeenCalledExactlyOnceWith({ appType: 'API_ONLY', accountId: 'organization-id' }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'id-from-cmd-line') + expect(apiAppsRegisterMock).toHaveBeenCalledExactlyOnceWith('chosen-id') + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Confirmation request sent')) +}) diff --git a/src/__tests__/commands/apps/create.test.ts b/src/__tests__/commands/smartapps/create.test.ts similarity index 50% rename from src/__tests__/commands/apps/create.test.ts rename to src/__tests__/commands/smartapps/create.test.ts index cb415500..0a0da43f 100644 --- a/src/__tests__/commands/apps/create.test.ts +++ b/src/__tests__/commands/smartapps/create.test.ts @@ -3,25 +3,31 @@ import { jest } from '@jest/globals' import type { ArgumentsCamelCase, Argv } from 'yargs' import type { - AppCreateRequest, - AppCreationResponse, - AppsEndpoint, + HttpClientHeaders, + SmartAppCreateRequest, + SmartAppCreationResponse, + SmartAppsEndpoint, SmartThingsClient, } from '@smartthings/core-sdk' import type { buildEpilog } from '../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' -import type { lambdaAuthBuilder } from '../../../lib/command/common-flags.js' +import type { fatalError } from '../../../lib/util.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' import type { CustomCommonOutputProducer } from '../../../lib/command/format.js' import type { inputAndOutputItem, inputAndOutputItemBuilder, } from '../../../lib/command/input-and-output-item.js' import { InputProcessor, userInputProcessor } from '../../../lib/command/input-processor.js' -import { type authorizeApp, tableFieldDefinitions } from '../../../lib/command/util/apps-util.js' -import { getAppCreateRequestFromUser } from '../../../lib/command/util/apps-user-input-create.js' -import type { CommandArgs } from '../../../commands/apps/create.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' +import { tableFieldDefinitions } from '../../../lib/command/util/smartapps-table.js' +import { getAppCreateRequestFromUser } from '../../../lib/command/util/smartapps-user-input-create.js' +import { chooseOrganization } from '../../../lib/command/util/organizations-util.js' +import type { CommandArgs } from '../../../commands/smartapps/create.js' import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' import { buildTableFromItemMock, @@ -35,15 +41,21 @@ jest.unstable_mockModule('../../../lib/help.js', () => ({ buildEpilog: buildEpilogMock, })) -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() +const fatalErrorMock = jest.fn() + .mockImplementation(() => { throw Error('should exit') }) +jest.unstable_mockModule('../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) -const lambdaAuthBuilderMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/common-flags.js', () => ({ - lambdaAuthBuilder: lambdaAuthBuilderMock, +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>() + jest.fn>() .mockImplementation(async () => { /* no-op */ }) const inputAndOutputItemBuilderMock = jest.fn() jest.unstable_mockModule('../../../lib/command/input-and-output-item.js', () => ({ @@ -51,79 +63,86 @@ jest.unstable_mockModule('../../../lib/command/input-and-output-item.js', () => inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, })) -const inputProcessor = { ioFormat: 'common' } as InputProcessor +const inputProcessor = { ioFormat: 'common' } as InputProcessor const userInputProcessorMock = jest.fn() .mockReturnValue(inputProcessor) jest.unstable_mockModule('../../../lib/command/input-processor.js', () => ({ userInputProcessor: userInputProcessorMock, })) -const authorizeAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - tableFieldDefinitions, - authorizeApp: authorizeAppMock, -})) - const getAppCreateRequestFromUserMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-user-input-create.js', () => ({ +jest.unstable_mockModule('../../../lib/command/util/smartapps-user-input-create.js', () => ({ getAppCreateRequestFromUser: getAppCreateRequestFromUserMock, })) +const chooseOrganizationMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/util/organizations-util.js', () => ({ + chooseOrganization: chooseOrganizationMock, +})) -const { default: cmd } = await import('../../../commands/apps/create.js') + +const { default: cmd } = await import('../../../commands/smartapps/create.js') test('builder', () => { const yargsMock = buildArgvMockStub() - const apiCommandBuilderArgvMock = buildArgvMockStub() + const apiCommandBuilderArgvMock = buildArgvMockStub() const { - yargsMock: lambdaAuthBuilderArgvMock, exampleMock, - optionMock, epilogMock, argvMock, - } = buildArgvMock() + } = buildArgvMock() - apiCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) - lambdaAuthBuilderMock.mockReturnValueOnce(lambdaAuthBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(lambdaAuthBuilderMock) - .toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) expect(inputAndOutputItemBuilderMock) - .toHaveBeenCalledExactlyOnceWith(lambdaAuthBuilderArgvMock) + .toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) expect(exampleMock).toHaveBeenCalledTimes(1) - expect(optionMock).toHaveBeenCalledTimes(1) expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) }) describe('handler', () => { - const appRequest = { appName: 'app' } as AppCreateRequest + const appRequest = { appName: 'app' } as SmartAppCreateRequest const created = { app: { appId: 'app-id', webhookSmartApp: { targetUrl: 'targetUrl' } }, - } as AppCreationResponse - const apiAppsCreateMock = jest.fn() + } as SmartAppCreationResponse + const apiAppsCreateMock = jest.fn() .mockResolvedValue(created) const client = { - apps: { + smartapps: { create: apiAppsCreateMock, }, + // eslint-disable-next-line @typescript-eslint/naming-convention + config: { headers: { 'X-ST-Organization': 'account-id' } as HttpClientHeaders }, } as unknown as SmartThingsClient - const command = { client, tableGenerator: tableGeneratorMock } as APICommand - apiCommandMock.mockResolvedValue(command) - const inputArgv = { profile: 'default' } as ArgumentsCamelCase + const command = { + client, + tableGenerator: tableGeneratorMock, + } as unknown as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) + const organizationId = 'organization-id' + chooseOrganizationMock.mockResolvedValue(organizationId) + const inputArgv = { + profile: 'default', + } as ArgumentsCamelCase it('uses inputAndOutputItem with correct config', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseOrganizationMock).toHaveBeenCalledExactlyOnceWith( + command, + 'account-id', + { autoChoose: true, notATTYMessage: expect.stringContaining('not an interactive terminal') }, + ) expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( command, @@ -140,37 +159,73 @@ describe('handler', () => { expect(getAppCreateRequestFromUserMock).toHaveBeenCalledExactlyOnceWith(command) }) + it('passes no preselected id to chooseOrganization when no organization header is set', async () => { + client.config.headers = {} + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(chooseOrganizationMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + { autoChoose: true, notATTYMessage: expect.stringContaining('not an interactive terminal') }, + ) + + // eslint-disable-next-line @typescript-eslint/naming-convention + client.config.headers = { 'X-ST-Organization': 'account-id' } + }) + + it('uses `undefined` for the organization id on a dry run without prompting', async () => { + const dryRunArgv = { ...inputArgv, dryRun: true } as ArgumentsCamelCase + + await expect(cmd.handler(dryRunArgv)).resolves.not.toThrow() + + expect(chooseOrganizationMock).toHaveBeenCalledTimes(0) + const createApp = inputAndOutputItemMock.mock.calls[0][2] + expect(await createApp(undefined, appRequest)).toBe(created) + expect(apiAppsCreateMock).toHaveBeenCalledExactlyOnceWith(appRequest, undefined) + }) + it('creates app using correct endpoint', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() const createApp = inputAndOutputItemMock.mock.calls[0][2] expect(await createApp(undefined, appRequest)).toBe(created) - expect(apiAppsCreateMock).toHaveBeenCalledExactlyOnceWith(appRequest) + expect(apiAppsCreateMock).toHaveBeenCalledExactlyOnceWith(appRequest, organizationId) + }) - expect(authorizeAppMock).not.toHaveBeenCalled() + it('shows a helpful message when creation is forbidden', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + const createApp = inputAndOutputItemMock.mock.calls[0][2] + + const forbiddenError = { response: { status: 403 } } + apiAppsCreateMock.mockRejectedValueOnce(forbiddenError) + + await expect(createApp(undefined, appRequest)).rejects.toThrow('should exit') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining( + 'https://developer.smartthings.com/console/service-integrations/manage-plans', + ), + ) }) - it('authorizes app when requested', async () => { - await expect(cmd.handler({ - ...inputArgv, - authorize: true, - principal: 'principal', - statement: 'statement', - })).resolves.not.toThrow() + it('rethrows other errors from create', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() const createApp = inputAndOutputItemMock.mock.calls[0][2] - expect(await createApp(undefined, appRequest)).toBe(created) + const otherError = { response: { status: 500 } } + apiAppsCreateMock.mockRejectedValueOnce(otherError) + + await expect(createApp(undefined, appRequest)).rejects.toBe(otherError) - expect(apiAppsCreateMock).toHaveBeenCalledExactlyOnceWith(appRequest) - expect(authorizeAppMock) - .toHaveBeenCalledExactlyOnceWith(appRequest, 'principal', 'statement') + expect(fatalErrorMock).not.toHaveBeenCalled() }) it('displays basic info', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() const config = inputAndOutputItemMock.mock.calls[0][1] as - CustomCommonOutputProducer + CustomCommonOutputProducer expect(config.buildTableOutput(created)).toBe(mockedItemTableOutput) @@ -183,11 +238,11 @@ describe('handler', () => { app: { appId: 'app-id', webhookSmartApp: { targetUrl: 'targetUrl' } }, oauthClientId: 'oauth-client-id', oauthClientSecret: 'oauth-client-secret', - } as AppCreationResponse + } as SmartAppCreationResponse await expect(cmd.handler(inputArgv)).resolves.not.toThrow() const config = inputAndOutputItemMock.mock.calls[0][1] as - CustomCommonOutputProducer + CustomCommonOutputProducer buildTableFromItemMock.mockReturnValueOnce('basic info table') buildTableFromItemMock.mockReturnValueOnce('oauth info table') diff --git a/src/__tests__/commands/smartapps/delete.test.ts b/src/__tests__/commands/smartapps/delete.test.ts new file mode 100644 index 00000000..8c12e7d9 --- /dev/null +++ b/src/__tests__/commands/smartapps/delete.test.ts @@ -0,0 +1,97 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type { PagedSmartApp, SmartAppsEndpoint, SmartThingsClient } from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../commands/smartapps/delete.js' +import type { buildEpilog } from '../../../lib/help.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' +import type { ChooseFunction } from '../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { buildArgvMock } from '../../test-lib/builder-mock.js' + + +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const chooseSmartAppMock = jest.fn>() +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, +})) + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) + + +const { default: cmd } = await import('../../../commands/smartapps/delete.js') + + +test('builder', () => { + const { + yargsMock, + positionalMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledTimes(1) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledWith(yargsMock) + + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +test('handler', async () => { + const apiAppsDeleteMock = jest.fn() + chooseSmartAppMock.mockResolvedValueOnce('chosen-app-id') + const clientMock = { + smartapps: { + delete: apiAppsDeleteMock, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + config: { headers: { 'X-ST-Organization': 'organization-id' } }, + } as unknown as SmartThingsClient + const command = { + client: clientMock, + } as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) + const inputArgv = { + profile: 'default', + id: 'command-line-id', + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledTimes(1) + expect(apiOrganizationCommandMock).toHaveBeenCalledWith(inputArgv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + expect(chooseSmartAppMock).toHaveBeenCalledTimes(1) + expect(chooseSmartAppMock).toHaveBeenCalledWith(command, 'command-line-id') + expect(apiAppsDeleteMock).toHaveBeenCalledTimes(1) + expect(apiAppsDeleteMock).toHaveBeenCalledWith('chosen-app-id') + + expect(consoleLogSpy).toHaveBeenLastCalledWith('Smart App chosen-app-id deleted.') +}) diff --git a/src/__tests__/commands/smartapps/oauth.test.ts b/src/__tests__/commands/smartapps/oauth.test.ts new file mode 100644 index 00000000..3b2eb0bd --- /dev/null +++ b/src/__tests__/commands/smartapps/oauth.test.ts @@ -0,0 +1,116 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type { PagedSmartApp, SmartAppOAuthResponse, SmartAppsEndpoint } from '@smartthings/core-sdk' + +import type { buildEpilog } from '../../../lib/help.js' +import type { CommandArgs } from '../../../commands/smartapps/oauth.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' +import type { OutputItemOrListFlags } from '../../../lib/command/listing-io.js' +import type { outputItem, outputItemBuilder } from '../../../lib/command/output-item.js' +import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js' +import type { ChooseFunction } from '../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { oauthTableFieldDefinitions } from '../../../lib/command/util/smartapps-table.js' +import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' + + +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const outputItemMock = jest.fn() +const outputItemBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/output-item.js', () => ({ + outputItem: outputItemMock, + outputItemBuilder: outputItemBuilderMock, +})) + +const chooseSmartAppMock = jest.fn>() +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, +})) + + +const { default: cmd } = await import('../../../commands/smartapps/oauth.js') + + +test('builder', () => { + const yargsMock = buildArgvMockStub() + const { + yargsMock: apiOrganizationCommandBuilderArgvMock, + positionalMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValue(apiOrganizationCommandBuilderArgvMock) + outputItemBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(outputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +test('handler', async () => { + const apiAppsGetOauthMock = jest.fn() + const command = { + client: { + smartapps: { + getOauth: apiAppsGetOauthMock, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + config: { headers: { 'X-ST-Organization': 'organization-id' } }, + }, + } as unknown as APIOrganizationCommand + + apiOrganizationCommandMock.mockResolvedValueOnce(command) + chooseSmartAppMock.mockResolvedValueOnce('chosen-app-id') + + const argv = { + profile: 'default', + idOrIndex: 'cmd-line-id', + } as ArgumentsCamelCase + + await expect(cmd.handler(argv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(argv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-id', { allowIndex: true }) + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + { tableFieldDefinitions: oauthTableFieldDefinitions }, + expect.any(Function), + ) + + const getFunction = outputItemMock.mock.calls[0][2] + const oauthResponse = { clientName: 'my-client-name' } as SmartAppOAuthResponse + apiAppsGetOauthMock.mockResolvedValueOnce(oauthResponse) + + expect(await getFunction()).toBe(oauthResponse) + + expect(apiAppsGetOauthMock).toHaveBeenCalledExactlyOnceWith('chosen-app-id') +}) diff --git a/src/__tests__/commands/apps/oauth/generate.test.ts b/src/__tests__/commands/smartapps/oauth/generate.test.ts similarity index 60% rename from src/__tests__/commands/apps/oauth/generate.test.ts rename to src/__tests__/commands/smartapps/oauth/generate.test.ts index 2ef1f801..de931d4e 100644 --- a/src/__tests__/commands/apps/oauth/generate.test.ts +++ b/src/__tests__/commands/smartapps/oauth/generate.test.ts @@ -3,25 +3,31 @@ import { jest } from '@jest/globals' import { ArgumentsCamelCase, Argv } from 'yargs' import type { - AppOAuthRequest, - AppsEndpoint, - GenerateAppOAuthRequest, - GenerateAppOAuthResponse, + GenerateSmartAppOAuthRequest, + GenerateSmartAppOAuthResponse, + PagedSmartApp, + SmartAppOAuthRequest, + SmartAppsEndpoint, SmartThingsClient, } from '@smartthings/core-sdk' import type { itemInputHelpText, buildEpilog } from '../../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../../lib/command/api-organization-command.js' import type { inputAndOutputItem, inputAndOutputItemBuilder, } from '../../../../lib/command/input-and-output-item.js' -import { inputProcessor } from '../../../../lib/command/input-processor.js' -import type { chooseApp } from '../../../../lib/command/util/apps-util.js' +import { userInputProcessor } from '../../../../lib/command/input-processor.js' +import type { ChooseFunction } from '../../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../../lib/command/util/smartapps-choose.js' import type { objectDef, stringDef, updateFromUserInput } from '../../../../lib/item-input/index.js' -import type { CommandArgs } from '../../../../commands/apps/oauth/generate.js' +import type { CommandArgs } from '../../../../commands/smartapps/oauth/generate.js' import { buildArgvMock, buildArgvMockStub } from '../../../test-lib/builder-mock.js' -import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' import { buildInputDefMock } from '../../../test-lib/input-type-mock.js' @@ -32,32 +38,35 @@ jest.unstable_mockModule('../../../../lib/help.js', () => ({ itemInputHelpText: itemInputHelpTextMock, })) -const { - apiCommandMock, - apiCommandBuilderMock, -} = apiCommandMocks() +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>() + jest.fn>() const inputAndOutputItemBuilderMock = jest.fn() jest.unstable_mockModule('../../../../lib/command/input-and-output-item.js', () => ({ inputAndOutputItem: inputAndOutputItemMock, inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, })) -const inputProcessorMock = jest.fn() - .mockReturnValue({} as unknown as ReturnType) +const userInputProcessorMock = jest.fn() + .mockReturnValue({} as unknown as ReturnType) jest.unstable_mockModule('../../../../lib/command/input-processor.js', () => ({ - inputProcessor: inputProcessorMock, + userInputProcessor: userInputProcessorMock, })) -const chooseAppMock = jest.fn().mockResolvedValue('chosen-app-id') -jest.unstable_mockModule('../../../../lib/command/util/apps-util.js', () => ({ - chooseApp: chooseAppMock, +const chooseSmartAppMock = jest.fn>().mockResolvedValue('chosen-app-id') +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, })) const oauthAppScopeDefMock = buildInputDefMock('Scopes Mock') -jest.unstable_mockModule('../../../../lib/command/util/apps-input-primitives.js', () => ({ +jest.unstable_mockModule('../../../../lib/command/util/smartapps-input-primitives.js', () => ({ oauthAppScopeDef: oauthAppScopeDefMock, })) @@ -71,27 +80,27 @@ jest.unstable_mockModule('../../../../lib/item-input/index.js', () => ({ })) -const { default: cmd } = await import('../../../../commands/apps/oauth/generate.js') +const { default: cmd } = await import('../../../../commands/smartapps/oauth/generate.js') test('builder', () => { const yargsMock = buildArgvMockStub() const { - yargsMock: apiCommandBuilderArgvMock, + yargsMock: apiOrganizationCommandBuilderArgvMock, positionalMock, exampleMock, epilogMock, argvMock, - } = buildArgvMock() + } = buildArgvMock() - apiCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) expect(positionalMock).toHaveBeenCalledTimes(1) expect(exampleMock).toHaveBeenCalledTimes(1) @@ -100,26 +109,27 @@ test('builder', () => { }) describe('handler', () => { - const apiAppsGetOauthMock = jest.fn() - const apiAppsRegenerateOauthMock = jest.fn() + const apiAppsGetOauthMock = jest.fn() + const apiAppsRegenerateOauthMock = jest.fn() const client = { - apps: { + smartapps: { getOauth: apiAppsGetOauthMock, regenerateOauth: apiAppsRegenerateOauthMock, }, + config: { headers: {} }, } as unknown as SmartThingsClient - const command = { client } as APICommand - apiCommandMock.mockResolvedValue(command) + const command = { client } as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) const inputArgv = { profile: 'default' } as ArgumentsCamelCase it('prompts user when no app specified', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) - expect(inputProcessorMock) - .toHaveBeenCalledExactlyOnceWith(expect.any(Function), expect.any(Function)) + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: undefined }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) + expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( command, expect.objectContaining({}), @@ -129,9 +139,9 @@ describe('handler', () => { const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - const regeneratedOAuthData = { oauthClientId: 'client-id' } as GenerateAppOAuthResponse + const regeneratedOAuthData = { oauthClientId: 'client-id' } as GenerateSmartAppOAuthResponse apiAppsRegenerateOauthMock.mockResolvedValueOnce(regeneratedOAuthData) - const generateRequest = { clientName: 'client-name' } as GenerateAppOAuthRequest + const generateRequest = { clientName: 'client-name' } as GenerateSmartAppOAuthRequest expect(await actionFunction(undefined, generateRequest)).toBe(regeneratedOAuthData) @@ -142,28 +152,36 @@ describe('handler', () => { it('accepts app id from command line', async () => { await expect(cmd.handler({ ...inputArgv, id: 'app-id-arg' })).resolves.not.toThrow() - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'app-id-arg') + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'app-id-arg') }) - const userGeneratedRequest = { clientName: 'client-name' } as GenerateAppOAuthRequest - const originalOauth = { clientName: 'client-name' } as AppOAuthRequest + it('passes resolved organization header to chooseSmartAppFn as accountId', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + client.config.headers = { 'X-ST-Organization': 'organization-id' } + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + + client.config.headers = {} + }) + + const userGeneratedRequest = { clientName: 'client-name' } as GenerateSmartAppOAuthRequest + const originalOauth = { clientName: 'client-name' } as SmartAppOAuthRequest const clientNameMock = buildInputDefMock('Client Name Mock') - const inputDefMock = buildInputDefMock('Generate Request Mock') + const inputDefMock = buildInputDefMock('Generate Request Mock') itemInputHelpTextMock.mockReturnValue('input help text') it('allows user-input of request', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - const hasInput = inputProcessorMock.mock.calls[0][0] - expect(hasInput()).toBe(true) - - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise - const userGeneratedRequest = { clientName: 'client-name' } as GenerateAppOAuthRequest + const userGeneratedRequest = { clientName: 'client-name' } as GenerateSmartAppOAuthRequest const originalOauth = { clientName: 'client-name', scope: ['r:devices:*'], - } as AppOAuthRequest + } as SmartAppOAuthRequest apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) @@ -190,7 +208,7 @@ describe('handler', () => { it('forces starting scopes to at least be an empty array', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) @@ -210,7 +228,7 @@ describe('handler', () => { it('passes dry-run flag on to updateFromUserInput', async () => { await expect(cmd.handler({ ...inputArgv, dryRun: true })).resolves.not.toThrow() - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) diff --git a/src/__tests__/commands/apps/oauth/update.test.ts b/src/__tests__/commands/smartapps/oauth/update.test.ts similarity index 57% rename from src/__tests__/commands/apps/oauth/update.test.ts rename to src/__tests__/commands/smartapps/oauth/update.test.ts index ffc393f6..029be5e6 100644 --- a/src/__tests__/commands/apps/oauth/update.test.ts +++ b/src/__tests__/commands/smartapps/oauth/update.test.ts @@ -3,26 +3,32 @@ import { jest } from '@jest/globals' import { ArgumentsCamelCase, Argv } from 'yargs' import type { - AppOAuthRequest, - AppOAuthResponse, - AppsEndpoint, - GenerateAppOAuthRequest, - GenerateAppOAuthResponse, + GenerateSmartAppOAuthRequest, + GenerateSmartAppOAuthResponse, + PagedSmartApp, + SmartAppOAuthRequest, + SmartAppOAuthResponse, + SmartAppsEndpoint, SmartThingsClient, } from '@smartthings/core-sdk' import type { itemInputHelpText, buildEpilog } from '../../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../../lib/command/api-organization-command.js' import type { inputAndOutputItem, inputAndOutputItemBuilder, } from '../../../../lib/command/input-and-output-item.js' -import { inputProcessor } from '../../../../lib/command/input-processor.js' -import { type chooseApp, oauthTableFieldDefinitions } from '../../../../lib/command/util/apps-util.js' +import { userInputProcessor } from '../../../../lib/command/input-processor.js' +import type { ChooseFunction } from '../../../../lib/command/util/util-util.js' +import { type chooseSmartAppFn } from '../../../../lib/command/util/smartapps-choose.js' import type { objectDef, stringDef, updateFromUserInput } from '../../../../lib/item-input/index.js' -import type { CommandArgs } from '../../../../commands/apps/oauth/update.js' +import type { CommandArgs } from '../../../../commands/smartapps/oauth/update.js' import { buildArgvMock, buildArgvMockStub } from '../../../test-lib/builder-mock.js' -import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' import { buildInputDefMock } from '../../../test-lib/input-type-mock.js' @@ -33,36 +39,40 @@ jest.unstable_mockModule('../../../../lib/help.js', () => ({ itemInputHelpText: itemInputHelpTextMock, })) -const { - apiCommandMock, - apiCommandBuilderMock, -} = apiCommandMocks() +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>() + jest.fn>() const inputAndOutputItemBuilderMock = jest.fn() jest.unstable_mockModule('../../../../lib/command/input-and-output-item.js', () => ({ inputAndOutputItem: inputAndOutputItemMock, inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, })) -const inputProcessorMock = jest.fn() - .mockReturnValue({} as unknown as ReturnType) +const userInputProcessorMock = jest.fn() + .mockReturnValue({} as unknown as ReturnType) jest.unstable_mockModule('../../../../lib/command/input-processor.js', () => ({ - inputProcessor: inputProcessorMock, + userInputProcessor: userInputProcessorMock, })) -const chooseAppMock = jest.fn().mockResolvedValue('chosen-app-id') -jest.unstable_mockModule('../../../../lib/command/util/apps-util.js', () => ({ - chooseApp: chooseAppMock, - oauthTableFieldDefinitions, +const chooseSmartAppMock = jest.fn>().mockResolvedValue('chosen-app-id') +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, })) const oauthAppScopeDefMock = buildInputDefMock('Scopes Mock') const redirectUrisDefMock = buildInputDefMock('Redirect URIs Mock') -jest.unstable_mockModule('../../../../lib/command/util/apps-input-primitives.js', () => ({ +const corsDomainsDefMock = buildInputDefMock('CORS Domains Mock') +jest.unstable_mockModule('../../../../lib/command/util/smartapps-input-primitives.js', () => ({ oauthAppScopeDef: oauthAppScopeDefMock, redirectUrisDef: redirectUrisDefMock, + corsDomainsDef: corsDomainsDefMock, })) const updateFromUserInputMock = jest.fn() @@ -75,27 +85,27 @@ jest.unstable_mockModule('../../../../lib/item-input/index.js', () => ({ })) -const { default: cmd } = await import('../../../../commands/apps/oauth/update.js') +const { default: cmd } = await import('../../../../commands/smartapps/oauth/update.js') test('builder', () => { const yargsMock = buildArgvMockStub() const { - yargsMock: apiCommandBuilderArgvMock, + yargsMock: apiOrganizationCommandBuilderArgvMock, positionalMock, exampleMock, epilogMock, argvMock, - } = buildArgvMock() + } = buildArgvMock() - apiCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) expect(positionalMock).toHaveBeenCalledTimes(1) expect(exampleMock).toHaveBeenCalledTimes(1) @@ -104,26 +114,27 @@ test('builder', () => { }) describe('handler', () => { - const apiAppsGetOauthMock = jest.fn() - const apiAppsUpdateOauthMock = jest.fn() + const apiAppsGetOauthMock = jest.fn() + const apiAppsUpdateOauthMock = jest.fn() const client = { - apps: { + smartapps: { getOauth: apiAppsGetOauthMock, updateOauth: apiAppsUpdateOauthMock, }, + config: { headers: {} }, } as unknown as SmartThingsClient - const command = { client } as APICommand - apiCommandMock.mockResolvedValue(command) + const command = { client } as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) const inputArgv = { profile: 'default' } as ArgumentsCamelCase it('prompts user when no app specified', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) - expect(inputProcessorMock) - .toHaveBeenCalledExactlyOnceWith(expect.any(Function), expect.any(Function)) + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: undefined }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) + expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( command, expect.objectContaining({}), @@ -133,9 +144,9 @@ describe('handler', () => { const actionFunction = inputAndOutputItemMock.mock.calls[0][2] - const updatedOAuthData = { clientName: 'client-name' } as AppOAuthResponse + const updatedOAuthData = { clientName: 'client-name' } as SmartAppOAuthResponse apiAppsUpdateOauthMock.mockResolvedValueOnce(updatedOAuthData) - const updateRequest = { clientName: 'client-name' } as AppOAuthRequest + const updateRequest = { clientName: 'client-name' } as SmartAppOAuthRequest expect(await actionFunction(undefined, updateRequest)).toBe(updatedOAuthData) @@ -146,28 +157,36 @@ describe('handler', () => { it('accepts app id from command line', async () => { await expect(cmd.handler({ ...inputArgv, id: 'app-id-arg' })).resolves.not.toThrow() - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'app-id-arg') + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'app-id-arg') }) - const userGeneratedRequest = { clientName: 'client-name' } as GenerateAppOAuthRequest - const originalOauth = { clientName: 'client-name' } as AppOAuthRequest + it('passes resolved organization header to chooseSmartAppFn as accountId', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + client.config.headers = { 'X-ST-Organization': 'organization-id' } + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + + client.config.headers = {} + }) + + const userGeneratedRequest = { clientName: 'client-name' } as SmartAppOAuthRequest + const originalOauth = { clientName: 'client-name' } as SmartAppOAuthRequest const clientNameMock = buildInputDefMock('Client Name Mock') - const inputDefMock = buildInputDefMock('Generate Request Mock') + const inputDefMock = buildInputDefMock('Generate Request Mock') itemInputHelpTextMock.mockReturnValue('input help text') it('allows user-input of request', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - const hasInput = inputProcessorMock.mock.calls[0][0] - expect(hasInput()).toBe(true) - - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise - const updateRequest = { clientName: 'client-name' } as AppOAuthRequest + const updateRequest = { clientName: 'client-name' } as SmartAppOAuthRequest const originalOauth = { clientName: 'client-name', scope: ['r:devices:*'], - } as AppOAuthRequest + } as SmartAppOAuthRequest apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) @@ -194,7 +213,7 @@ describe('handler', () => { it('forces starting scopes to at least be an empty array', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) @@ -214,7 +233,7 @@ describe('handler', () => { it('passes dry-run flag on to updateFromUserInput', async () => { await expect(cmd.handler({ ...inputArgv, dryRun: true })).resolves.not.toThrow() - const getInputFromUser = inputProcessorMock.mock.calls[0][1] + const getInputFromUser = userInputProcessorMock.mock.calls[0][0] as () => Promise apiAppsGetOauthMock.mockResolvedValueOnce(originalOauth) stringDefMock.mockReturnValueOnce(clientNameMock) diff --git a/src/__tests__/commands/apps/settings.test.ts b/src/__tests__/commands/smartapps/settings.test.ts similarity index 50% rename from src/__tests__/commands/apps/settings.test.ts rename to src/__tests__/commands/smartapps/settings.test.ts index 37b85e61..74dcd014 100644 --- a/src/__tests__/commands/apps/settings.test.ts +++ b/src/__tests__/commands/smartapps/settings.test.ts @@ -2,17 +2,23 @@ import { jest } from '@jest/globals' import type { ArgumentsCamelCase, Argv } from 'yargs' -import type { AppsEndpoint, AppSettingsResponse } from '@smartthings/core-sdk' +import type { PagedSmartApp, SmartAppsEndpoint, SmartAppSettingsResponse } from '@smartthings/core-sdk' -import type { CommandArgs } from '../../../commands/apps/settings.js' +import type { CommandArgs } from '../../../commands/smartapps/settings.js' import type { buildEpilog } from '../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' import type { CustomCommonOutputProducer } from '../../../lib/command/format.js' import type { OutputItemOrListFlags } from '../../../lib/command/listing-io.js' import type { outputItem, outputItemBuilder } from '../../../lib/command/output-item.js' import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js' -import type { buildTableOutput, chooseApp } from '../../../lib/command/util/apps-util.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' +import type { ChooseFunction } from '../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import type { buildTableOutput } from '../../../lib/command/util/smartapps-table.js' import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' import { tableGeneratorMock } from '../../test-lib/table-mock.js' @@ -22,7 +28,12 @@ jest.unstable_mockModule('../../../lib/help.js', () => ({ buildEpilog: buildEpilogMock, })) -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) const outputItemMock = jest.fn() const outputItemBuilderMock = jest.fn() @@ -31,36 +42,40 @@ jest.unstable_mockModule('../../../lib/command/output-item.js', () => ({ outputItemBuilder: outputItemBuilderMock, })) +const chooseSmartAppMock = jest.fn>() +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, +})) + const buildTableOutputMock = jest.fn() -const chooseAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ +jest.unstable_mockModule('../../../lib/command/util/smartapps-table.js', () => ({ buildTableOutput: buildTableOutputMock, - chooseApp: chooseAppMock, })) -const { default: cmd } = await import('../../../commands/apps/settings.js') +const { default: cmd } = await import('../../../commands/smartapps/settings.js') test('builder', () => { const yargsMock = buildArgvMockStub() const { - yargsMock: apiCommandBuilderArgvMock, + yargsMock: apiOrganizationCommandBuilderArgvMock, positionalMock, exampleMock, epilogMock, argvMock, } = buildArgvMock() - apiCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValue(apiOrganizationCommandBuilderArgvMock) outputItemBuilderMock.mockReturnValue(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(outputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(outputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) expect(positionalMock).toHaveBeenCalledTimes(1) expect(exampleMock).toHaveBeenCalledTimes(1) expect(buildEpilogMock).toHaveBeenCalledTimes(1) @@ -68,33 +83,39 @@ test('builder', () => { }) test('handler', async () => { - const apiAppsGetSettingsMock = jest.fn() + const apiAppsGetSettingsMock = jest.fn() const command = { client: { - apps: { + smartapps: { getSettings: apiAppsGetSettingsMock, }, + // eslint-disable-next-line @typescript-eslint/naming-convention + config: { headers: { 'X-ST-Organization': 'organization-id' } }, }, tableGenerator: tableGeneratorMock, - } as unknown as APICommand + } as unknown as APIOrganizationCommand - apiCommandMock.mockResolvedValueOnce(command) - chooseAppMock.mockResolvedValueOnce('chosen-app-id') + apiOrganizationCommandMock.mockResolvedValueOnce(command) + chooseSmartAppMock.mockResolvedValueOnce('chosen-app-id') - const argv = { profile: 'default', idOrIndex: 'cmd-line-id' } as ArgumentsCamelCase + const argv = { + profile: 'default', + idOrIndex: 'cmd-line-id', + } as ArgumentsCamelCase await expect(cmd.handler(argv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(argv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-id', { allowIndex: true }) + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(argv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-id', { allowIndex: true }) expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( command, { buildTableOutput: expect.any(Function) }, expect.any(Function), ) - const config = outputItemMock.mock.calls[0][1] as CustomCommonOutputProducer - const settingsResponse: AppSettingsResponse = { settings: { key: 'value' } } + const config = outputItemMock.mock.calls[0][1] as CustomCommonOutputProducer + const settingsResponse: SmartAppSettingsResponse = { settings: { key: 'value' } } buildTableOutputMock.mockReturnValueOnce('table output') expect(config.buildTableOutput(settingsResponse)).toBe('table output') diff --git a/src/__tests__/commands/apps/settings/update.test.ts b/src/__tests__/commands/smartapps/settings/update.test.ts similarity index 51% rename from src/__tests__/commands/apps/settings/update.test.ts rename to src/__tests__/commands/smartapps/settings/update.test.ts index f611e9d8..bf3b5c13 100644 --- a/src/__tests__/commands/apps/settings/update.test.ts +++ b/src/__tests__/commands/smartapps/settings/update.test.ts @@ -2,17 +2,28 @@ import { jest } from '@jest/globals' import type { ArgumentsCamelCase, Argv } from 'yargs' -import type { AppsEndpoint, AppSettingsResponse, SmartThingsClient } from '@smartthings/core-sdk' +import type { + PagedSmartApp, + SmartAppsEndpoint, + SmartAppSettingsResponse, + SmartThingsClient, +} from '@smartthings/core-sdk' import type { buildEpilog } from '../../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../../lib/command/api-organization-command.js' import type { inputAndOutputItem, inputAndOutputItemBuilder, } from '../../../../lib/command/input-and-output-item.js' -import { type buildTableOutput, type chooseApp } from '../../../../lib/command/util/apps-util.js' -import type { CommandArgs } from '../../../../commands/locations/update.js' -import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' +import type { ChooseFunction } from '../../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../../lib/command/util/smartapps-choose.js' +import type { buildTableOutput } from '../../../../lib/command/util/smartapps-table.js' +import type { CommandArgs } from '../../../../commands/smartapps/settings/update.js' import { buildArgvMock, buildArgvMockStub } from '../../../test-lib/builder-mock.js' import { CustomCommonOutputProducer } from '../../../../lib/command/format.js' import { tableGeneratorMock } from '../../../test-lib/table-mock.js' @@ -23,7 +34,12 @@ jest.unstable_mockModule('../../../../lib/help.js', () => ({ buildEpilog: buildEpilogMock, })) -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() +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() @@ -32,35 +48,39 @@ jest.unstable_mockModule('../../../../lib/command/input-and-output-item.js', () inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, })) +const chooseSmartAppMock = jest.fn>().mockResolvedValue('chosen-id') +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, +})) + const buildTableOutputMock = jest.fn() -const chooseAppMock = jest.fn().mockResolvedValue('chosen-id') -jest.unstable_mockModule('../../../../lib/command/util/apps-util.js', () => ({ +jest.unstable_mockModule('../../../../lib/command/util/smartapps-table.js', () => ({ buildTableOutput: buildTableOutputMock, - chooseApp: chooseAppMock, })) -const { default: cmd } = await import('../../../../commands/apps/settings/update.js') +const { default: cmd } = await import('../../../../commands/smartapps/settings/update.js') test('builder', () => { const yargsMock = buildArgvMockStub() const { - yargsMock: apiCommandBuilderArgvMock, + yargsMock: apiOrganizationCommandBuilderArgvMock, positionalMock, exampleMock, epilogMock, argvMock, - } = buildArgvMock() + } = buildArgvMock() - apiCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(inputAndOutputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) expect(positionalMock).toHaveBeenCalledTimes(1) expect(exampleMock).toHaveBeenCalledTimes(1) @@ -69,25 +89,27 @@ test('builder', () => { }) describe('handler', () => { - const apiAppsUpdateSettingsMock = jest.fn() + const apiAppsUpdateSettingsMock = jest.fn() const clientMock = { - apps: { + smartapps: { updateSettings: apiAppsUpdateSettingsMock, }, + config: { headers: {} }, } as unknown as SmartThingsClient const command = { client: clientMock, tableGenerator: tableGeneratorMock, - } as APICommand - apiCommandMock.mockResolvedValue(command) + } as APIOrganizationCommand + apiOrganizationCommandMock.mockResolvedValue(command) const inputArgv = { profile: 'default' } as ArgumentsCamelCase it('queries user for appId and process via inputAndOutputItem', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: undefined }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, undefined) expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( command, { buildTableOutput: expect.any(Function) }, @@ -96,8 +118,8 @@ describe('handler', () => { const executeFunction = inputAndOutputItemMock.mock.calls[0][2] - const settings = { settings: { balloonColor: 'gross color' } } as AppSettingsResponse - const updatedSettings = { settings: { balloonColor: 'orange' } } as AppSettingsResponse + const settings = { settings: { balloonColor: 'gross color' } } as SmartAppSettingsResponse + const updatedSettings = { settings: { balloonColor: 'orange' } } as SmartAppSettingsResponse apiAppsUpdateSettingsMock.mockResolvedValueOnce(updatedSettings) expect(await executeFunction(undefined, settings)).toBe(updatedSettings) @@ -105,7 +127,7 @@ describe('handler', () => { expect(apiAppsUpdateSettingsMock).toHaveBeenCalledExactlyOnceWith('chosen-id', settings) const config = inputAndOutputItemMock.mock.calls[0][1] as - CustomCommonOutputProducer + CustomCommonOutputProducer buildTableOutputMock.mockReturnValueOnce('table output') expect(config.buildTableOutput(updatedSettings)).toBe('table output') @@ -116,6 +138,17 @@ describe('handler', () => { it('passes command line id on to chooseApp', async () => { await expect(cmd.handler({ ...inputArgv, id: 'argv-id' })).resolves.not.toThrow() - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'argv-id') + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'argv-id') + }) + + it('passes resolved organization header to chooseSmartAppFn as accountId', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + clientMock.config.headers = { 'X-ST-Organization': 'organization-id' } + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(chooseSmartAppFnMock).toHaveBeenCalledExactlyOnceWith({ accountId: 'organization-id' }) + + clientMock.config.headers = {} }) }) diff --git a/src/__tests__/commands/apps/update.test.ts b/src/__tests__/commands/smartapps/update.test.ts similarity index 50% rename from src/__tests__/commands/apps/update.test.ts rename to src/__tests__/commands/smartapps/update.test.ts index 8edc5c6b..7e776fa8 100644 --- a/src/__tests__/commands/apps/update.test.ts +++ b/src/__tests__/commands/smartapps/update.test.ts @@ -3,25 +3,28 @@ import { jest } from '@jest/globals' import type { ArgumentsCamelCase, Argv } from 'yargs' import type { - AppCreateRequest, - AppCreationResponse, - AppResponse, - AppsEndpoint, - AppUpdateRequest, + PagedSmartApp, + SmartAppResponse, + SmartAppsEndpoint, + SmartAppUpdateRequest, } from '@smartthings/core-sdk' import type { buildEpilog } from '../../../lib/help.js' -import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' -import type { lambdaAuthBuilder } from '../../../lib/command/common-flags.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' import type { inputAndOutputItem, inputAndOutputItemBuilder, } from '../../../lib/command/input-and-output-item.js' import type { InputProcessor, userInputProcessor } from '../../../lib/command/input-processor.js' -import { type authorizeApp, type chooseApp, tableFieldDefinitions } from '../../../lib/command/util/apps-util.js' -import type { getAppUpdateRequestFromUser } from '../../../lib/command/util/apps-user-input-update.js' -import type { CommandArgs } from '../../../commands/apps/create.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' +import type { ChooseFunction } from '../../../lib/command/util/util-util.js' +import type { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import type { getAppUpdateRequestFromUser } from '../../../lib/command/util/smartapps-user-input-update.js' +import type { CommandArgs } from '../../../commands/smartapps/update.js' import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' @@ -30,15 +33,15 @@ jest.unstable_mockModule('../../../lib/help.js', () => ({ buildEpilog: buildEpilogMock, })) -const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks() - -const lambdaAuthBuilderMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/common-flags.js', () => ({ - lambdaAuthBuilder: lambdaAuthBuilderMock, +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>() + jest.fn>() .mockImplementation(async () => { /* no-op */ }) const inputAndOutputItemBuilderMock = jest.fn() jest.unstable_mockModule('../../../lib/command/input-and-output-item.js', () => ({ @@ -46,80 +49,73 @@ jest.unstable_mockModule('../../../lib/command/input-and-output-item.js', () => inputAndOutputItemBuilder: inputAndOutputItemBuilderMock, })) -const inputProcessor = { ioFormat: 'common' } as InputProcessor +const inputProcessor = { ioFormat: 'common' } as InputProcessor const userInputProcessorMock = jest.fn() .mockReturnValue(inputProcessor) jest.unstable_mockModule('../../../lib/command/input-processor.js', () => ({ userInputProcessor: userInputProcessorMock, })) -const authorizeAppMock = jest.fn() -const chooseAppMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-util.js', () => ({ - tableFieldDefinitions, - authorizeApp: authorizeAppMock, - chooseApp: chooseAppMock, +const chooseSmartAppMock = jest.fn>() +const chooseSmartAppFnMock = jest.fn().mockReturnValue(chooseSmartAppMock) +jest.unstable_mockModule('../../../lib/command/util/smartapps-choose.js', () => ({ + chooseSmartAppFn: chooseSmartAppFnMock, })) const getAppUpdateRequestFromUserMock = jest.fn() -jest.unstable_mockModule('../../../lib/command/util/apps-user-input-update.js', () => ({ +jest.unstable_mockModule('../../../lib/command/util/smartapps-user-input-update.js', () => ({ getAppUpdateRequestFromUser: getAppUpdateRequestFromUserMock, })) -jest.unstable_mockModule('../../../lib/command/util/apps-input-primitives.js', () => ({ +jest.unstable_mockModule('../../../lib/command/util/smartapps-input-primitives.js', () => ({ smartAppHelpText: 'smartapp help text', })) -const { default: cmd } = await import('../../../commands/apps/update.js') +const { default: cmd } = await import('../../../commands/smartapps/update.js') test('builder', () => { const yargsMock = buildArgvMockStub() - const apiCommandBuilderArgvMock = buildArgvMockStub() + const apiOrganizationCommandBuilderArgvMock = buildArgvMockStub() const { - yargsMock: lambdaAuthBuilderArgvMock, exampleMock, positionalMock, - optionMock, epilogMock, argvMock, - } = buildArgvMock() + } = buildArgvMock() - apiCommandBuilderMock.mockReturnValueOnce(apiCommandBuilderArgvMock) - lambdaAuthBuilderMock.mockReturnValueOnce(lambdaAuthBuilderArgvMock) + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) inputAndOutputItemBuilderMock.mockReturnValueOnce(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv expect(builder(yargsMock)).toBe(argvMock) - expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) - expect(lambdaAuthBuilderMock) - .toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) expect(inputAndOutputItemBuilderMock) - .toHaveBeenCalledExactlyOnceWith(lambdaAuthBuilderArgvMock) + .toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) expect(exampleMock).toHaveBeenCalledTimes(1) expect(positionalMock).toHaveBeenCalledTimes(1) - expect(optionMock).toHaveBeenCalledTimes(1) expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) }) -const apiAppsUpdateMock = jest.fn() +const apiAppsUpdateMock = jest.fn() const command = { client: { - apps: { + smartapps: { update: apiAppsUpdateMock, }, + config: { headers: {} }, }, -} as unknown as APICommand> +} as unknown as APIOrganizationCommand> describe('handler', () => { - apiCommandMock.mockResolvedValue(command) - chooseAppMock.mockResolvedValue('chosen-app-id') - const updatedApp = { appName: 'updated app' } as AppResponse + apiOrganizationCommandMock.mockResolvedValue(command) + chooseSmartAppMock.mockResolvedValue('chosen-app-id') + const updatedApp = { appName: 'updated app' } as SmartAppResponse apiAppsUpdateMock.mockResolvedValue(updatedApp) const inputArgv = { @@ -131,8 +127,10 @@ describe('handler', () => { it('updates app', async () => { await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) - expect(chooseAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-app-id') + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseSmartAppFnMock) + .toHaveBeenCalledExactlyOnceWith({ appType: 'API_ONLY', accountId: undefined }) + expect(chooseSmartAppMock).toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-app-id') expect(userInputProcessorMock).toHaveBeenCalledExactlyOnceWith(expect.any(Function)) expect(inputAndOutputItemMock).toHaveBeenCalledExactlyOnceWith( command, @@ -142,12 +140,11 @@ describe('handler', () => { ) const executeUpdate = inputAndOutputItemMock.mock.calls[0][2] - const updateRequest = { appName: 'app to update' } as AppUpdateRequest + const updateRequest = {} as SmartAppUpdateRequest expect(await executeUpdate(undefined, updateRequest)).toBe(updatedApp) expect(apiAppsUpdateMock).toHaveBeenCalledExactlyOnceWith('chosen-app-id', updateRequest) - expect(authorizeAppMock).not.toHaveBeenCalled() const userInputFunction = userInputProcessorMock.mock.calls[0][0] getAppUpdateRequestFromUserMock.mockResolvedValueOnce(updateRequest) @@ -157,20 +154,15 @@ describe('handler', () => { expect(getAppUpdateRequestFromUserMock).toHaveBeenCalledExactlyOnceWith(command, 'chosen-app-id') }) - it('authorizes when requested', async () => { - await expect(cmd.handler({ - ...inputArgv, - authorize: true, - principal: 'principal', - statement: 'statement', - })).resolves.not.toThrow() + it('passes resolved organization header to chooseSmartAppFn as accountId', async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + command.client.config.headers = { 'X-ST-Organization': 'organization-id' } - const executeUpdate = inputAndOutputItemMock.mock.calls[0][2] - const updateRequest = { appName: 'app to update' } as AppUpdateRequest + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() - expect(await executeUpdate(undefined, updateRequest)).toBe(updatedApp) + expect(chooseSmartAppFnMock) + .toHaveBeenCalledExactlyOnceWith({ appType: 'API_ONLY', accountId: 'organization-id' }) - expect(authorizeAppMock).toHaveBeenCalledExactlyOnceWith(updateRequest, 'principal', 'statement') - expect(apiAppsUpdateMock).toHaveBeenCalledExactlyOnceWith('chosen-app-id', updateRequest) + command.client.config.headers = {} }) }) diff --git a/src/__tests__/lib/aws-util.test.ts b/src/__tests__/lib/aws-util.test.ts index ac2c62a3..ef7b5b4f 100644 --- a/src/__tests__/lib/aws-util.test.ts +++ b/src/__tests__/lib/aws-util.test.ts @@ -15,18 +15,14 @@ jest.unstable_mockModule('@aws-sdk/client-lambda', () => ({ })) -const { - addPermission, - addSchemaPermission, - schemaAWSPrincipal, -} = await import('../../lib/aws-util.js') +const { addSchemaPermission } = await import('../../lib/aws-util.js') const arn = 'seg0:seg1:seg2:region:seg4:seg5:seg6' -describe('addPermission', () => { +describe('addSchemaPermission', () => { it('returns "Invalid Lambda ARN" with too few segments', async () => { - expect(await addPermission('bad arn')).toBe('Invalid Lambda ARN') + expect(await addSchemaPermission('bad arn')).toBe('Invalid Lambda ARN') expect(LambdaClientMock).not.toHaveBeenCalled() expect(AddPermissionCommandMock).not.toHaveBeenCalled() @@ -35,13 +31,13 @@ describe('addPermission', () => { it('returns "Authorization added" when successful', async () => { sendMock.mockImplementationOnce(async () => {}) - expect(await addPermission(arn)).toBe('Authorization added') + expect(await addSchemaPermission(arn)).toBe('Authorization added') expect(LambdaClientMock).toHaveBeenCalledExactlyOnceWith({ region: 'region' }) expect(AddPermissionCommandMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ Action: 'lambda:InvokeFunction', FunctionName: arn, - Principal: '906037444270', + Principal: '148790070172', StatementId: 'smartthings', })) expect(sendMock).toHaveBeenCalledExactlyOnceWith(command) @@ -50,7 +46,7 @@ describe('addPermission', () => { it('returns "Already Authorized" when already authorized', async () => { sendMock.mockImplementationOnce(async () => { throw { name: 'ResourceConflictException' } }) - expect(await addPermission(arn)).toBe('Already authorized') + expect(await addSchemaPermission(arn)).toBe('Already authorized') expect(AddPermissionCommandMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ Action: 'lambda:InvokeFunction', FunctionName: arn, @@ -62,7 +58,7 @@ describe('addPermission', () => { const error = Error('unexpected error') sendMock.mockImplementationOnce(async () => { throw error }) - await expect(addPermission(arn)).rejects.toThrow(error) + await expect(addSchemaPermission(arn)).rejects.toThrow(error) expect(AddPermissionCommandMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ Action: 'lambda:InvokeFunction', FunctionName: arn, @@ -70,18 +66,3 @@ describe('addPermission', () => { expect(sendMock).toHaveBeenCalledExactlyOnceWith(command) }) }) - -test('addSchemaPermission', async () => { - sendMock.mockImplementationOnce(async () => {}) - - expect(await addSchemaPermission(arn)).toBe('Authorization added') - - expect(LambdaClientMock).toHaveBeenCalledExactlyOnceWith({ region: 'region' }) - expect(AddPermissionCommandMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ - Action: 'lambda:InvokeFunction', - FunctionName: arn, - Principal: schemaAWSPrincipal, - StatementId: 'smartthings', - })) - expect(sendMock).toHaveBeenCalledExactlyOnceWith(command) -}) diff --git a/src/__tests__/lib/command/input-processor.test.ts b/src/__tests__/lib/command/input-processor.test.ts index 8811a65e..64382363 100644 --- a/src/__tests__/lib/command/input-processor.test.ts +++ b/src/__tests__/lib/command/input-processor.test.ts @@ -12,7 +12,9 @@ import { parseJSONOrYAML, readDataFromStdin, stdinIsTTY, + stdoutIsTTY, } from '../../../lib/io-util.js' +import type { fatalError } from '../../../lib/util.js' import { SimpleType, validData } from '../../test-lib/simple-type.js' @@ -24,12 +26,19 @@ jest.unstable_mockModule('node:fs/promises', () => ({ const formatFromFilenameMock = jest.fn() const parseJSONOrYAMLMock = jest.fn() const readDataFromStdinMock = jest.fn() -const stdinIsTTYMock = jest.fn() +const stdinIsTTYMock = jest.fn().mockReturnValue(true) +const stdoutIsTTYMock = jest.fn().mockReturnValue(true) jest.unstable_mockModule('../../../lib/io-util.js', () => ({ formatFromFilename: formatFromFilenameMock, parseJSONOrYAML: parseJSONOrYAMLMock, readDataFromStdin: readDataFromStdinMock, stdinIsTTY: stdinIsTTYMock, + stdoutIsTTY: stdoutIsTTYMock, +})) + +const fatalErrorMock = jest.fn().mockReturnValue('never returns' as never) +jest.unstable_mockModule('../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, })) @@ -194,6 +203,62 @@ describe('simple input processor builder functions', () => { expect(inputProcessor.read).toBe(read) }) + + describe('when stdin or stdout is not a TTY', () => { + // Reaching this case means stdin was already read and was empty, so the message must not + // send the user back to stdin. + const notATTYStdinMessage = 'No input was received on stdin and it is not an interactive' + + ' terminal, so you cannot be prompted for input. Specify input with the --input option' + + ' or run in an interactive terminal.' + const notATTYStdoutMessage = 'Output is not an interactive terminal so prompts would be' + + ' mixed into the output. Use the --output option to write results to a file instead of' + + ' redirecting output.' + + afterEach(() => { + stdinIsTTYMock.mockReturnValue(true) + stdoutIsTTYMock.mockReturnValue(true) + }) + + it('points at --input and a terminal, not back at stdin, when stdin is not a TTY', () => { + stdinIsTTYMock.mockReturnValue(false) + + expect(userInputProcessor(readMock).hasInput()).toBe('never returns') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(notATTYStdinMessage) + expect(readMock).not.toHaveBeenCalled() + }) + + it('points at --output, not --input, when only stdout is not a TTY', () => { + stdoutIsTTYMock.mockReturnValue(false) + + expect(userInputProcessor(readMock).hasInput()).toBe('never returns') + + // `--dry-run` with redirected output is a supported way to build an input file, so + // suggesting `--input` here would be nonsense. + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(notATTYStdoutMessage) + expect(readMock).not.toHaveBeenCalled() + }) + + it('reports the stdin problem first when neither is a TTY', () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + expect(userInputProcessor(readMock).hasInput()).toBe('never returns') + + // --output cannot rescue a non-interactive stdin, so that is the actionable message. + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(notATTYStdinMessage) + }) + + it('exits with an explanation when built from a command', () => { + stdinIsTTYMock.mockReturnValue(false) + const command: UserInputCommand = { getInputFromUser: readMock } + + expect(userInputProcessor(command).hasInput()).toBe('never returns') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(notATTYStdinMessage) + expect(readMock).not.toHaveBeenCalled() + }) + }) }) describe('combinedInputProcessor', () => { diff --git a/src/__tests__/lib/command/select.test.ts b/src/__tests__/lib/command/select.test.ts index db617445..35f536ce 100644 --- a/src/__tests__/lib/command/select.test.ts +++ b/src/__tests__/lib/command/select.test.ts @@ -22,6 +22,8 @@ import type { SmartThingsCommand, SmartThingsCommandFlags, } from '../../../lib/command/smartthings-command.js' +import type { stdinIsTTY, stdoutIsTTY } from '../../../lib/io-util.js' +import type { fatalError } from '../../../lib/util.js' import type { SimpleType } from '../../test-lib/simple-type.js' @@ -48,6 +50,21 @@ jest.unstable_mockModule('../../../lib/command/output-list.js', () => ({ outputList: outputListMock, })) +const actualIoUtil = await import('../../../lib/io-util.js') +const stdinIsTTYMock = jest.fn().mockReturnValue(true) +const stdoutIsTTYMock = jest.fn().mockReturnValue(true) +jest.unstable_mockModule('../../../lib/io-util.js', () => ({ + ...actualIoUtil, + stdinIsTTY: stdinIsTTYMock, + stdoutIsTTY: stdoutIsTTYMock, +})) + +const fatalErrorMock = jest.fn() + .mockImplementation(error => { throw error instanceof Error ? error : Error(error) }) +jest.unstable_mockModule('../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) + const { indefiniteArticleFor, @@ -80,6 +97,7 @@ const config: SelectFromListConfig = { const listItemsMock = jest.fn>().mockResolvedValue(list) const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /*no-op*/ }) +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { /*no-op*/ }) describe('indefiniteArticleFor', () => { it.each(['apple', 'Animal', 'egret', 'item', 'orange'])('returns "an" for "%s"', word => { @@ -267,7 +285,7 @@ describe('selectFromList', () => { const options = { listItems: listItemsMock, defaultValue } expect(await selectFromList(commandWithDefault, config, options)).toBe('default-item-id') - expect(consoleLogSpy).toHaveBeenCalledWith('user message') + expect(consoleErrorSpy).toHaveBeenCalledWith('user message') expect(listItemsMock).not.toHaveBeenCalled() expect(outputListMock).not.toHaveBeenCalled() @@ -409,6 +427,98 @@ describe('selectFromList', () => { 'You can reset these settings using the config:reset command.') }) + describe('when stdin or stdout is not a TTY', () => { + afterEach(() => { + stdinIsTTYMock.mockReturnValue(true) + stdoutIsTTYMock.mockReturnValue(true) + }) + + it('throws an error when neither stdin nor stdout is a TTY and no item id is given', async () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + await expect(selectFromList(command, config, { listItems: listItemsMock })).rejects.toThrow( + 'Input and/or output is not an interactive terminal; you must specify an item.', + ) + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( + 'Input and/or output is not an interactive terminal; you must specify an item.', + ) + }) + + it('throws a custom error message when configured', async () => { + stdinIsTTYMock.mockReturnValue(false) + + await expect(selectFromList(command, config, + { listItems: listItemsMock, notATTYMessage: 'custom not a TTY message' })) + .rejects.toThrow('custom not a TTY message') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('custom not a TTY message') + }) + + it('does not throw when a preselected id is given', async () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + expect(await selectFromList(command, config, + { preselectedId: 'sample-id', listItems: listItemsMock })).toBe('sample-id') + }) + + it('uses the configured default value without requiring a TTY', async () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + const commandWithDefault = commandWithProfile({ + defaultItem: 'default-item-id', + }) + getItemMock.mockResolvedValueOnce(item1) + userMessageMock.mockReturnValueOnce('user message') + + const options = { listItems: listItemsMock, defaultValue } + expect(await selectFromList(commandWithDefault, config, options)).toBe('default-item-id') + + expect(consoleErrorSpy).toHaveBeenCalledWith('user message') + expect(listItemsMock).not.toHaveBeenCalled() + }) + + it('does not throw when autoChoose is true and there is only one item', async () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + expect(await selectFromList(command, config, + { listItems: jest.fn>().mockResolvedValue(singleItemList), autoChoose: true })) + .toBe('string-id-1') + }) + + it('throws when autoChoose is true but there is more than one item', async () => { + stdoutIsTTYMock.mockReturnValue(false) + + await expect(selectFromList(command, config, { listItems: listItemsMock, autoChoose: true })).rejects.toThrow( + 'Input and/or output is not an interactive terminal; you must specify an item.', + ) + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( + 'Input and/or output is not an interactive terminal; you must specify an item.', + ) + }) + + it('does not throw and lets promptUser report no items found when the list is empty', async () => { + stdinIsTTYMock.mockReturnValue(false) + stdoutIsTTYMock.mockReturnValue(false) + + const exitSpy = jest.spyOn(process, 'exit') + exitSpy.mockImplementation(() => { throw Error('should exit') }) + + const emptyListItemsMock = jest.fn>().mockResolvedValue([]) + outputListMock.mockResolvedValueOnce([]) + + await expect(selectFromList(command, config, { listItems: emptyListItemsMock })) + .rejects.toThrow('should exit') + + expect(emptyListItemsMock).toHaveBeenCalledTimes(1) + }) + }) + it('saves "never ask again" response"', async () => { booleanConfigValueMock.mockReturnValueOnce(false) selectMock.mockResolvedValue('never') diff --git a/src/__tests__/lib/command/util/apps-user-input-update.test.ts b/src/__tests__/lib/command/util/apps-user-input-update.test.ts deleted file mode 100644 index 1e71d47f..00000000 --- a/src/__tests__/lib/command/util/apps-user-input-update.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { jest } from '@jest/globals' - -import type { ArgumentsCamelCase } from 'yargs' - -import { - type ApiOnlyAppRequest, - AppClassification, - type AppCreateRequest, - type AppResponse, - type AppsEndpoint, - AppTargetStatus, - AppType, - type AppUISettings, - type AppUpdateRequest, - type IconImage, - type LambdaSmartApp, - OwnerType, - PrincipalType, -} from '@smartthings/core-sdk' - -import type { httpsURLValidate } from '../../../../lib/validate-util.js' -import type { APICommand } from '../../../../lib/command/api-command.js' -import type { InputAndOutputItemFlags } from '../../../../lib/command/input-and-output-item.js' -import type { - arrayDef, - objectDef, - optionalStringDef, - staticDef, - stringDef, - updateFromUserInput, -} from '../../../../lib/item-input/index.js' -import { buildInputDefMock } from '../../../test-lib/input-type-mock.js' - - -const httpsURLValidateMock = jest.fn() -jest.unstable_mockModule('../../../../lib/validate-util.js', () => ({ - httpsURLValidate: httpsURLValidateMock, -})) - -const arrayDefMock = jest.fn() -const objectDefMock = jest.fn() -const optionalStringDefMock = jest.fn() -const staticDefMock = jest.fn() -const stringDefMock = jest.fn() -const updateFromUserInputMock = jest.fn() -jest.unstable_mockModule('../../../../lib/item-input/index.js', () => ({ - arrayDef: arrayDefMock, - objectDef: objectDefMock, - optionalStringDef: optionalStringDefMock, - staticDef: staticDefMock, - stringDef: stringDefMock, - updateFromUserInput: updateFromUserInputMock, -})) - -jest.unstable_mockModule('../../../../lib/command//util/apps-input-primitives.js', () => ({ - smartAppHelpText: 'smartapp help text', -})) - - -const { - getAppUpdateRequestFromUser, -} = await import('../../../../lib/command/util/apps-user-input-update.js') - - -describe('getAppUpdateRequestFromUser', () => { - const apiAppsGetMock = jest.fn() - const apiAppsUpdateMock = jest.fn() - const command = { - client: { - apps: { - get: apiAppsGetMock, - update: apiAppsUpdateMock, - }, - }, - flags: {}, - } as unknown as APICommand> - - const baseStartingRequest: Omit = { - appName: 'starting app name', - classifications: [AppClassification.AUTOMATION], - displayName: 'display name', - description: 'description', - singleInstance: true, - iconImage: { url: 'https://icon.example.com' }, - ui: { - dashboardCardsEnabled: true, - preInstallDashboardCardsEnabled: false, - }, - } - const baseStartingApp: Partial = { - appId: 'app-id', - owner: { - ownerType: OwnerType.IMPLICIT, - ownerId: 'owner id', - }, - singleInstance: true, - createdDate: 'the day after tomorrow', - lastUpdatedDate: 'the day before yesterday', - principalType: PrincipalType.LOCATION, - installMetadata: {}, - } - - const updatedApp = { appName: 'updated app name' } as AppUpdateRequest - updateFromUserInputMock.mockResolvedValue(updatedApp) - - const displayNameDefMock = buildInputDefMock('Display Name Mock') - const descriptionDefMock = buildInputDefMock('Description Mock') - const appNameDefMock = buildInputDefMock('App Name Mock') - const appTypeDefMock = buildInputDefMock('App Type Mock') - const classificationsDefMock = buildInputDefMock('Classifications Mock') - const singleInstanceDefMock = buildInputDefMock('Single Instance Mock') - const iconImageDefMock = buildInputDefMock('Icon Image URL Mock') - const uiDefMock = buildInputDefMock('UI Mock') - - const mockCommonPropertyInputDefs = (): void => { - stringDefMock.mockReturnValueOnce(displayNameDefMock) - stringDefMock.mockReturnValueOnce(descriptionDefMock) - staticDefMock.mockReturnValueOnce(appNameDefMock) - staticDefMock.mockReturnValueOnce(appTypeDefMock) - staticDefMock.mockReturnValueOnce(classificationsDefMock) - staticDefMock.mockReturnValueOnce(singleInstanceDefMock) - objectDefMock.mockReturnValueOnce(iconImageDefMock) - staticDefMock.mockReturnValueOnce(uiDefMock) - } - - it('starts with request based on app with fields removed, lambda smartapp version', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.LAMBDA_SMART_APP, - lambdaSmartApp: { functions: ['lambda-function-name'] }, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const lambdaFunctionDefMock = buildInputDefMock('Lambda Function Mock') - stringDefMock.mockReturnValueOnce(lambdaFunctionDefMock) - const lambdaFunctionsDefMock = buildInputDefMock('Lambda Functions Mock') - arrayDefMock.mockReturnValueOnce(lambdaFunctionsDefMock) - const lambdaSmartAppDefMock = buildInputDefMock('Lambda SmartApp Mock') - objectDefMock.mockReturnValueOnce(lambdaSmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) - - expect(apiAppsGetMock).toHaveBeenCalledExactlyOnceWith('app-id') - expect(stringDefMock).toHaveBeenCalledTimes(3) - expect(staticDefMock).toHaveBeenCalledTimes(5) - expect(arrayDefMock).toHaveBeenCalledTimes(1) - expect(objectDefMock).toHaveBeenCalledTimes(3) - - expect(objectDefMock).toHaveBeenCalledWith( - 'App Update', - { - appName: appNameDefMock, - appType: appTypeDefMock, - classifications: classificationsDefMock, - displayName: displayNameDefMock, - description: descriptionDefMock, - singleInstance: singleInstanceDefMock, - iconImage: iconImageDefMock, - ui: uiDefMock, - lambdaSmartApp: lambdaSmartAppDefMock, - }, - { helpText: 'smartapp help text' }, - ) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - command, - appUpdateDefMock, - startingRequest, - { dryRun: false }, - ) - }) - - it('handles unspecified iconImage', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.LAMBDA_SMART_APP, - lambdaSmartApp: { functions: ['lambda-function-name'] }, - iconImage: {}, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - iconImage: undefined, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const lambdaFunctionDefMock = buildInputDefMock('Lambda Function Mock') - stringDefMock.mockReturnValueOnce(lambdaFunctionDefMock) - const lambdaFunctionsDefMock = buildInputDefMock('Lambda Functions Mock') - arrayDefMock.mockReturnValueOnce(lambdaFunctionsDefMock) - const lambdaSmartAppDefMock = buildInputDefMock('Lambda SmartApp Mock') - objectDefMock.mockReturnValueOnce(lambdaSmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - command, - appUpdateDefMock, - startingRequest, - { dryRun: false }, - ) - }) - - it('starts with request based on app with fields removed, webhook smartapp version', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.WEBHOOK_SMART_APP, - webhookSmartApp: { targetUrl: 'https://target-url.example.com' }, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const targetURLDefMock = buildInputDefMock('Target URL Mock') - stringDefMock.mockReturnValueOnce(targetURLDefMock) - const webhookSmartAppDefMock = buildInputDefMock('Webhook SmartApp Mock') - objectDefMock.mockReturnValueOnce(webhookSmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) - - expect(apiAppsGetMock).toHaveBeenCalledExactlyOnceWith('app-id') - expect(stringDefMock).toHaveBeenCalledTimes(3) - expect(staticDefMock).toHaveBeenCalledTimes(5) - expect(objectDefMock).toHaveBeenCalledTimes(3) - - expect(objectDefMock).toHaveBeenCalledWith( - 'App Update', - expect.objectContaining({ webhookSmartApp: webhookSmartAppDefMock }), - { helpText: 'smartapp help text' }, - ) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - command, - appUpdateDefMock, - startingRequest, - { dryRun: false }, - ) - }) - - it('handles unspecified webhook targetUrl', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.WEBHOOK_SMART_APP, - webhookSmartApp: { targetUrl: '' }, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - webhookSmartApp: undefined, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const targetURLDefMock = buildInputDefMock('Target URL Mock') - stringDefMock.mockReturnValueOnce(targetURLDefMock) - const webhookSmartAppDefMock = buildInputDefMock('Webhook SmartApp Mock') - objectDefMock.mockReturnValueOnce(webhookSmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - command, - appUpdateDefMock, - startingRequest, - { dryRun: false }, - ) - }) - - it('starts with request based on app with fields removed, api-only version', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.API_ONLY, - apiOnly: { targetUrl: 'https://target-url.example.com' }, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - apiOnly: { subscription: { - targetUrl: 'https://target-url.example.com', - targetStatus: AppTargetStatus.CONFIRMED, - } }, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const targetURLDefMock = buildInputDefMock('Target URL Mock') - stringDefMock.mockReturnValueOnce(targetURLDefMock) - const apiOnlySmartAppDefMock = buildInputDefMock('API-Only SmartApp Mock') - objectDefMock.mockReturnValueOnce(apiOnlySmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) - - expect(apiAppsGetMock).toHaveBeenCalledExactlyOnceWith('app-id') - expect(stringDefMock).toHaveBeenCalledTimes(3) - expect(staticDefMock).toHaveBeenCalledTimes(5) - expect(objectDefMock).toHaveBeenCalledTimes(3) - - expect(objectDefMock).toHaveBeenCalledWith( - 'App Update', - expect.objectContaining({ apiOnly: apiOnlySmartAppDefMock }), - { helpText: 'smartapp help text' }, - ) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - command, - appUpdateDefMock, - startingRequest, - { dryRun: false }, - ) - }) - - it('passed dryRun flag on to updateFromUserInputMock', async () => { - const startingRequest: AppCreateRequest = { - ...baseStartingRequest, - appType: AppType.API_ONLY, - apiOnly: { targetUrl: 'https://target-url.example.com' }, - } - const startingApp = { - ...startingRequest, - ...baseStartingApp, - apiOnly: { subscription: { - targetUrl: 'https://target-url.example.com', - targetStatus: AppTargetStatus.CONFIRMED, - } }, - } as AppResponse - apiAppsGetMock.mockResolvedValueOnce(startingApp) - mockCommonPropertyInputDefs() - const targetURLDefMock = buildInputDefMock('Target URL Mock') - stringDefMock.mockReturnValueOnce(targetURLDefMock) - const apiOnlySmartAppDefMock = buildInputDefMock('API-Only SmartApp Mock') - objectDefMock.mockReturnValueOnce(apiOnlySmartAppDefMock) - const appUpdateDefMock = buildInputDefMock('App Update Mock') - objectDefMock.mockReturnValueOnce(appUpdateDefMock) - - const commandWithDryRun = { ...command, flags: { dryRun: true } } as - APICommand> - - expect(await getAppUpdateRequestFromUser(commandWithDryRun, 'app-id')).toBe(updatedApp) - - expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( - commandWithDryRun, - appUpdateDefMock, - startingRequest, - { dryRun: true }, - ) - }) -}) diff --git a/src/__tests__/lib/command/util/apps-util.test.ts b/src/__tests__/lib/command/util/apps-util.test.ts deleted file mode 100644 index ca86474a..00000000 --- a/src/__tests__/lib/command/util/apps-util.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { jest } from '@jest/globals' - -import { - type AppResponse, - type AppsEndpoint, - AppType, - type AppUpdateRequest, - type PagedApp, - type SmartThingsClient, -} from '@smartthings/core-sdk' - -import type { addPermission } from '../../../../lib/aws-util.js' -import type { - PropertyTableFieldDefinition, - TableGenerator, - ValueTableFieldDefinition, -} from '../../../../lib/table-generator.js' -import type { fatalError } from '../../../../lib/util.js' -import type { APICommand } from '../../../../lib/command/api-command.js' -import type { stringTranslateToId } from '../../../../lib/command/command-util.js' -import type { - createChooseFn, - ChooseFunction, -} from '../../../../lib/command/util/util-util.js' -import { - mockedTableOutput, - tableMock, - tablePushMock, - tableToStringMock, -} from '../../../test-lib/table-mock.js' - - -const addPermissionMock = jest.fn() -jest.unstable_mockModule('../../../../lib/aws-util.js', () => ({ - addPermission: addPermissionMock, -})) - -const fatalErrorMock = jest.fn().mockReturnValue('never return' as never) -jest.unstable_mockModule('../../../../lib/util.js', () => ({ - fatalError: fatalErrorMock, -})) - -const stringTranslateToIdMock = jest.fn() -jest.unstable_mockModule('../../../../lib/command/command-util.js', () => ({ - stringTranslateToId: stringTranslateToIdMock, -})) - -const createChooseFnMock = jest.fn>() -jest.unstable_mockModule('../../../../lib/command/util/util-util.js', () => ({ - createChooseFn: createChooseFnMock, -})) - - -const { - authorizeApp, - buildTableOutput, - chooseAppFn, - hasSubscription, - isWebhookSmartApp, - shortARNorURL, - tableFieldDefinitions, - verboseApps, -} = await import('../../../../lib/command/util/apps-util.js') - - -test.each` - input | expected - ${{}} | ${false} - ${{ webhookSmartApp: undefined }} | ${false} - ${{ webhookSmartApp: {} }} | ${true} -`('isWebhookSmartApp returns $expected for $input', ({ input, expected }) => { - expect(isWebhookSmartApp(input)).toBe(expected) -}) - -test.each` - input | expected - ${{}} | ${false} - ${{ apiOnly: undefined }} | ${false} - ${{ apiOnly: {} }} | ${false} - ${{ apiOnly: { subscription: undefined } }} | ${false} - ${{ apiOnly: { subscription: {} } }} | ${true} -`('certified include returns $expected for $input', ({ input, expected }) => { - expect(hasSubscription(input)).toBe(expected) -}) - -describe('tableFieldDefinitions functions', () => { - test.each` - input | expected - ${{}} | ${false} - ${{ classifications: undefined }} | ${false} - ${{ classifications: [] }} | ${true} - `('classifications include returns $expected for $input', ({ input, expected }) => { - const include = (tableFieldDefinitions[5] as PropertyTableFieldDefinition).include as (input: AppResponse) => boolean - expect(include(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${false} - ${{ installMetadata: undefined }} | ${false} - ${{ installMetadata: {} }} | ${false} - ${{ installMetadata: { certified: undefined } }} | ${false} - ${{ installMetadata: { certified: true } }} | ${true} - `('certified include returns $expected for $input', ({ input, expected }) => { - const include = (tableFieldDefinitions[6] as PropertyTableFieldDefinition).include as (input: AppResponse) => boolean - expect(include(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${false} - ${{ installMetadata: undefined }} | ${false} - ${{ installMetadata: {} }} | ${false} - ${{ installMetadata: { maxInstalls: undefined } }} | ${false} - ${{ installMetadata: { maxInstalls: true } }} | ${true} - `('maxInstalls include returns $expected for $input', ({ input, expected }) => { - const include = (tableFieldDefinitions[7] as PropertyTableFieldDefinition).include as (input: AppResponse) => boolean - expect(include(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${false} - ${{ webhookSmartApp: undefined }} | ${false} - ${{ webhookSmartApp: {} }} | ${false} - ${{ webhookSmartApp: { publicKey: undefined } }} | ${false} - ${{ webhookSmartApp: { publicKey: 'key' } }} | ${true} - `('Public Key include returns $expected for $input', ({ input, expected }) => { - const include = (tableFieldDefinitions[12] as ValueTableFieldDefinition).include as (input: AppResponse) => boolean - expect(include(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${''} - ${{ webhookSmartApp: {} }} | ${''} - ${{ webhookSmartApp: { publicKey: 'key' } }} | ${'key'} - ${{ webhookSmartApp: { publicKey: 'key\r\npart2\r\npart3' } }} | ${'key\npart2\npart3'} - `('Public Key value returns $expected for $input', ({ input, expected }) => { - const value = (tableFieldDefinitions[12] as ValueTableFieldDefinition).value as (input: AppResponse) => string - expect(value(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${false} - ${{ lambdaSmartApp: undefined }} | ${false} - ${{ lambdaSmartApp: {} }} | ${false} - ${{ lambdaSmartApp: { functions: undefined } }} | ${false} - ${{ lambdaSmartApp: { functions: [] } }} | ${true} - `('Lambda Function include returns $expected for $input', ({ input, expected }) => { - const include = (tableFieldDefinitions[13] as ValueTableFieldDefinition).include as (input: AppResponse) => boolean - expect(include(input)).toBe(expected) - }) - - test.each` - input | expected - ${{}} | ${''} - ${{ lambdaSmartApp: {} }} | ${''} - ${{ lambdaSmartApp: { functions: ['fun1'] } }} | ${'fun1'} - ${{ lambdaSmartApp: { functions: ['fun1', 'fun2'] } }} | ${'fun1\nfun2'} - `('Lambda Function value returns $expected for $input', ({ input, expected }) => { - const value = (tableFieldDefinitions[13] as ValueTableFieldDefinition).value as (input: AppResponse) => string - expect(value(input)).toBe(expected) - }) -}) - -test('chooseAppFn uses correct endpoint to list apps', async () => { - const chooseAppMock = jest.fn>() - createChooseFnMock.mockReturnValueOnce(chooseAppMock) - - const chooseApp = chooseAppFn() - - expect(chooseApp).toBe(chooseAppMock) - - expect(createChooseFnMock).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ itemName: 'app' }), - expect.any(Function), - ) - - const appList = [{ appId: 'listed-app-id' } as PagedApp] - const apiAppsListMock = jest.fn() - .mockResolvedValueOnce(appList) - const listItems = createChooseFnMock.mock.calls[0][1] - const command = { - client: { - apps: { - list: apiAppsListMock, - }, - }, - } as unknown as APICommand - - expect(await listItems(command)).toBe(appList) - - expect(apiAppsListMock).toHaveBeenCalledExactlyOnceWith() -}) - -describe('buildTableOutput', () => { - const newOutputTableMock = jest.fn() - const mockTableGenerator = { - newOutputTable: newOutputTableMock, - } as unknown as TableGenerator - it('returns simple string when app settings are not present', () => { - expect(buildTableOutput(mockTableGenerator, { settings: {} })).toBe('No application settings.') - }) - - it('creates new table with correct options and adds settings', () => { - newOutputTableMock.mockReturnValueOnce(tableMock) - - expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting value' } })) - .toBe(mockedTableOutput) - expect(newOutputTableMock).toHaveBeenCalledWith( - expect.objectContaining({ head: ['Key', 'Value'] }), - ) - expect(tablePushMock).toHaveBeenCalledExactlyOnceWith(['setting', 'setting value']) - expect(tableToStringMock).toHaveBeenCalledExactlyOnceWith() - }) -}) - -describe('verboseApps', () => { - const listMock = jest.fn() - const getMock = jest.fn() - const apps = { list: listMock, get: getMock } as unknown as AppsEndpoint - const client = { apps } as SmartThingsClient - - it('passes options to list', async () => { - listMock.mockResolvedValueOnce([]) - const options = { appType: AppType.API_ONLY } - - expect(await verboseApps(client, options)) - - expect(listMock).toHaveBeenCalledExactlyOnceWith(options) - expect(getMock).toHaveBeenCalledTimes(0) - }) - - it('uses get for every item in list', async () => { - const pagedApp1 = { appId: 'paged-app-1-id' } as PagedApp - const pagedApp2 = { appId: 'paged-app-2-id' } as PagedApp - const verboseApp1 = { appId: 'verbose-app-1-id' } as AppResponse - const verboseApp2 = { appId: 'verbose-app-2-id' } as AppResponse - - listMock.mockResolvedValueOnce([pagedApp1, pagedApp2]) - getMock.mockResolvedValueOnce(verboseApp1) - getMock.mockResolvedValueOnce(verboseApp2) - - expect(await verboseApps(client, {})) - - expect(listMock).toHaveBeenCalledExactlyOnceWith({}) - expect(getMock).toHaveBeenCalledTimes(2) - expect(getMock).toHaveBeenCalledWith('paged-app-1-id') - expect(getMock).toHaveBeenCalledWith('paged-app-2-id') - }) -}) - -describe('shortARNorURL', () => { - it('uses webhookSmartApp targetUrl', () => { - const targetUrl = 'webhook target URL' - expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedApp)).toBe(targetUrl) - }) - - it('uses first lambdaSmartApp function', () => { - expect(shortARNorURL({ lambdaSmartApp: { functions: ['function 1'] } } as unknown as PagedApp)) - .toBe('function 1') - }) - - it('uses empty string for lambdaSmartApp empty function list', () => { - expect(shortARNorURL({ lambdaSmartApp: {} } as unknown as PagedApp)).toBe('') - expect(shortARNorURL({ lambdaSmartApp: { functions: [] } } as unknown as PagedApp)).toBe('') - }) - - it('uses apiOnly subscription targetUrl', () => { - const targetUrl = 'apiOnly subscription URL' - expect(shortARNorURL({ apiOnly: { subscription: { targetUrl } } } as unknown as PagedApp)).toBe(targetUrl) - expect(shortARNorURL({ apiOnly: {} } as unknown as PagedApp)).toBe('') - expect(shortARNorURL({ apiOnly: { subscription: {} } } as unknown as PagedApp)).toBe('') - }) - - it('falls back on an empty string', () => { - expect(shortARNorURL({} as unknown as PagedApp)).toBe('') - }) - - it.each([ - 'short URL', - '12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345', - ])('includes full URL for short URLs', (targetUrl) => { - expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedApp)).toBe(targetUrl) - }) - - it('trims long URLs', () => { - const targetUrl = '123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456' - const trimmed = '12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345...' - expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedApp)).toBe(trimmed) - }) -}) - -describe('authorizeApp', () => { - it('errors for non-lambda apps', async () => { - expect(await authorizeApp({} as AppUpdateRequest, 'principal', 'statement')).toBe('never return') - - expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( - 'Authorization is only applicable to Lambda SmartApps.', - ) - }) - - it('does nothing given no functions', async () => { - await authorizeApp({ lambdaSmartApp: {} } as AppUpdateRequest, 'principal', 'statement') - - expect(addPermissionMock).not.toHaveBeenCalled() - expect(fatalErrorMock).not.toHaveBeenCalled() - }) - - it('calls addPermission for all functions', async () => { - await authorizeApp({ lambdaSmartApp: { - functions: ['function-arn-1', 'function-arn-2'], - } } as AppUpdateRequest, 'principal', 'statement') - - expect(addPermissionMock).toHaveBeenCalledTimes(2) - expect(addPermissionMock).toHaveBeenCalledWith('function-arn-1', 'principal', 'statement') - expect(addPermissionMock).toHaveBeenCalledWith('function-arn-2', 'principal', 'statement') - - expect(fatalErrorMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/__tests__/lib/command/util/smartapps-choose.test.ts b/src/__tests__/lib/command/util/smartapps-choose.test.ts new file mode 100644 index 00000000..4fcfed96 --- /dev/null +++ b/src/__tests__/lib/command/util/smartapps-choose.test.ts @@ -0,0 +1,70 @@ +import { jest } from '@jest/globals' + +import { type SmartAppsEndpoint, type PagedSmartApp, type SmartAppListOptions } from '@smartthings/core-sdk' + +import type { APICommand } from '../../../../lib/command/api-command.js' +import type { createChooseFn, ChooseFunction } from '../../../../lib/command/util/util-util.js' + + +const createChooseFnMock = jest.fn>() +jest.unstable_mockModule('../../../../lib/command/util/util-util.js', () => ({ + createChooseFn: createChooseFnMock, +})) + + +const { chooseSmartAppFn } = await import('../../../../lib/command/util/smartapps-choose.js') + + +describe('chooseSmartAppFn', () => { + const chooseAppMock = jest.fn>() + const appList = [{ appId: 'listed-app-id' } as PagedSmartApp] + const apiAppsListMock = jest.fn() + .mockResolvedValue(appList) + const command = { + client: { + smartapps: { + list: apiAppsListMock, + }, + }, + } as unknown as APICommand + + it('uses correct endpoint to list apps', async () => { + createChooseFnMock.mockReturnValueOnce(chooseAppMock) + + const chooseApp = chooseSmartAppFn() + + expect(chooseApp).toBe(chooseAppMock) + + expect(createChooseFnMock).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ itemName: 'Smart App' }), + expect.any(Function), + ) + + const listItems = createChooseFnMock.mock.calls[0][1] + + expect(await listItems(command)).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledExactlyOnceWith(undefined) + }) + + it('passes smartAppListOptions on to smartapps.list function', async () => { + createChooseFnMock.mockReturnValueOnce(chooseAppMock) + + const smartAppListOptions: SmartAppListOptions = { accountId: 'account-id' } + + const chooseApp = chooseSmartAppFn(smartAppListOptions) + + expect(chooseApp).toBe(chooseAppMock) + + expect(createChooseFnMock).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ itemName: 'Smart App' }), + expect.any(Function), + ) + + const listItems = createChooseFnMock.mock.calls[0][1] + + expect(await listItems(command)).toBe(appList) + + expect(apiAppsListMock).toHaveBeenCalledExactlyOnceWith(smartAppListOptions) + }) +}) diff --git a/src/__tests__/lib/command/util/apps-input-primitives.test.ts b/src/__tests__/lib/command/util/smartapps-input-primitives.test.ts similarity index 76% rename from src/__tests__/lib/command/util/apps-input-primitives.test.ts rename to src/__tests__/lib/command/util/smartapps-input-primitives.test.ts index 23f74d12..ab2fcf55 100644 --- a/src/__tests__/lib/command/util/apps-input-primitives.test.ts +++ b/src/__tests__/lib/command/util/smartapps-input-primitives.test.ts @@ -28,19 +28,31 @@ stringDefMock.mockReturnValueOnce(redirectUriDefMock) const redirectUrisDefMock = buildInputDefMock('Redirect URIs Mock') arrayDefMock.mockReturnValueOnce(redirectUrisDefMock) +const corsDomainDefMock = buildInputDefMock('CORS Domain Mock') +stringDefMock.mockReturnValueOnce(corsDomainDefMock) +const corsDomainsDefMock = buildInputDefMock('CORS Domains Mock') +arrayDefMock.mockReturnValueOnce(corsDomainsDefMock) + const { + corsDomainsDef, oauthAppScopeDef, redirectUrisDef, -} = await import('../../../../lib/command/util/apps-input-primitives.js') +} = await import('../../../../lib/command/util/smartapps-input-primitives.js') expect(oauthAppScopeDef).toBe(oauthAppScopeDefMock) expect(redirectUrisDef).toBe(redirectUrisDefMock) -expect(arrayDefMock).toHaveBeenCalledExactlyOnceWith( +expect(arrayDefMock).toHaveBeenCalledWith( 'Redirect URIs', redirectUriDefMock, expect.objectContaining({ minItems: 0 }), ) +expect(corsDomainsDef).toBe(corsDomainsDefMock) +expect(arrayDefMock).toHaveBeenCalledWith( + 'CORS Domains', + corsDomainDefMock, + expect.objectContaining({ minItems: 0 }), +) const validate = checkboxDefMock.mock.calls[0][2]?.validate test('oauthAppScopeDef requires at least one scope', () => { diff --git a/src/__tests__/lib/command/util/smartapps-table.test.ts b/src/__tests__/lib/command/util/smartapps-table.test.ts new file mode 100644 index 00000000..3e6da93f --- /dev/null +++ b/src/__tests__/lib/command/util/smartapps-table.test.ts @@ -0,0 +1,158 @@ +import { jest } from '@jest/globals' + +import type { SmartAppResponse } from '@smartthings/core-sdk' + +import type { + PropertyTableFieldDefinition, + TableGenerator, + ValueTableFieldDefinition, +} from '../../../../lib/table-generator.js' +import { + mockedTableOutput, + tableMock, + tablePushMock, + tableToStringMock, +} from '../../../test-lib/table-mock.js' + + +const { + buildTableOutput, + hasSubscription, + isAPIOnlySmartApp, + isWebhookSmartApp, + tableFieldDefinitions, +} = await import('../../../../lib/command/util/smartapps-table.js') + + +test.each` + input | expected + ${{}} | ${false} + ${{ webhookSmartApp: undefined }} | ${false} + ${{ webhookSmartApp: {} }} | ${true} +`('isWebhookSmartApp returns $expected for $input', ({ input, expected }) => { + expect(isWebhookSmartApp(input)).toBe(expected) +}) + +test.each` + input | expected + ${{}} | ${false} + ${{ apiOnly: undefined }} | ${false} + ${{ apiOnly: {} }} | ${true} +`('isAPIOnlySmartApp returns $expected for $input', ({ input, expected }) => { + expect(isAPIOnlySmartApp(input)).toBe(expected) +}) + +test.each` + input | expected + ${{}} | ${false} + ${{ apiOnly: undefined }} | ${false} + ${{ apiOnly: {} }} | ${false} + ${{ apiOnly: { subscription: undefined } }} | ${false} + ${{ apiOnly: { subscription: {} } }} | ${true} +`('certified include returns $expected for $input', ({ input, expected }) => { + expect(hasSubscription(input)).toBe(expected) +}) + +describe('tableFieldDefinitions functions', () => { + test.each` + input | expected + ${{}} | ${false} + ${{ classifications: undefined }} | ${false} + ${{ classifications: [] }} | ${true} + `('classifications include returns $expected for $input', ({ input, expected }) => { + const include = (tableFieldDefinitions[4] as PropertyTableFieldDefinition).include as (input: SmartAppResponse) => boolean + expect(include(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${false} + ${{ installMetadata: undefined }} | ${false} + ${{ installMetadata: {} }} | ${false} + ${{ installMetadata: { certified: undefined } }} | ${false} + ${{ installMetadata: { certified: true } }} | ${true} + `('certified include returns $expected for $input', ({ input, expected }) => { + const include = (tableFieldDefinitions[5] as PropertyTableFieldDefinition).include as (input: SmartAppResponse) => boolean + expect(include(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${false} + ${{ installMetadata: undefined }} | ${false} + ${{ installMetadata: {} }} | ${false} + ${{ installMetadata: { maxInstalls: undefined } }} | ${false} + ${{ installMetadata: { maxInstalls: true } }} | ${true} + `('maxInstalls include returns $expected for $input', ({ input, expected }) => { + const include = (tableFieldDefinitions[6] as PropertyTableFieldDefinition).include as (input: SmartAppResponse) => boolean + expect(include(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${false} + ${{ webhookSmartApp: undefined }} | ${false} + ${{ webhookSmartApp: {} }} | ${false} + ${{ webhookSmartApp: { publicKey: undefined } }} | ${false} + ${{ webhookSmartApp: { publicKey: 'key' } }} | ${true} + `('Public Key include returns $expected for $input', ({ input, expected }) => { + const include = (tableFieldDefinitions[11] as ValueTableFieldDefinition).include as (input: SmartAppResponse) => boolean + expect(include(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${''} + ${{ webhookSmartApp: {} }} | ${''} + ${{ webhookSmartApp: { publicKey: 'key' } }} | ${'key'} + ${{ webhookSmartApp: { publicKey: 'key\r\npart2\r\npart3' } }} | ${'key\npart2\npart3'} + `('Public Key value returns $expected for $input', ({ input, expected }) => { + const value = (tableFieldDefinitions[11] as ValueTableFieldDefinition).value as (input: SmartAppResponse) => string + expect(value(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${false} + ${{ lambdaSmartApp: undefined }} | ${false} + ${{ lambdaSmartApp: {} }} | ${false} + ${{ lambdaSmartApp: { functions: undefined } }} | ${false} + ${{ lambdaSmartApp: { functions: [] } }} | ${true} + `('Lambda Function include returns $expected for $input', ({ input, expected }) => { + const include = (tableFieldDefinitions[12] as ValueTableFieldDefinition).include as (input: SmartAppResponse) => boolean + expect(include(input)).toBe(expected) + }) + + test.each` + input | expected + ${{}} | ${''} + ${{ lambdaSmartApp: {} }} | ${''} + ${{ lambdaSmartApp: { functions: ['fun1'] } }} | ${'fun1'} + ${{ lambdaSmartApp: { functions: ['fun1', 'fun2'] } }} | ${'fun1\nfun2'} + `('Lambda Function value returns $expected for $input', ({ input, expected }) => { + const value = (tableFieldDefinitions[12] as ValueTableFieldDefinition).value as (input: SmartAppResponse) => string + expect(value(input)).toBe(expected) + }) +}) + +describe('buildTableOutput', () => { + const newOutputTableMock = jest.fn() + const mockTableGenerator = { + newOutputTable: newOutputTableMock, + } as unknown as TableGenerator + it('returns simple string when app settings are not present', () => { + expect(buildTableOutput(mockTableGenerator, { settings: {} })).toBe('No application settings.') + }) + + it('creates new table with correct options and adds settings', () => { + newOutputTableMock.mockReturnValueOnce(tableMock) + + expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting value' } })) + .toBe(mockedTableOutput) + expect(newOutputTableMock).toHaveBeenCalledWith( + expect.objectContaining({ head: ['Key', 'Value'] }), + ) + expect(tablePushMock).toHaveBeenCalledExactlyOnceWith(['setting', 'setting value']) + expect(tableToStringMock).toHaveBeenCalledExactlyOnceWith() + }) +}) diff --git a/src/__tests__/lib/command/util/apps-user-input-create.test.ts b/src/__tests__/lib/command/util/smartapps-user-input-create.test.ts similarity index 76% rename from src/__tests__/lib/command/util/apps-user-input-create.test.ts rename to src/__tests__/lib/command/util/smartapps-user-input-create.test.ts index 7c192213..d46c6829 100644 --- a/src/__tests__/lib/command/util/apps-user-input-create.test.ts +++ b/src/__tests__/lib/command/util/smartapps-user-input-create.test.ts @@ -1,16 +1,15 @@ import { jest } from '@jest/globals' -import { select } from '@inquirer/prompts' import type { v4 as uuid } from 'uuid' import type { - ApiOnlyAppRequest, - AppCreateRequest, - AppOAuthRequest, + ApiOnlySmartAppRequest, + SmartAppCreateRequest, + SmartAppOAuthRequest, IconImage, } from '@smartthings/core-sdk' -import type { cancelCommand, sanitize } from '../../../../lib/util.js' +import type { sanitize } from '../../../../lib/util.js' import type { httpsURLValidate, stringValidateFn } from '../../../../lib/validate-util.js' import type { InputAndOutputItemFlags } from '../../../../lib/command/input-and-output-item.js' import type { @@ -27,25 +26,18 @@ import type { } from '../../../../lib/item-input/index.js' import type { getAppCreateRequestFromUser as getAppCreateRequestFromUserForType, -} from '../../../../lib/command/util/apps-user-input-create.js' +} from '../../../../lib/command/util/smartapps-user-input-create.js' import { buildInputDefMock } from '../../../test-lib/input-type-mock.js' -const selectMock = jest.fn() -jest.unstable_mockModule('@inquirer/prompts', () => ({ - select: selectMock, -})) - const uuidMock = jest.fn().mockReturnValue('generated-uuid' as unknown as Uint8Array) jest.unstable_mockModule('uuid', () => ({ v4: uuidMock, })) -const cancelCommandMock = jest.fn() const sanitizeMock = jest.fn() .mockReturnValue('sanitized') jest.unstable_mockModule('../../../../lib/util.js', () => ({ - cancelCommand: cancelCommandMock, sanitize: sanitizeMock, })) @@ -74,18 +66,22 @@ jest.unstable_mockModule('../../../../lib/item-input/index.js', () => ({ const oauthAppScopeDefMock = buildInputDefMock('Scopes Mock') const redirectUrisDefMock = buildInputDefMock('Redirect URIs Mock') -jest.unstable_mockModule('../../../../lib/command/util/apps-input-primitives.js', () => ({ +const corsDomainsDefMock = buildInputDefMock('CORS Domains Mock') +jest.unstable_mockModule('../../../../lib/command/util/smartapps-input-primitives.js', () => ({ oauthAppScopeDef: oauthAppScopeDefMock, redirectUrisDef: redirectUrisDefMock, + corsDomainsDef: corsDomainsDefMock, smartAppHelpText: 'smartapp help text', })) +jest.spyOn(console, 'error').mockImplementation(() => { /* do nothing */ }) + const appNameDefMock = buildInputDefMock('App Name Mock') const clientNameDefMock = buildInputDefMock('Client Name Mock') const iconImageDefMock = buildInputDefMock>('Icon Image URL Mock') -const apiOnlyDefMock = buildInputDefMock('API Only Mock') -const oauthDefMock = buildInputDefMock>('OAuth Mock') -const oauthAppCreateRequestDefMock = buildInputDefMock('OAuth-In SmartApp Mock') +const apiOnlyDefMock = buildInputDefMock('API Only Mock') +const oauthDefMock = buildInputDefMock>('OAuth Mock') +const oauthAppCreateRequestDefMock = buildInputDefMock('API Access Smart App Mock') let getAppCreateRequestFromUser: typeof getAppCreateRequestFromUserForType @@ -100,7 +96,7 @@ test('module initialization', async () => { objectDefMock.mockReturnValueOnce(oauthDefMock) objectDefMock.mockReturnValueOnce(oauthAppCreateRequestDefMock) - getAppCreateRequestFromUser = (await import('../../../../lib/command/util/apps-user-input-create.js')) + getAppCreateRequestFromUser = (await import('../../../../lib/command/util/smartapps-user-input-create.js')) .getAppCreateRequestFromUser expect(computedDefMock).toHaveBeenCalledTimes(2) @@ -141,32 +137,17 @@ describe('clientNameDef compute function', () => { describe('getAppCreateRequestFromUser', () => { const command = { flags: {} } as SmartThingsCommand - it('queries user for app for oauth-in app', async () => { - const appRequest = { appName: 'App Name' } as AppCreateRequest + it('creates an api-access app', async () => { + const appRequest = { appName: 'App Name' } as SmartAppCreateRequest - selectMock.mockResolvedValueOnce('oauth-in') createFromUserInputMock.mockResolvedValueOnce(appRequest) expect(await getAppCreateRequestFromUser(command)).toBe(appRequest) - expect(selectMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ default: 'oauth-in' })) expect(createFromUserInputMock).toHaveBeenCalledExactlyOnceWith( command, - expect.objectContaining({ name: 'OAuth-In SmartApp Mock' }), + expect.objectContaining({ name: 'API Access Smart App Mock' }), { dryRun: false }, ) - - expect(cancelCommandMock).not.toHaveBeenCalled() - }) - - it('allows cancel at top level', async () => { - selectMock.mockResolvedValueOnce('cancel') - - await getAppCreateRequestFromUser(command) - - selectMock.mockResolvedValueOnce('oauth-in') - expect(cancelCommandMock).toHaveBeenCalledExactlyOnceWith() - - expect(createFromUserInputMock).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/lib/command/util/smartapps-user-input-update.test.ts b/src/__tests__/lib/command/util/smartapps-user-input-update.test.ts new file mode 100644 index 00000000..ee423e8c --- /dev/null +++ b/src/__tests__/lib/command/util/smartapps-user-input-update.test.ts @@ -0,0 +1,241 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase } from 'yargs' + +import { + type ApiOnlySmartAppRequest, + type SmartAppClassification, + type SmartAppCreateRequest, + type SmartAppResponse, + type SmartAppsEndpoint, + type SmartAppUISettings, + type SmartAppUpdateRequest, + type IconImage, + OwnerType, +} from '@smartthings/core-sdk' + +import type { httpsURLValidate } from '../../../../lib/validate-util.js' +import type { fatalError } from '../../../../lib/util.js' +import type { APICommand } from '../../../../lib/command/api-command.js' +import type { InputAndOutputItemFlags } from '../../../../lib/command/input-and-output-item.js' +import type { + objectDef, + optionalStringDef, + staticDef, + stringDef, + updateFromUserInput, +} from '../../../../lib/item-input/index.js' +import { buildInputDefMock } from '../../../test-lib/input-type-mock.js' + + +const httpsURLValidateMock = jest.fn() +jest.unstable_mockModule('../../../../lib/validate-util.js', () => ({ + httpsURLValidate: httpsURLValidateMock, +})) + +const fatalErrorMock = jest.fn().mockReturnValue('never return' as never) +jest.unstable_mockModule('../../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) + +const objectDefMock = jest.fn() +const optionalStringDefMock = jest.fn() +const staticDefMock = jest.fn() +const stringDefMock = jest.fn() +const updateFromUserInputMock = jest.fn() +jest.unstable_mockModule('../../../../lib/item-input/index.js', () => ({ + objectDef: objectDefMock, + optionalStringDef: optionalStringDefMock, + staticDef: staticDefMock, + stringDef: stringDefMock, + updateFromUserInput: updateFromUserInputMock, +})) + +jest.unstable_mockModule('../../../../lib/command//util/smartapps-input-primitives.js', () => ({ + smartAppHelpText: 'smartapp help text', +})) + + +const { + getAppUpdateRequestFromUser, +} = await import('../../../../lib/command/util/smartapps-user-input-update.js') + + +describe('getAppUpdateRequestFromUser', () => { + const apiAppsGetMock = jest.fn() + const apiAppsUpdateMock = jest.fn() + const command = { + client: { + smartapps: { + get: apiAppsGetMock, + update: apiAppsUpdateMock, + }, + }, + flags: {}, + } as unknown as APICommand> + + const baseStartingRequest: Omit = { + classifications: ['AUTOMATION'], + displayName: 'display name', + description: 'description', + singleInstance: true, + iconImage: { url: 'https://icon.example.com' }, + ui: { + dashboardCardsEnabled: true, + preInstallDashboardCardsEnabled: false, + }, + } + const baseStartingApp: Partial = { + appId: 'app-id', + owner: { + ownerType: OwnerType.IMPLICIT, + ownerId: 'owner id', + }, + singleInstance: true, + createdDate: 'the day after tomorrow', + lastUpdatedDate: 'the day before yesterday', + principalType: 'LOCATION', + installMetadata: {}, + } + + const updatedApp = {} as SmartAppUpdateRequest + updateFromUserInputMock.mockResolvedValue(updatedApp) + + const displayNameDefMock = buildInputDefMock('Display Name Mock') + const descriptionDefMock = buildInputDefMock('Description Mock') + const appTypeDefMock = buildInputDefMock('App Type Mock') + const classificationsDefMock = buildInputDefMock('Classifications Mock') + const singleInstanceDefMock = buildInputDefMock('Single Instance Mock') + const iconImageDefMock = buildInputDefMock('Icon Image URL Mock') + const targetURLDefMock = buildInputDefMock('Target URL Mock') + const apiOnlySmartAppDefMock = buildInputDefMock('API-Only Smart App Mock') + const uiDefMock = buildInputDefMock('UI Mock') + const appUpdateDefMock = buildInputDefMock('App Update Mock') + + const mockPropertyInputDefs = (): void => { + stringDefMock.mockReturnValueOnce(displayNameDefMock) + stringDefMock.mockReturnValueOnce(descriptionDefMock) + staticDefMock.mockReturnValueOnce(appTypeDefMock) + staticDefMock.mockReturnValueOnce(classificationsDefMock) + staticDefMock.mockReturnValueOnce(singleInstanceDefMock) + objectDefMock.mockReturnValueOnce(iconImageDefMock) + stringDefMock.mockReturnValueOnce(targetURLDefMock) + objectDefMock.mockReturnValueOnce(apiOnlySmartAppDefMock) + staticDefMock.mockReturnValueOnce(uiDefMock) + objectDefMock.mockReturnValueOnce(appUpdateDefMock) + } + + it.each(['LAMBDA_SMART_APP', 'WEBHOOK_SMART_APP'] as const)( + 'calls fatalError when appType is %s', + async (appType) => { + const startingApp = { + ...baseStartingRequest, + ...baseStartingApp, + appType, + } as SmartAppResponse + apiAppsGetMock.mockResolvedValueOnce(startingApp) + + expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe('never return') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('Only API_ONLY apps can be modified.') + expect(updateFromUserInputMock).not.toHaveBeenCalled() + }, + ) + + it('handles unspecified iconImage', async () => { + const startingRequest: SmartAppUpdateRequest = { + ...baseStartingRequest, + appType: 'API_ONLY', + apiOnly: { targetUrl: 'https://target-url.example.com' }, + iconImage: {}, + } + const startingApp = { + ...startingRequest, + ...baseStartingApp, + apiOnly: { subscription: { + targetUrl: 'https://target-url.example.com', + targetStatus: 'CONFIRMED', + } }, + iconImage: undefined, + } as SmartAppResponse + apiAppsGetMock.mockResolvedValueOnce(startingApp) + mockPropertyInputDefs() + + expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) + + expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( + command, + appUpdateDefMock, + startingRequest, + { dryRun: false }, + ) + }) + + it('starts with request based on app with fields removed, api-only version', async () => { + const startingRequest: SmartAppUpdateRequest = { + ...baseStartingRequest, + appType: 'API_ONLY', + apiOnly: { targetUrl: 'https://target-url.example.com' }, + } + const startingApp = { + ...startingRequest, + ...baseStartingApp, + apiOnly: { subscription: { + targetUrl: 'https://target-url.example.com', + targetStatus: 'CONFIRMED', + } }, + } as SmartAppResponse + apiAppsGetMock.mockResolvedValueOnce(startingApp) + mockPropertyInputDefs() + + expect(await getAppUpdateRequestFromUser(command, 'app-id')).toBe(updatedApp) + + expect(apiAppsGetMock).toHaveBeenCalledExactlyOnceWith('app-id') + expect(stringDefMock).toHaveBeenCalledTimes(2) + expect(staticDefMock).toHaveBeenCalledTimes(4) + expect(objectDefMock).toHaveBeenCalledTimes(3) + + expect(objectDefMock).toHaveBeenCalledWith( + 'App Update', + expect.objectContaining({ apiOnly: apiOnlySmartAppDefMock }), + { helpText: 'smartapp help text' }, + ) + + expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( + command, + appUpdateDefMock, + startingRequest, + { dryRun: false }, + ) + }) + + it('passed dryRun flag on to updateFromUserInputMock', async () => { + const startingRequest: SmartAppUpdateRequest = { + ...baseStartingRequest, + appType: 'API_ONLY', + apiOnly: { targetUrl: 'https://target-url.example.com' }, + } + const startingApp = { + ...startingRequest, + ...baseStartingApp, + apiOnly: { subscription: { + targetUrl: 'https://target-url.example.com', + targetStatus: 'CONFIRMED', + } }, + } as SmartAppResponse + apiAppsGetMock.mockResolvedValueOnce(startingApp) + mockPropertyInputDefs() + + const commandWithDryRun = { ...command, flags: { dryRun: true } } as + APICommand> + + expect(await getAppUpdateRequestFromUser(commandWithDryRun, 'app-id')).toBe(updatedApp) + + expect(updateFromUserInputMock).toHaveBeenCalledExactlyOnceWith( + commandWithDryRun, + appUpdateDefMock, + startingRequest, + { dryRun: true }, + ) + }) +}) diff --git a/src/__tests__/lib/command/util/smartapps-util.test.ts b/src/__tests__/lib/command/util/smartapps-util.test.ts new file mode 100644 index 00000000..68c4b9b5 --- /dev/null +++ b/src/__tests__/lib/command/util/smartapps-util.test.ts @@ -0,0 +1,91 @@ +import { jest } from '@jest/globals' + +import { + type SmartAppResponse, + type SmartAppsEndpoint, + type PagedSmartApp, + type SmartThingsClient, +} from '@smartthings/core-sdk' + + +const { + shortARNorURL, + verboseSmartApps, +} = await import('../../../../lib/command/util/smartapps-util.js') + + +describe('verboseSmartApps', () => { + const listMock = jest.fn() + const getMock = jest.fn() + const smartapps = { list: listMock, get: getMock } as unknown as SmartAppsEndpoint + const client = { smartapps } as SmartThingsClient + + it('passes options to list', async () => { + listMock.mockResolvedValueOnce([]) + const options = { appType: 'API_ONLY' } as const + + expect(await verboseSmartApps(client, options)) + + expect(listMock).toHaveBeenCalledExactlyOnceWith(options) + expect(getMock).toHaveBeenCalledTimes(0) + }) + + it('uses get for every item in list', async () => { + const pagedApp1 = { appId: 'paged-app-1-id' } as PagedSmartApp + const pagedApp2 = { appId: 'paged-app-2-id' } as PagedSmartApp + const verboseApp1 = { appId: 'verbose-app-1-id' } as SmartAppResponse + const verboseApp2 = { appId: 'verbose-app-2-id' } as SmartAppResponse + + listMock.mockResolvedValueOnce([pagedApp1, pagedApp2]) + getMock.mockResolvedValueOnce(verboseApp1) + getMock.mockResolvedValueOnce(verboseApp2) + + expect(await verboseSmartApps(client, {})) + + expect(listMock).toHaveBeenCalledExactlyOnceWith({}) + expect(getMock).toHaveBeenCalledTimes(2) + expect(getMock).toHaveBeenCalledWith('paged-app-1-id') + expect(getMock).toHaveBeenCalledWith('paged-app-2-id') + }) +}) + +describe('shortARNorURL', () => { + it('uses webhookSmartApp targetUrl', () => { + const targetUrl = 'webhook target URL' + expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedSmartApp)).toBe(targetUrl) + }) + + it('uses first lambdaSmartApp function', () => { + expect(shortARNorURL({ lambdaSmartApp: { functions: ['function 1'] } } as unknown as PagedSmartApp)) + .toBe('function 1') + }) + + it('uses empty string for lambdaSmartApp empty function list', () => { + expect(shortARNorURL({ lambdaSmartApp: {} } as unknown as PagedSmartApp)).toBe('') + expect(shortARNorURL({ lambdaSmartApp: { functions: [] } } as unknown as PagedSmartApp)).toBe('') + }) + + it('uses apiOnly subscription targetUrl', () => { + const targetUrl = 'apiOnly subscription URL' + expect(shortARNorURL({ apiOnly: { subscription: { targetUrl } } } as unknown as PagedSmartApp)).toBe(targetUrl) + expect(shortARNorURL({ apiOnly: {} } as unknown as PagedSmartApp)).toBe('') + expect(shortARNorURL({ apiOnly: { subscription: {} } } as unknown as PagedSmartApp)).toBe('') + }) + + it('falls back on an empty string', () => { + expect(shortARNorURL({} as unknown as PagedSmartApp)).toBe('') + }) + + it.each([ + 'short URL', + '12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345', + ])('includes full URL for short URLs', (targetUrl) => { + expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedSmartApp)).toBe(targetUrl) + }) + + it('trims long URLs', () => { + const targetUrl = '123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456' + const trimmed = '12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345...' + expect(shortARNorURL({ webhookSmartApp: { targetUrl } } as unknown as PagedSmartApp)).toBe(trimmed) + }) +}) diff --git a/src/__tests__/lib/command/util/util-util.test.ts b/src/__tests__/lib/command/util/util-util.test.ts index 8200edd1..ff4b40e5 100644 --- a/src/__tests__/lib/command/util/util-util.test.ts +++ b/src/__tests__/lib/command/util/util-util.test.ts @@ -1,9 +1,10 @@ import { jest } from '@jest/globals' -import type { - selectFromList, - SelectFromListConfig, - SelectFromListFlags, +import { + indefiniteArticleFor, + type selectFromList, + type SelectFromListConfig, + type SelectFromListFlags, } from '../../../../lib/command/select.js' import type { APICommand } from '../../../../lib/command/api-command.js' import type { stringTranslateToId } from '../../../../lib/command/command-util.js' @@ -18,6 +19,7 @@ jest.unstable_mockModule('../../../../lib/command/command-util.js', () => ({ const selectFromListMock = jest.fn() jest.unstable_mockModule('../../../../lib/command/select.js', () => ({ + indefiniteArticleFor, selectFromList: selectFromListMock, })) @@ -250,5 +252,33 @@ describe('createChooseFn', () => { expect.objectContaining({ customNotFoundMessage: 'custom not found' }), ) }) + + it('passes notATTYMessage option on to selectFromList', async () => { + expect(await chooseSimpleType(command, undefined, { notATTYMessage: 'custom not a TTY message' })) + .toBe('selected-simple-type-id') + + expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.objectContaining({ notATTYMessage: 'custom not a TTY message' }), + ) + }) + + it('passes notATTYMessage and defaultValue on to selectFromList together when no id is given', async () => { + expect(await chooseSimpleTypeWithDefaultConfig(command, undefined, { + useConfigDefault: true, + notATTYMessage: 'custom not a TTY message', + })).toBe('selected-simple-type-id') + + expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.objectContaining({ + preselectedId: undefined, + notATTYMessage: 'custom not a TTY message', + defaultValue: expect.objectContaining({ configKey: '' }), + }), + ) + }) }) }) diff --git a/src/__tests__/lib/login-authenticator.test.ts b/src/__tests__/lib/login-authenticator.test.ts index efc56d53..6c04f5b3 100644 --- a/src/__tests__/lib/login-authenticator.test.ts +++ b/src/__tests__/lib/login-authenticator.test.ts @@ -419,7 +419,8 @@ describe('login', () => { it('logs axios response data', async () => { readFileSyncMock.mockReturnValueOnce(Buffer.from(JSON.stringify(credentialsFileData))) - postMock.mockRejectedValueOnce({ isAxiosError: true, response: { data: 'axios error data' } }) + const response = { data: 'axios error data' } + postMock.mockRejectedValueOnce({ isAxiosError: true, response }) const authenticator = setupAuthenticator() const loginPromise = authenticator.login?.() @@ -427,7 +428,7 @@ describe('login', () => { await mockBrowser() await expect(loginPromise).rejects.toBe('unable to get authentication info') - expect(errorMock).toHaveBeenCalledWith('axios error data') + expect(errorMock).toHaveBeenCalledWith('axios error:', response.data) }) it('logs express server close error', async () => { diff --git a/src/commands/apps.ts b/src/commands/apps.ts deleted file mode 100644 index 579ac6ba..00000000 --- a/src/commands/apps.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { - AppType, - AppClassification, - type AppListOptions, - type PagedApp, - type AppResponse, -} from '@smartthings/core-sdk' - -import { buildEpilog } from '../lib/help.js' -import { type TableFieldDefinition } from '../lib/table-generator.js' -import { - type APICommandFlags, - apiCommand, - apiCommandBuilder, -} from '../lib/command/api-command.js' -import { - type OutputItemOrListConfig, - type OutputItemOrListFlags, - outputItemOrList, - outputItemOrListBuilder, -} from '../lib/command/listing-io.js' -import { shortARNorURL, tableFieldDefinitions, verboseApps } from '../lib/command/util/apps-util.js' - - -export type CommandArgs = APICommandFlags & OutputItemOrListFlags & { - type?: AppType - classification?: AppClassification[] - verbose: boolean - idOrIndex?: string -} - -const command = 'apps [id-or-index]' - -const describe = 'get a specific app or a list of apps' - -const builder = (yargs: Argv): Argv => - outputItemOrListBuilder(apiCommandBuilder(yargs)) - .positional('id-or-index', { describe: 'the app id or number from list', type: 'string' }) - .option('type', { - describe: 'filter results by app type', - type: 'string', - choices: Object.values(AppType), - coerce: arg => arg.toUpperCase() as AppType, - }) - .option('classification', { - describe: 'filter results by one or more classifications', - type: 'string', - array: true, - choices: Object.values(AppClassification), - coerce: arg => arg?.map((str: string) => str.toUpperCase() as AppClassification), - }) - .option('verbose', - { alias: 'v', describe: 'include URLs and ARNs in table output', type: 'boolean', default: false }) - .example([ - ['$0 apps', 'list all apps'], - ['$0 apps 1', 'display details for the first app in the list retrieved by running "smartthings apps"'], - ['$0 apps 5dfd6626-ab1d-42da-bb76-90def3153998', 'display details for an app by id'], - ['$0 apps --verbose', 'include URLs and ARNs in the output'], - ['$0 apps --classification SERVICE', 'list SERVICE classification apps'], - ['$0 apps --type API_ONLY', 'list API-only apps'], - ]) - .epilog(buildEpilog({ command, apiDocs: ['listApps', 'getApp'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const listTableFieldDefinitions: TableFieldDefinition[] = ['displayName', 'appType', 'appId'] - if (argv.verbose) { - listTableFieldDefinitions.push({ label: 'ARN/URL', value: shortARNorURL }) - } - const config: OutputItemOrListConfig = { - primaryKeyName: 'appId', - sortKeyName: 'displayName', - tableFieldDefinitions, - listTableFieldDefinitions, - } - - const listApps = async (): Promise => { - const appListOptions: AppListOptions = {} - if (argv.type) { - appListOptions.appType = AppType[argv.type as keyof typeof AppType] - } - - if (argv.classification) { - appListOptions.classification = argv.classification - .map(classification => AppClassification[classification as keyof typeof AppClassification]) - } - - if (argv.verbose) { - return verboseApps(command.client, appListOptions) - } - return command.client.apps.list(appListOptions) - } - - await outputItemOrList(command, config, argv.idOrIndex, listApps, id => command.client.apps.get(id)) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/authorize.ts b/src/commands/apps/authorize.ts deleted file mode 100644 index 81baaa9e..00000000 --- a/src/commands/apps/authorize.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs' - -import { addPermission } from '../../lib/aws-util.js' -import { buildEpilog } from '../../lib/help.js' -import { lambdaAuthBuilder, type LambdaAuthFlags } from '../../lib/command/common-flags.js' -import { - smartThingsCommand, - smartThingsCommandBuilder, - type SmartThingsCommandFlags, -} from '../../lib/command/smartthings-command.js' - - -export type CommandArgs = SmartThingsCommandFlags & LambdaAuthFlags & { - arn: string -} - -const command = 'apps:authorize ' - -const describe = 'authorize calls to your AWS Lambda function from SmartThings' - -const builder = (yargs: Argv): Argv => - lambdaAuthBuilder(smartThingsCommandBuilder(yargs)) - .positional( - 'arn', - { describe: 'the ARN of the AWS Lambda function', type: 'string', demandOption: true }, - ) - .example([ - [ - '$0 apps:authorize \\\n> arn:aws:lambda:us-east-1:1234567890:function:your-app', - 'authorize an app', - ], - ]) - .epilog(buildEpilog({ - command, - formattedNotes: `The example above is the same as running the following with the AWS CLI: - -$ aws lambda add-permission --region us-east-1 --function-name \\ -> arn:aws:lambda:us-east-1:1234567890:function:your-app \\ -> --statement smartthings --principal 906037444270 --action lambda:InvokeFunction - -This command requires your machine to be configured to run the AWS CLI.`, - })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - await smartThingsCommand(argv) - - const message = await addPermission(argv.arn, argv.principal, argv.statement) - console.log(message) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts deleted file mode 100644 index a8e04de0..00000000 --- a/src/commands/apps/create.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ArgumentsCamelCase, Argv, CommandModule } from 'yargs' - -import { type AppCreateRequest, type AppCreationResponse } from '@smartthings/core-sdk' - -import { buildEpilog } from '../../lib/help.js' -import { - apiCommand, - apiCommandBuilder, - type APICommandFlags, -} from '../../lib/command/api-command.js' -import { lambdaAuthBuilder, LambdaAuthFlags } from '../../lib/command/common-flags.js' -import { - inputAndOutputItem, - inputAndOutputItemBuilder, - type InputAndOutputItemFlags, -} from '../../lib/command/input-and-output-item.js' -import { userInputProcessor } from '../../lib/command/input-processor.js' -import { - authorizeApp, - tableFieldDefinitions, -} from '../../lib/command/util/apps-util.js' -import { getAppCreateRequestFromUser } from '../../lib/command/util/apps-user-input-create.js' - - -export type CommandArgs = - & APICommandFlags - & InputAndOutputItemFlags - & LambdaAuthFlags - & { - authorize: boolean - } - -const command = 'apps:create' - -const describe = 'create an app' - -const builder = (yargs: Argv): Argv => - inputAndOutputItemBuilder(lambdaAuthBuilder(apiCommandBuilder(yargs))) - .option( - 'authorize', - { - describe: 'authorize Lambda functions to be called by SmartThings', - type: 'boolean', - default: false, - }, - ) - .example([ - [ '$0 apps:create', 'create an OAuth-In app from prompted input'], - ['$0 apps:create -i my-app.yaml', 'create an app defined in "my-app.yaml'], - [ - '$0 apps:create -i my-app.json --authorize', - 'create an app defined in "my-app.json" and then authorize it\n' + - '(See "smartthings apps:authorize" for more information on authorization.)', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: ['createApp'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const createApp = async (_: void, data: AppCreateRequest): Promise => { - if (argv.authorize) { - await authorizeApp(data, argv.principal, argv.statement) - } - return command.client.apps.create(data) - } - - const buildTableOutput = (data: AppCreationResponse): string => { - const basicInfo = command.tableGenerator.buildTableFromItem(data.app, tableFieldDefinitions) - - const oauthInfo = data.oauthClientId || data.oauthClientSecret - ? command.tableGenerator.buildTableFromItem(data, ['oauthClientId', 'oauthClientSecret']) - : undefined - return oauthInfo - ? `Basic App Data:\n${basicInfo}\n\n` + - 'OAuth Info (you will not be able to see the OAuth info again so please save' + - ` it now!):\n${oauthInfo}` - : basicInfo - } - - await inputAndOutputItem( - command, - { buildTableOutput }, - createApp, - userInputProcessor(() => getAppCreateRequestFromUser(command)), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/delete.ts b/src/commands/apps/delete.ts deleted file mode 100644 index 82f75da8..00000000 --- a/src/commands/apps/delete.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { buildEpilog } from '../../lib/help.js' -import { - type APICommandFlags, - apiCommand, - apiCommandBuilder, -} from '../../lib/command/api-command.js' -import { chooseApp } from '../../lib/command/util/apps-util.js' - - -export type CommandArgs = APICommandFlags & { - id?: string -} - -const command = 'apps:delete [id]' - -const describe = 'delete an app' - -const builder = (yargs: Argv): Argv => - apiCommandBuilder(yargs) - .positional('id', { describe: 'app id', type: 'string' }) - .example([ - ['$0 apps:delete', 'choose the app to delete from a list'], - [ - '$0 apps:delete 5dfd6626-ab1d-42da-bb76-90def3153998', - 'delete the app with the specified id', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: 'deleteApp' })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const id = await chooseApp(command, argv.id) - await command.client.apps.delete(id) - console.log(`App ${id} deleted.`) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/oauth.ts b/src/commands/apps/oauth.ts deleted file mode 100644 index 7e0af67c..00000000 --- a/src/commands/apps/oauth.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { apiCommand, apiCommandBuilder, type APICommandFlags } from '../../lib/command/api-command.js' -import { buildEpilog } from '../../lib/help.js' -import { outputItem, outputItemBuilder, type OutputItemFlags } from '../../lib/command/output-item.js' -import { chooseApp, oauthTableFieldDefinitions } from '../../lib/command/util/apps-util.js' - - -export type CommandArgs = - & APICommandFlags - & OutputItemFlags - & { - idOrIndex?: string - } - -const command = 'apps:oauth [id-or-index]' - -const describe = 'get OAuth information for an app' - -const builder = (yargs: Argv): Argv => - outputItemBuilder(apiCommandBuilder(yargs)) - .positional('id-or-index', { describe: 'the app id or number from list', type: 'string' }) - .example([ - ['$0 apps:oauth', 'prompt for an app and list OAuth information for it'], - [ - '$0 apps:oauth 2', - 'list OAuth information for the second app in the list generated by "smartthings apps"', - ], - [ - '$0 apps:oauth 392bcb11-e251-44f3-b58b-17f93015f3aa', - 'list OAuth information for the app with the given id', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: ['getAppOauth'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const id = await chooseApp(command, argv.idOrIndex, { allowIndex: true }) - await outputItem( - command, - { tableFieldDefinitions: oauthTableFieldDefinitions }, - () => command.client.apps.getOauth(id), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/oauth/generate.ts b/src/commands/apps/oauth/generate.ts deleted file mode 100644 index d55aa437..00000000 --- a/src/commands/apps/oauth/generate.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import type { GenerateAppOAuthRequest, GenerateAppOAuthResponse } from '@smartthings/core-sdk' - -import { buildEpilog, itemInputHelpText } from '../../../lib/help.js' -import { type TableFieldDefinition } from '../../../lib/table-generator.js' -import { - apiCommand, - apiCommandBuilder, - type APICommandFlags, -} from '../../../lib/command/api-command.js' -import { - inputAndOutputItem, - inputAndOutputItemBuilder, - type InputAndOutputItemConfig, - type InputAndOutputItemFlags, -} from '../../../lib/command/input-and-output-item.js' -import { inputProcessor } from '../../../lib/command/input-processor.js' -import { chooseApp } from '../../../lib/command/util/apps-util.js' -import { oauthAppScopeDef } from '../../../lib/command/util/apps-input-primitives.js' -import { objectDef, stringDef, updateFromUserInput } from '../../../lib/item-input/index.js' - - -export type CommandArgs = APICommandFlags & InputAndOutputItemFlags & { - id?: string -} - -const command = 'apps:oauth:generate [id]' - -const describe = 'regenerate the OAuth clientId and clientSecret of an app' - -const docNames = 'generateAppOauth' -const builder = (yargs: Argv): Argv => - inputAndOutputItemBuilder(apiCommandBuilder(yargs)) - .positional('id', { describe: 'app id', type: 'string' }) - .example([ - [ - '$0 apps:oauth:generate', - 'prompt for an app and then regenerate the OAuth clientId and clientSecret for it', - ], - [ - '$0 apps:oauth:generate 392bcb11-e251-44f3-b58b-17f93015f3aa', - 'regenerate the OAuth clientId and clientSecret of the app with the given id', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: [docNames] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const appId = await chooseApp(command, argv.id) - - const tableFieldDefinitions: TableFieldDefinition[] = [ - { path: 'oauthClientDetails.clientName' }, - { path: 'oauthClientDetails.scope' }, - { path: 'oauthClientDetails.redirectUris' }, - 'oauthClientId', - 'oauthClientSecret', - ] - const config: InputAndOutputItemConfig = { - tableFieldDefinitions, - } - const getInputFromUser = async (): Promise => { - const originalOauth = await command.client.apps.getOauth(appId) - const startingRequest: GenerateAppOAuthRequest = { - clientName: originalOauth.clientName, - scope: originalOauth.scope ?? [], - } - const inputDef = objectDef('Generate Request', { - clientName: stringDef('Client Name'), - scope: oauthAppScopeDef, - }, { helpText: itemInputHelpText(docNames) }) - - return updateFromUserInput(command, inputDef, startingRequest, { dryRun: !!argv.dryRun }) - } - - await inputAndOutputItem( - command, - config, - (_, data: GenerateAppOAuthRequest) => command.client.apps.regenerateOauth(appId, data), - inputProcessor(() => true, getInputFromUser), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/oauth/update.ts b/src/commands/apps/oauth/update.ts deleted file mode 100644 index 26928048..00000000 --- a/src/commands/apps/oauth/update.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { AppOAuthRequest } from '@smartthings/core-sdk' - -import { buildEpilog, itemInputHelpText } from '../../../lib/help.js' -import { - apiCommand, - apiCommandBuilder, - APICommandFlags, -} from '../../../lib/command/api-command.js' -import { - inputAndOutputItem, - inputAndOutputItemBuilder, - InputAndOutputItemFlags, -} from '../../../lib/command/input-and-output-item.js' -import { inputProcessor } from '../../../lib/command/input-processor.js' -import { chooseApp, oauthTableFieldDefinitions } from '../../../lib/command/util/apps-util.js' -import { - oauthAppScopeDef, - redirectUrisDef, -} from '../../../lib/command/util/apps-input-primitives.js' -import { objectDef, stringDef, updateFromUserInput } from '../../../lib/item-input/index.js' - - -export type CommandArgs = APICommandFlags & InputAndOutputItemFlags & { - id?: string -} - -const command = 'apps:oauth:update [id]' - -const describe = 'update the OAuth settings of an app' - -const docNames = 'updateAppOauth' -const builder = (yargs: Argv): Argv => - inputAndOutputItemBuilder(apiCommandBuilder(yargs)) - .positional('id', { describe: 'app id', type: 'string' }) - .example([ - [ - '$0 apps:oauth:update', - 'prompt for an app and update its OAuth settings interactively"', - ], - [ - '$0 apps:oauth:update -i oauth-settings.json', - 'prompt for an app and update its OAuth settings using the data in "oauth-settings.json', - ], - [ - '$0 apps:oauth:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i oauth-settings.json', - 'update OAuth settings for the app with the given id using the data in "oauth-settings.json"', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: [docNames] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const appId = await chooseApp(command, argv.id) - - const getInputFromUser = async (): Promise => { - const startingRequest: AppOAuthRequest = await command.client.apps.getOauth(appId) - if (!startingRequest.scope) { - startingRequest.scope = [] - } - const inputDef = objectDef('OAuth Settings', { - clientName: stringDef('Client Name'), - scope: oauthAppScopeDef, - redirectUris: redirectUrisDef, - }, { helpText: itemInputHelpText(docNames) }) - - return updateFromUserInput(command, inputDef, startingRequest, { dryRun: !!argv.dryRun }) - } - - await inputAndOutputItem(command, { tableFieldDefinitions: oauthTableFieldDefinitions }, - (_, data: AppOAuthRequest) => command.client.apps.updateOauth(appId, data), - inputProcessor(() => true, getInputFromUser)) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/register.ts b/src/commands/apps/register.ts deleted file mode 100644 index 27879fcf..00000000 --- a/src/commands/apps/register.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { AppType, type PagedApp } from '@smartthings/core-sdk' - -import { apiCommand, apiCommandBuilder, type APICommandFlags } from '../../lib/command/api-command.js' -import { buildEpilog } from '../../lib/help.js' -import { chooseApp } from '../../lib/command/util/apps-util.js' - - -export type CommandArgs = APICommandFlags & { - id?: string -} - -const command = 'apps:register [id]' - -const describe = 'send request to app target URL to confirm existence and authorize lifecycle events' - -const builder = (yargs: Argv): Argv => - apiCommandBuilder(yargs) - .positional('id', { describe: 'app id', type: 'string' }) - .example([ - ['$0 apps:register', 'choose the app to register from a list'], - [ - '$0 apps:register 392bcb11-e251-44f3-b58b-17f93015f3aa', - 'send registration request to the app with the given id', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: ['register'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const id = await chooseApp( - command, - argv.id, - { - listFilter: (app: PagedApp): boolean => - app.appType === AppType.WEBHOOK_SMART_APP || app.appType === AppType.API_ONLY, - }, - ) - - await command.client.apps.register(id) - console.log(`Registration request sent to app ${id}. Check server log for confirmation URL.`) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/settings.ts b/src/commands/apps/settings.ts deleted file mode 100644 index 9d5b67f3..00000000 --- a/src/commands/apps/settings.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { apiCommand, apiCommandBuilder, type APICommandFlags } from '../../lib/command/api-command.js' -import { buildEpilog } from '../../lib/help.js' -import { outputItem, outputItemBuilder, OutputItemFlags } from '../../lib/command/output-item.js' -import { buildTableOutput, chooseApp } from '../../lib/command/util/apps-util.js' - - -export type CommandArgs = - & APICommandFlags - & OutputItemFlags - & { - idOrIndex?: string - } - -const command = 'apps:settings [id-or-index]' - -const describe = 'get the settings of an app' - -const builder = (yargs: Argv): Argv => - outputItemBuilder(apiCommandBuilder(yargs)) - .positional('id-or-index', { describe: 'the app id or number from list', type: 'string' }) - .example([ - ['$0 apps:settings', 'prompt for an app and get settings for it'], - [ - '$0 apps:settings 3', - 'get settings for the third app in the list generated by "smartthings apps"', - ], - [ - '$0 apps:settings 392bcb11-e251-44f3-b58b-17f93015f3aa', - 'get the settings of the app with the given id', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: ['getAppSettings'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - - const id = await chooseApp(command, argv.idOrIndex, { allowIndex: true }) - - await outputItem( - command, - { buildTableOutput: appSettings => buildTableOutput(command.tableGenerator, appSettings) }, - () => command.client.apps.getSettings(id), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/settings/update.ts b/src/commands/apps/settings/update.ts deleted file mode 100644 index 74f40c7e..00000000 --- a/src/commands/apps/settings/update.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { type AppSettingsRequest, type AppSettingsResponse } from '@smartthings/core-sdk' - -import { - apiCommand, - apiCommandBuilder, - type APICommandFlags, -} from '../../../lib/command/api-command.js' -import { buildEpilog } from '../../../lib/help.js' -import { - inputAndOutputItem, - inputAndOutputItemBuilder, - type InputAndOutputItemFlags, -} from '../../../lib/command/input-and-output-item.js' -import { buildTableOutput, chooseApp } from '../../../lib/command/util/apps-util.js' - - -export type CommandArgs = APICommandFlags & InputAndOutputItemFlags & { - id?: string -} - -const command = 'apps:settings:update [id]' - -const describe = 'update the settings of an app' - -const builder = (yargs: Argv): Argv => - inputAndOutputItemBuilder(apiCommandBuilder(yargs)) - .positional('id', { describe: 'app id', type: 'string' }) - .example([ - [ - '$0 apps:settings:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i app-settings.json', - 'update the settings of the app with the given id using the data in "app-settings.json"', - ], - [ - '$0 apps:settings:update -i app-settings.json', - 'ask for the ID of an app to update and then update it using the data in "app-settings.json"', - ], - ]) - .epilog(buildEpilog({ command, apiDocs: ['updateAppSettings'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - const appId = await chooseApp(command, argv.id) - await inputAndOutputItem( - command, - { buildTableOutput: (data: AppSettingsResponse) => buildTableOutput(command.tableGenerator, data) }, - (_, data: AppSettingsRequest) => command.client.apps.updateSettings(appId, data), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts deleted file mode 100644 index 91c1fa99..00000000 --- a/src/commands/apps/update.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' - -import { type AppUpdateRequest, type AppResponse } from '@smartthings/core-sdk' - -import { apiCommand, apiCommandBuilder, type APICommandFlags } from '../../lib/command/api-command.js' -import { buildEpilog } from '../../lib/help.js' -import { lambdaAuthBuilder, type LambdaAuthFlags } from '../../lib/command/common-flags.js' -import { type TableCommonOutputProducer } from '../../lib/command/format.js' -import { - inputAndOutputItem, - inputAndOutputItemBuilder, - type InputAndOutputItemFlags, -} from '../../lib/command/input-and-output-item.js' -import { userInputProcessor } from '../../lib/command/input-processor.js' -import { type ActionFunction } from '../../lib/command/io-defs.js' -import { getAppUpdateRequestFromUser } from '../../lib/command/util/apps-user-input-update.js' -import { authorizeApp, chooseApp, tableFieldDefinitions } from '../../lib/command/util/apps-util.js' - - -export type CommandArgs = - & APICommandFlags - & LambdaAuthFlags - & InputAndOutputItemFlags - & { - authorize: boolean - id?: string - } - -const command = 'apps:update [id]' - -const describe = 'update the settings of the app' - -const builder = (yargs: Argv): Argv => - inputAndOutputItemBuilder(lambdaAuthBuilder(apiCommandBuilder(yargs))) - .positional('id', { describe: 'app id', type: 'string' }) - .option( - 'authorize', - { describe: 'authorize Lambda functions to be called by SmartThings', type: 'boolean', default: false }, - ) - .example([ - [ - '$0 apps:update', - 'prompt for an app and edit it interactively', - ], - [ - '$0 apps:update -i my-app.json', - 'prompt for an app and update it using the data in "my-app.json"', - ], - [ - '$0 apps:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i my-app.json', - 'update the app with the given id using the data in "my-app.json"', - ], - [ - '$0 apps:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i my-app.json --authorize', - 'update the given app using the data in "my-app.json" and then authorize it\n' + - '(See "smartthings apps:authorize" for more information on authorization.)', - ], - ]) - .epilog('See apps:oauth:update and apps:oauth:generate for updating oauth-related data.\n\n' + - buildEpilog({ command, apiDocs: ['updateApp'] })) - -const handler = async (argv: ArgumentsCamelCase): Promise => { - const command = await apiCommand(argv) - const appId = await chooseApp(command, argv.id) - - const executeUpdate: ActionFunction = async (_, data) => { - if (argv.authorize) { - await authorizeApp(data, argv.principal, argv.statement) - } - return command.client.apps.update(appId, data) - } - - const config: TableCommonOutputProducer = { tableFieldDefinitions } - await inputAndOutputItem( - command, - config, - executeUpdate, - userInputProcessor(() => getAppUpdateRequestFromUser(command, appId)), - ) -} - -const cmd: CommandModule = { command, describe, builder, handler } -export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index d64bc593..6834acbe 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,16 +1,5 @@ import { CommandModule } from 'yargs' -import appsCommand from './apps.js' -import appsAuthorizeCommand from './apps/authorize.js' -import appsCreateCommand from './apps/create.js' -import appsDeleteCommand from './apps/delete.js' -import appsOAuthCommand from './apps/oauth.js' -import appsOAuthGenerateCommand from './apps/oauth/generate.js' -import appsOAuthUpdateCommand from './apps/oauth/update.js' -import appsRegisterCommand from './apps/register.js' -import appsSettingsCommand from './apps/settings.js' -import appsSettingsUpdateCommand from './apps/settings/update.js' -import appsUpdateCommand from './apps/update.js' import capabilitiesCommand from './capabilities.js' import capabilitiesCreateCommand from './capabilities/create.js' import capabilitiesDeleteCommand from './capabilities/delete.js' @@ -126,6 +115,16 @@ import schemaCreateCommand from './schema/create.js' import schemaDeleteCommand from './schema/delete.js' import schemaRegenerateCommand from './schema/regenerate.js' import schemaUpdateCommand from './schema/update.js' +import smartappsCommand from './smartapps.js' +import smartappsConfirmCommand from './smartapps/confirm.js' +import smartappsCreateCommand from './smartapps/create.js' +import smartappsDeleteCommand from './smartapps/delete.js' +import smartappsOAuthCommand from './smartapps/oauth.js' +import smartappsOAuthGenerateCommand from './smartapps/oauth/generate.js' +import smartappsOAuthUpdateCommand from './smartapps/oauth/update.js' +import smartappsSettingsCommand from './smartapps/settings.js' +import smartappsSettingsUpdateCommand from './smartapps/settings/update.js' +import smartappsUpdateCommand from './smartapps/update.js' import virtualdevicesCommand from './virtualdevices.js' import virtualdevicesCreateCommand from './virtualdevices/create.js' import virtualdevicesCreateStandardCommand from './virtualdevices/create-standard.js' @@ -136,17 +135,6 @@ import virtualdevicesUpdateCommand from './virtualdevices/update.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const commands: CommandModule[] = [ - appsCommand, - appsAuthorizeCommand, - appsCreateCommand, - appsDeleteCommand, - appsOAuthCommand, - appsOAuthGenerateCommand, - appsOAuthUpdateCommand, - appsRegisterCommand, - appsSettingsCommand, - appsSettingsUpdateCommand, - appsUpdateCommand, capabilitiesCommand, capabilitiesCreateCommand, capabilitiesDeleteCommand, @@ -262,6 +250,16 @@ export const commands: CommandModule[] = [ schemaDeleteCommand, schemaRegenerateCommand, schemaUpdateCommand, + smartappsCommand, + smartappsConfirmCommand, + smartappsCreateCommand, + smartappsDeleteCommand, + smartappsOAuthCommand, + smartappsOAuthGenerateCommand, + smartappsOAuthUpdateCommand, + smartappsSettingsCommand, + smartappsSettingsUpdateCommand, + smartappsUpdateCommand, virtualdevicesCommand, virtualdevicesCreateCommand, virtualdevicesCreateStandardCommand, diff --git a/src/commands/smartapps.ts b/src/commands/smartapps.ts new file mode 100644 index 00000000..edce1c8b --- /dev/null +++ b/src/commands/smartapps.ts @@ -0,0 +1,123 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { + type PagedSmartApp, + type SmartAppClassification, + smartAppClassificationValues, + type SmartAppListOptions, + type SmartAppResponse, + type SmartThingsClient, + type ViewSmartAppType, + viewSmartAppTypeValues, +} from '@smartthings/core-sdk' + +import { forAllOrganizations, type WithOrganization } from '../lib/api-helpers.js' +import { buildEpilog } from '../lib/help.js' +import { type TableFieldDefinition } from '../lib/table-generator.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../lib/command/api-organization-command.js' +import { type AllOrganizationFlags, allOrganizationsBuilder } from '../lib/command/common-flags.js' +import { + type OutputItemOrListConfig, + type OutputItemOrListFlags, + outputItemOrList, + outputItemOrListBuilder, +} from '../lib/command/listing-io.js' +import { tableFieldDefinitions } from '../lib/command/util/smartapps-table.js' +import { shortARNorURL, verboseSmartApps } from '../lib/command/util/smartapps-util.js' + + +export type CommandArgs = APIOrganizationCommandFlags & AllOrganizationFlags & OutputItemOrListFlags & { + type?: ViewSmartAppType + classification?: SmartAppClassification[] + verbose: boolean + idOrIndex?: string +} + +type ListedSmartApp = (PagedSmartApp | SmartAppResponse) & Partial + +const command = 'smartapps [id-or-index]' + +const describe = 'get a specific Smart App or a list of Smart Apps' + +const builder = (yargs: Argv): Argv => + outputItemOrListBuilder(allOrganizationsBuilder(apiOrganizationCommandBuilder(yargs))) + .positional('id-or-index', { describe: 'the Smart App id or number from list', type: 'string' }) + .option('type', { + describe: 'filter results by Smart App type', + type: 'string', + choices: viewSmartAppTypeValues, + coerce: arg => arg.toUpperCase() as ViewSmartAppType, + }) + .option('classification', { + describe: 'filter results by one or more classifications', + type: 'string', + array: true, + choices: smartAppClassificationValues, + coerce: arg => arg?.map((str: string) => str.toUpperCase() as SmartAppClassification), + }) + .option('verbose', + { alias: 'v', describe: 'include URLs and ARNs in table output', type: 'boolean', default: false }) + .example([ + ['$0 smartapps', 'list all Smart Apps'], + [ + '$0 smartapps 1', + 'display details for the first smart app in the list retrieved by running "smartthings smartapps"', + ], + ['$0 smartapps 5dfd6626-ab1d-42da-bb76-90def3153998', 'display details for a Smart App by id'], + ['$0 smartapps --verbose', 'include URLs and ARNs in the output'], + ['$0 smartapps --classification SERVICE', 'list SERVICE classification Smart Apps'], + ['$0 smartapps --type API_ONLY', 'list API-only Smart Apps'], + ]) + .epilog(buildEpilog({ command, apiDocs: ['listSmartApps', 'getSmartApp'] })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const listTableFieldDefinitions: TableFieldDefinition[] = + ['displayName', 'appType', 'appId'] + if (argv.verbose) { + listTableFieldDefinitions.push({ label: 'Target URL or ARN', value: shortARNorURL }) + } + const config: OutputItemOrListConfig = { + primaryKeyName: 'appId', + sortKeyName: 'displayName', + tableFieldDefinitions, + listTableFieldDefinitions, + } + + const listApps = async (): Promise => { + const appListOptions: SmartAppListOptions = {} + + if (argv.type) { + appListOptions.appType = argv.type + } + + if (argv.classification) { + appListOptions.classification = argv.classification + } + + const listForClient = (client: SmartThingsClient, accountId?: string): Promise => { + const options = accountId ? { ...appListOptions, accountId } : appListOptions + return argv.verbose ? verboseSmartApps(client, options) : client.smartapps.list(options) + } + + if (argv.allOrganizations) { + listTableFieldDefinitions.push('organization') + return forAllOrganizations( + command.client, + (orgClient, org) => listForClient(orgClient, org.organizationId), + ) + } + + return listForClient(command.client, command.client.config.headers?.['X-ST-Organization']) + } + + await outputItemOrList(command, config, argv.idOrIndex, listApps, id => command.client.smartapps.get(id)) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/confirm.ts b/src/commands/smartapps/confirm.ts new file mode 100644 index 00000000..13b3f5e9 --- /dev/null +++ b/src/commands/smartapps/confirm.ts @@ -0,0 +1,45 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { buildEpilog } from '../../lib/help.js' +import { chooseSmartAppFn } from '../../lib/command/util/smartapps-choose.js' + + +export type CommandArgs = APIOrganizationCommandFlags & { + id?: string +} + +const command = 'smartapps:confirm [id]' + +const describe = 'send request to Smart App target URL to confirm existence and authorize lifecycle events' + +const builder = (yargs: Argv): Argv => + apiOrganizationCommandBuilder(yargs) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + ['$0 smartapps:confirm', 'choose the app to confirm from a list'], + [ + '$0 smartapps:confirm 392bcb11-e251-44f3-b58b-17f93015f3aa', + 'send confirmation request to the app with the given id', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: 'registerSmartApp' })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const id = await chooseSmartAppFn({ + appType: 'API_ONLY', + accountId: command.client.config.headers?.['X-ST-Organization'], + })(command, argv.id) + + await command.client.smartapps.register(id) + console.log(`Confirmation request sent to Smart App ${id}. Check server log for confirmation URL.`) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/create.ts b/src/commands/smartapps/create.ts new file mode 100644 index 00000000..6feed2fc --- /dev/null +++ b/src/commands/smartapps/create.ts @@ -0,0 +1,89 @@ +import type { ArgumentsCamelCase, Argv, CommandModule } from 'yargs' + +import { type SmartAppCreateRequest, type SmartAppCreationResponse } from '@smartthings/core-sdk' + +import { buildEpilog } from '../../lib/help.js' +import { fatalError } from '../../lib/util.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + type InputAndOutputItemFlags, +} from '../../lib/command/input-and-output-item.js' +import { userInputProcessor } from '../../lib/command/input-processor.js' +import { tableFieldDefinitions } from '../../lib/command/util/smartapps-table.js' +import { getAppCreateRequestFromUser } from '../../lib/command/util/smartapps-user-input-create.js' +import { chooseOrganization } from '../../lib/command/util/organizations-util.js' + + +export type CommandArgs = + & APIOrganizationCommandFlags + & InputAndOutputItemFlags + +const command = 'smartapps:create' + +const describe = 'create a Smart App' + +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .example([ + [ '$0 smartapps:create', 'create an API Access app from prompted input'], + ['$0 smartapps:create -i my-app.yaml', 'create an app defined in "my-app.yaml"'], + ]) + .epilog(buildEpilog({ + command, + apiDocs: ['createSmartApp'], + notes: 'Running this command is similar to "Register" in the Developer Center.' + + '\n https://developers.smartthingsgdev.com/console/service-integrations', + })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const accountId = argv.dryRun ? undefined : await chooseOrganization( + command, + command.client.config.headers?.['X-ST-Organization'], + { + autoChoose: true, + notATTYMessage: 'Input and/or output is not an interactive terminal.' + + '\nYou must specify an organization via the command line or config file.', + }, + ) + + const createSmartApp = async (_: void, data: SmartAppCreateRequest): Promise => + command.client.smartapps.create(data, accountId).catch(error => { + if (error.response?.status === 403) { + return fatalError('Creating a Smart App requires a paid account. Visit' + + '\n https://developer.smartthings.com/console/service-integrations/manage-plans' + + '\nto upgrade.') + } + throw error + }) + + const buildTableOutput = (data: SmartAppCreationResponse): string => { + const basicInfo = command.tableGenerator.buildTableFromItem(data.app, tableFieldDefinitions) + + const oauthInfo = data.oauthClientId || data.oauthClientSecret + ? command.tableGenerator.buildTableFromItem(data, ['oauthClientId', 'oauthClientSecret']) + : undefined + return oauthInfo + ? `Basic App Data:\n${basicInfo}\n\n` + + 'OAuth Info (you will not be able to see the OAuth info again so please save' + + ` it now!):\n${oauthInfo}` + : basicInfo + } + + await inputAndOutputItem( + command, + { buildTableOutput }, + createSmartApp, + userInputProcessor(() => getAppCreateRequestFromUser(command)), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/delete.ts b/src/commands/smartapps/delete.ts new file mode 100644 index 00000000..cde3bc16 --- /dev/null +++ b/src/commands/smartapps/delete.ts @@ -0,0 +1,41 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { buildEpilog } from '../../lib/help.js' +import { + type APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../lib/command/api-organization-command.js' +import { chooseSmartAppFn } from '../../lib/command/util/smartapps-choose.js' + + +export type CommandArgs = APIOrganizationCommandFlags & { + id?: string +} + +const command = 'smartapps:delete [id]' + +const describe = 'delete a Smart App' + +const builder = (yargs: Argv): Argv => + apiOrganizationCommandBuilder(yargs) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + ['$0 smartapps:delete', 'choose the Smart App to delete from a list'], + [ + '$0 smartapps:delete 5dfd6626-ab1d-42da-bb76-90def3153998', + 'delete the Smart App with the specified id', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: 'deleteSmartApp' })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const id = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })(command, argv.id) + await command.client.smartapps.delete(id) + console.log(`Smart App ${id} deleted.`) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/oauth.ts b/src/commands/smartapps/oauth.ts new file mode 100644 index 00000000..44def426 --- /dev/null +++ b/src/commands/smartapps/oauth.ts @@ -0,0 +1,55 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { buildEpilog } from '../../lib/help.js' +import { outputItem, outputItemBuilder, type OutputItemFlags } from '../../lib/command/output-item.js' +import { chooseSmartAppFn } from '../../lib/command/util/smartapps-choose.js' +import { oauthTableFieldDefinitions } from '../../lib/command/util/smartapps-table.js' + + +export type CommandArgs = + & APIOrganizationCommandFlags + & OutputItemFlags + & { + idOrIndex?: string + } + +const command = 'smartapps:oauth [id-or-index]' + +const describe = 'get OAuth information for a Smart App' + +const builder = (yargs: Argv): Argv => + outputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id-or-index', { describe: 'the Smart App id or number from list', type: 'string' }) + .example([ + ['$0 smartapps:oauth', 'prompt for a Smart App and list OAuth information for it'], + [ + '$0 smartapps:oauth 2', + 'list OAuth information for the second Smart App in the list generated by "smartthings smartapps"', + ], + [ + '$0 smartapps:oauth 392bcb11-e251-44f3-b58b-17f93015f3aa', + 'list OAuth information for the Smart App with the given id', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: 'getSmartAppOauth' })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const id = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })( + command, argv.idOrIndex, { allowIndex: true }, + ) + await outputItem( + command, + { tableFieldDefinitions: oauthTableFieldDefinitions }, + () => command.client.smartapps.getOauth(id), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/oauth/generate.ts b/src/commands/smartapps/oauth/generate.ts new file mode 100644 index 00000000..e5c41d83 --- /dev/null +++ b/src/commands/smartapps/oauth/generate.ts @@ -0,0 +1,87 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import type { GenerateSmartAppOAuthRequest, GenerateSmartAppOAuthResponse } from '@smartthings/core-sdk' + +import { buildEpilog, itemInputHelpText } from '../../../lib/help.js' +import { type TableFieldDefinition } from '../../../lib/table-generator.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../../lib/command/api-organization-command.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + type InputAndOutputItemConfig, + type InputAndOutputItemFlags, +} from '../../../lib/command/input-and-output-item.js' +import { userInputProcessor } from '../../../lib/command/input-processor.js' +import { oauthAppScopeDef } from '../../../lib/command/util/smartapps-input-primitives.js' +import { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { objectDef, stringDef, updateFromUserInput } from '../../../lib/item-input/index.js' + + +export type CommandArgs = APIOrganizationCommandFlags & InputAndOutputItemFlags & { + id?: string +} + +const command = 'smartapps:oauth:generate [id]' + +const describe = 'regenerate the OAuth clientId and clientSecret of a Smart App' + +const docNames = 'generateSmartAppOauth' +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + [ + '$0 smartapps:oauth:generate', + 'prompt for a Smart App and then regenerate the OAuth clientId and clientSecret for it', + ], + [ + '$0 smartapps:oauth:generate 392bcb11-e251-44f3-b58b-17f93015f3aa', + 'regenerate the OAuth clientId and clientSecret of the Smart App with the given id', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: [docNames] })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const appId = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })(command, argv.id) + + const tableFieldDefinitions: TableFieldDefinition[] = [ + { path: 'oauthClientDetails.clientName' }, + { path: 'oauthClientDetails.scope' }, + { path: 'oauthClientDetails.redirectUris' }, + { path: 'oauthClientDetails.corsDomains' }, + 'oauthClientId', + 'oauthClientSecret', + ] + const config: InputAndOutputItemConfig = { + tableFieldDefinitions, + } + const getInputFromUser = async (): Promise => { + const originalOauth = await command.client.smartapps.getOauth(appId) + const startingRequest: GenerateSmartAppOAuthRequest = { + clientName: originalOauth.clientName, + scope: originalOauth.scope ?? [], + } + const inputDef = objectDef('Generate Request', { + clientName: stringDef('Client Name'), + scope: oauthAppScopeDef, + }, { helpText: itemInputHelpText(docNames) }) + + return updateFromUserInput(command, inputDef, startingRequest, { dryRun: !!argv.dryRun }) + } + + await inputAndOutputItem( + command, + config, + (_, data: GenerateSmartAppOAuthRequest) => command.client.smartapps.regenerateOauth(appId, data), + userInputProcessor(getInputFromUser), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/oauth/update.ts b/src/commands/smartapps/oauth/update.ts new file mode 100644 index 00000000..d5a997d9 --- /dev/null +++ b/src/commands/smartapps/oauth/update.ts @@ -0,0 +1,84 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type SmartAppOAuthRequest } from '@smartthings/core-sdk' + +import { buildEpilog, itemInputHelpText } from '../../../lib/help.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + APIOrganizationCommandFlags, +} from '../../../lib/command/api-organization-command.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + InputAndOutputItemFlags, +} from '../../../lib/command/input-and-output-item.js' +import { userInputProcessor } from '../../../lib/command/input-processor.js' +import { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { oauthTableFieldDefinitions } from '../../../lib/command/util/smartapps-table.js' +import { + corsDomainsDef, + oauthAppScopeDef, + redirectUrisDef, +} from '../../../lib/command/util/smartapps-input-primitives.js' +import { objectDef, stringDef, updateFromUserInput } from '../../../lib/item-input/index.js' + + +export type CommandArgs = APIOrganizationCommandFlags & InputAndOutputItemFlags & { + id?: string +} + +const command = 'smartapps:oauth:update [id]' + +const describe = 'update the OAuth settings of a Smart App' + +const docNames = 'updateSmartAppOauth' +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + [ + '$0 smartapps:oauth:update', + 'prompt for a Smart App and update its OAuth settings interactively', + ], + [ + '$0 smartapps:oauth:update -i oauth-settings.json', + 'prompt for a Smart App and update its OAuth settings using the data in "oauth-settings.json', + ], + [ + '$0 smartapps:oauth:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i oauth-settings.json', + 'update OAuth settings for the Smart App with the given id using the data in "oauth-settings.json"', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: [docNames] })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const appId = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })(command, argv.id) + + const getInputFromUser = async (): Promise => { + const startingRequest: SmartAppOAuthRequest = await command.client.smartapps.getOauth(appId) + if (!startingRequest.scope) { + startingRequest.scope = [] + } + if (!startingRequest.corsDomains) { + startingRequest.corsDomains = [] + } + const inputDef = objectDef('OAuth Settings', { + clientName: stringDef('Client Name'), + scope: oauthAppScopeDef, + redirectUris: redirectUrisDef, + corsDomains: corsDomainsDef, + }, { helpText: itemInputHelpText(docNames) }) + + return updateFromUserInput(command, inputDef, startingRequest, { dryRun: !!argv.dryRun }) + } + + await inputAndOutputItem(command, { tableFieldDefinitions: oauthTableFieldDefinitions }, + (_, data: SmartAppOAuthRequest) => command.client.smartapps.updateOauth(appId, data), + userInputProcessor(getInputFromUser)) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/settings.ts b/src/commands/smartapps/settings.ts new file mode 100644 index 00000000..43769c3a --- /dev/null +++ b/src/commands/smartapps/settings.ts @@ -0,0 +1,56 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { buildEpilog } from '../../lib/help.js' +import { outputItem, outputItemBuilder, OutputItemFlags } from '../../lib/command/output-item.js' +import { chooseSmartAppFn } from '../../lib/command/util/smartapps-choose.js' +import { buildTableOutput } from '../../lib/command/util/smartapps-table.js' + + +export type CommandArgs = + & APIOrganizationCommandFlags + & OutputItemFlags + & { + idOrIndex?: string + } + +const command = 'smartapps:settings [id-or-index]' + +const describe = 'get the settings of a Smart App' + +const builder = (yargs: Argv): Argv => + outputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id-or-index', { describe: 'the Smart App id or number from list', type: 'string' }) + .example([ + ['$0 smartapps:settings', 'prompt for a Smart App and get settings for it'], + [ + '$0 smartapps:settings 3', + 'get settings for the third Smart App in the list generated by "smartthings smartapps"', + ], + [ + '$0 smartapps:settings 392bcb11-e251-44f3-b58b-17f93015f3aa', + 'get the settings of the Smart App with the given id', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: ['getSmartAppSettings'] })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const id = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })( + command, argv.idOrIndex, { allowIndex: true }, + ) + + await outputItem( + command, + { buildTableOutput: appSettings => buildTableOutput(command.tableGenerator, appSettings) }, + () => command.client.smartapps.getSettings(id), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/settings/update.ts b/src/commands/smartapps/settings/update.ts new file mode 100644 index 00000000..1701d53f --- /dev/null +++ b/src/commands/smartapps/settings/update.ts @@ -0,0 +1,54 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type SmartAppSettingsRequest, type SmartAppSettingsResponse } from '@smartthings/core-sdk' + +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../../lib/command/api-organization-command.js' +import { buildEpilog } from '../../../lib/help.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + type InputAndOutputItemFlags, +} from '../../../lib/command/input-and-output-item.js' +import { chooseSmartAppFn } from '../../../lib/command/util/smartapps-choose.js' +import { buildTableOutput } from '../../../lib/command/util/smartapps-table.js' + + +export type CommandArgs = APIOrganizationCommandFlags & InputAndOutputItemFlags & { + id?: string +} + +const command = 'smartapps:settings:update [id]' + +const describe = 'update the settings of a Smart App' + +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + [ + '$0 smartapps:settings:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i app-settings.json', + 'update the settings of the Smart App with the given id using the data in "app-settings.json"', + ], + [ + '$0 smartapps:settings:update -i app-settings.json', + 'ask for the id of a Smart App to update and then update it using the data in "app-settings.json"', + ], + ]) + .epilog(buildEpilog({ command, apiDocs: 'updateSmartAppSettings' })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + const appId = await chooseSmartAppFn({ accountId: command.client.config.headers?.['X-ST-Organization'] })(command, argv.id) + await inputAndOutputItem( + command, + { buildTableOutput: (data: SmartAppSettingsResponse) => buildTableOutput(command.tableGenerator, data) }, + (_, data: SmartAppSettingsRequest) => command.client.smartapps.updateSettings(appId, data), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/smartapps/update.ts b/src/commands/smartapps/update.ts new file mode 100644 index 00000000..cf04534e --- /dev/null +++ b/src/commands/smartapps/update.ts @@ -0,0 +1,78 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type SmartAppUpdateRequest, type SmartAppResponse } from '@smartthings/core-sdk' + +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { buildEpilog } from '../../lib/help.js' +import { type TableCommonOutputProducer } from '../../lib/command/format.js' +import { + inputAndOutputItem, + inputAndOutputItemBuilder, + type InputAndOutputItemFlags, +} from '../../lib/command/input-and-output-item.js' +import { userInputProcessor } from '../../lib/command/input-processor.js' +import { type ActionFunction } from '../../lib/command/io-defs.js' +import { getAppUpdateRequestFromUser } from '../../lib/command/util/smartapps-user-input-update.js' +import { chooseSmartAppFn } from '../../lib/command/util/smartapps-choose.js' +import { tableFieldDefinitions } from '../../lib/command/util/smartapps-table.js' + + +export type CommandArgs = + & APIOrganizationCommandFlags + & InputAndOutputItemFlags + & { + id?: string + } + +const command = 'smartapps:update [id]' + +const describe = 'update a Smart App' + +const builder = (yargs: Argv): Argv => + inputAndOutputItemBuilder(apiOrganizationCommandBuilder(yargs)) + .positional('id', { describe: 'Smart App id', type: 'string' }) + .example([ + [ + '$0 smartapps:update', + 'prompt for a Smart App and edit it interactively', + ], + [ + '$0 smartapps:update -i my-app.json', + 'prompt for a Smart App and update it using the data in "my-app.json"', + ], + [ + '$0 smartapps:update 392bcb11-e251-44f3-b58b-17f93015f3aa -i my-app.json', + 'update the Smart App with the given id using the data in "my-app.json"', + ], + ]) + .epilog(buildEpilog({ + command, + notes: 'See smartapps:oauth:update and smartapps:oauth:generate for updating oauth-related data.', + apiDocs: ['updateSmartApp'], + })) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + const appId = await chooseSmartAppFn({ + appType: 'API_ONLY', + accountId: command.client.config.headers?.['X-ST-Organization'], + })(command, argv.id) + + const executeUpdate: ActionFunction = async (_, data) => + command.client.smartapps.update(appId, data) + + const config: TableCommonOutputProducer = { tableFieldDefinitions } + await inputAndOutputItem( + command, + config, + executeUpdate, + userInputProcessor(() => getAppUpdateRequestFromUser(command, appId)), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/lib/aws-util.ts b/src/lib/aws-util.ts index e058640a..a431b26e 100644 --- a/src/lib/aws-util.ts +++ b/src/lib/aws-util.ts @@ -1,9 +1,10 @@ import { LambdaClient, AddPermissionCommand, AddPermissionRequest } from '@aws-sdk/client-lambda' -export const addPermission = async ( +export const schemaAWSPrincipal = '148790070172' +export const addSchemaPermission = async ( arn: string, - principal = '906037444270', + principal = schemaAWSPrincipal, statementId = 'smartthings', ): Promise => { const segments = arn.split(':') @@ -35,13 +36,6 @@ export const addPermission = async ( } } -export const schemaAWSPrincipal = '148790070172' -export const addSchemaPermission = ( - arn: string, - principal = schemaAWSPrincipal, - statementId = 'smartthings', -): Promise => addPermission(arn, principal, statementId) - /** * Help text for use in `InputDefinition` instances. */ diff --git a/src/lib/command/input-processor.ts b/src/lib/command/input-processor.ts index cd672b9a..aefca80f 100644 --- a/src/lib/command/input-processor.ts +++ b/src/lib/command/input-processor.ts @@ -6,7 +6,9 @@ import { parseJSONOrYAML, readDataFromStdin, stdinIsTTY, + stdoutIsTTY, } from '../io-util.js' +import { fatalError } from '../util.js' export type InputProcessor = { @@ -89,6 +91,31 @@ export function commandLineInputProcessor(command: CommandLineInputCommand return inputProcessor(() => command.hasCommandLineInput(), () => command.getInputFromCommandLine()) } +/** + * A Q & A session needs an interactive terminal for both the questions and the answers. Without + * one the prompting library writes control characters into redirected output and then fails + * obscurely, so bail out here with an explanation instead. + * + * The two causes have different remedies, so they get different messages, and neither message + * suggests something the user has already tried. Reaching the stdin case means `stdinInputProcessor` + * already read stdin and got nothing, so stdin is not offered as a remedy. Suggesting `--input` for + * the stdout case would be wrong too: building an input file by answering prompts under `--dry-run` + * is exactly what that combination is for, and `--output` keeps the prompts and the JSON separate. + */ +const userInputHasInput = (): boolean => { + if (!stdinIsTTY()) { + return fatalError('No input was received on stdin and it is not an interactive terminal,' + + ' so you cannot be prompted for input. Specify input with the --input option or run in' + + ' an interactive terminal.') + } + if (!stdoutIsTTY()) { + return fatalError('Output is not an interactive terminal so prompts would be mixed into' + + ' the output. Use the --output option to write results to a file instead of' + + ' redirecting output.') + } + return true +} + export type UserInputCommand = { getInputFromUser(): Promise } @@ -102,9 +129,9 @@ export function userInputProcessor(command: UserInputCommand): InputProces export function userInputProcessor(readFn: () => Promise): InputProcessor export function userInputProcessor(commandOrReadFn: UserInputCommand | (() => Promise)): InputProcessor { if (typeof commandOrReadFn === 'function') { - return inputProcessor(() => true, commandOrReadFn) + return inputProcessor(userInputHasInput, commandOrReadFn) } - return inputProcessor(() => true, () => commandOrReadFn.getInputFromUser()) + return inputProcessor(userInputHasInput, () => commandOrReadFn.getInputFromUser()) } export const combinedInputProcessor = (inputProcessor: InputProcessor, ...moveInputProcessors: InputProcessor[]): InputProcessor => { diff --git a/src/lib/command/select.ts b/src/lib/command/select.ts index 99ec8b27..613ebe05 100644 --- a/src/lib/command/select.ts +++ b/src/lib/command/select.ts @@ -7,15 +7,22 @@ import { type Naming, type Sorting, } from './io-defs.js' -import { outputList, type OutputListConfig } from './output-list.js' +import { fatalError } from '../util.js' import { resetManagedConfigKey, setConfigKey } from '../cli-config.js' import { stringGetIdFromUser } from './command-util.js' +import { stdinIsTTY, stdoutIsTTY } from '../io-util.js' +import { outputList, type OutputListConfig } from './output-list.js' import { type SmartThingsCommand } from './smartthings-command.js' import { buildOutputFormatterBuilder, BuildOutputFormatterFlags } from './output-builder.js' export type SelectFromListConfig = Sorting & Naming & OutputListConfig +/** + * Make a decent guess as to the correct indefinite article for a word. Anything that uses this + * should allow a manual override for exceptions. (e.g. the prompt message for `selectFromList` + * is automatically generated using the item name but can be overridden with `promptMessage`) + */ export const indefiniteArticleFor = (name: string): string => name.match(/^[aeio]/i) ? 'an' : 'a' function promptFromNaming(config: Naming): string | undefined { @@ -60,6 +67,12 @@ export type PromptUserOptions = { * A custom error message to display when no items are found. */ customNotFoundMessage?: string + + /** + * A custom error message to display when input and/or output is not an interactive terminal + * and no item could be selected without prompting. + */ + notATTYMessage?: string } /** @@ -153,7 +166,7 @@ export async function selectFromList( try { const item = await options.defaultValue.getItem(configuredDefault) if (item) { - console.log(options.defaultValue.userMessage(item)) + console.error(options.defaultValue.userMessage(item)) return configuredDefault } } catch (error) { @@ -167,7 +180,23 @@ export async function selectFromList( } } - const userSelected = await promptUser(command, config, options) + // We want to ensure that `listItems` is called no more than once, so cache its result here in case + // it is needed both for the not-a-TTY check below and by `promptUser`. + let cachedItems: Promise | undefined + const listItems = (): Promise => cachedItems ??= options.listItems() + + if (!stdinIsTTY() || !stdoutIsTTY()) { + const availableItems = await listItems() + // If no available items, skip this error message and let `promptUser` report no items. + if (!(options.autoChoose && availableItems.length === 1) && availableItems.length > 0) { + const itemName = config.itemName ?? 'item' + return fatalError(options.notATTYMessage ?? + 'Input and/or output is not an interactive terminal; you must specify ' + + `${indefiniteArticleFor(itemName)} ${itemName}.`) + } + } + + const userSelected = await promptUser(command, config, { ...options, listItems }) const neverAgainKey = `${options.defaultValue?.configKey ?? ''}::neverAskForSaveAgain` if (options.defaultValue && !command.cliConfig.booleanConfigValue(neverAgainKey)) { diff --git a/src/lib/command/util/apps-user-input-create.ts b/src/lib/command/util/apps-user-input-create.ts deleted file mode 100644 index fe9a176c..00000000 --- a/src/lib/command/util/apps-user-input-create.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { select } from '@inquirer/prompts' -import { v4 as uuid } from 'uuid' - -import { - AppClassification, - AppType, - PrincipalType, - type AppCreateRequest, -} from '@smartthings/core-sdk' - -import { cancelCommand, sanitize } from '../../util.js' -import { httpsURLValidate, stringValidateFn } from '../../validate-util.js' -import { - computedDef, - createFromUserInput, - objectDef, - optionalStringDef, - staticDef, - stringDef, -} from '../../item-input/index.js' -import { oauthAppScopeDef, redirectUrisDef, smartAppHelpText } from './apps-input-primitives.js' -import type { InputAndOutputItemFlags } from '../input-and-output-item.js' -import { type SmartThingsCommandFlags, type SmartThingsCommand } from '../smartthings-command.js' - - -const appNameDef = computedDef((context?: unknown[]): string => { - if (!context || context.length === 0) { - throw Error('invalid context for appName computed input definition') - } - const displayName = (context[0] as Pick).displayName - - const retVal = `${sanitize(displayName)}-${uuid()}`.toLowerCase() - // the app name has to start with a letter or number - return retVal.match(/^[a-z]/) ? retVal : 'a' + retVal -}) - -const clientNameDef = computedDef((context?: unknown[]): string => { - if (!context || context.length !== 2) { - throw Error('invalid context for clientName computed input definition') - } - return (context[1] as Pick).displayName -}) - -const oauthAppCreateRequestInputDefinition = objectDef('OAuth-In SmartApp', { - displayName: stringDef('Display Name', { validate: stringValidateFn({ maxLength: 75 }) }), - description: stringDef('Description', { validate: stringValidateFn({ maxLength: 250 }) }), - appName: appNameDef, - appType: staticDef(AppType.API_ONLY), - classifications: staticDef([AppClassification.CONNECTED_SERVICE]), - singleInstance: staticDef(true), - iconImage: objectDef('Icon Image', { - url: optionalStringDef('Icon Image URL', { validate: httpsURLValidate }), - }), - apiOnly: objectDef( - 'API Only', - { targetUrl: optionalStringDef('Target URL', { validate: httpsURLValidate }) }, - ), - principalType: staticDef(PrincipalType.LOCATION), - oauth: objectDef('OAuth', { - clientName: clientNameDef, - scope: oauthAppScopeDef, - redirectUris: redirectUrisDef, - }), -}, { helpText: smartAppHelpText }) - -export const getAppCreateRequestFromUser = async ( - command: SmartThingsCommand, -): Promise => { - const action = await select({ - message: 'What kind of app do you want to create? (Currently, only OAuth-In apps are supported.)', - choices: [ - { name: 'OAuth-In App', value: 'oauth-in' }, - { name: 'Cancel', value: 'cancel' }, - ], - default: 'oauth-in', - }) - - if (action === 'oauth-in') { - return createFromUserInput( - command, - oauthAppCreateRequestInputDefinition, - { dryRun: !!command.flags.dryRun }, - ) - } - return cancelCommand() -} diff --git a/src/lib/command/util/apps-user-input-update.ts b/src/lib/command/util/apps-user-input-update.ts deleted file mode 100644 index df9ff39d..00000000 --- a/src/lib/command/util/apps-user-input-update.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - AppType, - type AppUpdateRequest, -} from '@smartthings/core-sdk' - -import { awsHelpText } from '../../aws-util.js' -import { httpsURLValidate } from '../../validate-util.js' -import { - arrayDef, - type InputDefsByProperty, - objectDef, - optionalStringDef, - staticDef, - stringDef, - updateFromUserInput, -} from '../../item-input/index.js' -import { type APICommand } from '../api-command.js' -import { smartAppHelpText } from './apps-input-primitives.js' -import { type InputAndOutputItemFlags } from '../input-and-output-item.js' - - -export const getAppUpdateRequestFromUser = async ( - command: APICommand, - appId: string, -): Promise => { - const { - singleInstance, lambdaSmartApp, webhookSmartApp, apiOnly, ui, iconImage, appName, - appType, classifications, displayName, description, - } = await command.client.apps.get(appId) - const startingRequest: AppUpdateRequest = { - appName, - appType, - classifications, - displayName, - description, - singleInstance, - iconImage: iconImage ?? {}, - ui, - } - const propertyInputDefs: InputDefsByProperty = { - displayName: stringDef('Display Name'), - description: stringDef('Description'), - appName: staticDef(startingRequest.appName), - appType: staticDef(appType), - classifications: staticDef(startingRequest.classifications), - singleInstance: staticDef(startingRequest.singleInstance), - iconImage: objectDef( - 'Icon Image URL', - { url: optionalStringDef('Icon Image URL', { validate: httpsURLValidate }) }, - ), - ui: staticDef(ui), - } - if (appType === AppType.LAMBDA_SMART_APP) { - startingRequest.lambdaSmartApp = lambdaSmartApp - const helpText = awsHelpText - propertyInputDefs.lambdaSmartApp = objectDef('Lambda SmartApp', - { functions: arrayDef('Lambda Functions', stringDef('Lambda Function', { helpText }), { helpText }) }) - } - if (appType === AppType.WEBHOOK_SMART_APP) { - startingRequest.webhookSmartApp = { targetUrl: webhookSmartApp?.targetUrl ?? '' } - propertyInputDefs.webhookSmartApp = objectDef('Webhook SmartApp', { targetUrl: stringDef('Target URL') }) - } - if (appType === AppType.API_ONLY) { - startingRequest.apiOnly = { targetUrl: apiOnly?.subscription?.targetUrl } - propertyInputDefs.apiOnly = objectDef('API-Only SmartApp', { targetUrl: stringDef('Target URL') }) - } - const appUpdateDef = objectDef('App Update', propertyInputDefs, { helpText: smartAppHelpText }) - - return updateFromUserInput(command, appUpdateDef, startingRequest, { dryRun: !!command.flags.dryRun }) -} diff --git a/src/lib/command/util/apps-util.ts b/src/lib/command/util/apps-util.ts deleted file mode 100644 index d31925ba..00000000 --- a/src/lib/command/util/apps-util.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - type AppListOptions, - type AppOAuthRequest, - type AppResponse, - type AppSettingsResponse, - type AppUpdateRequest, - type PagedApp, - type SmartThingsClient, -} from '@smartthings/core-sdk' - -import { addPermission } from '../../aws-util.js' -import { type TableFieldDefinition, type TableGenerator } from '../../table-generator.js' -import { fatalError } from '../../util.js' -import { type ChooseFunction, createChooseFn } from './util-util.js' - - -export const isWebhookSmartApp = (app: AppResponse): boolean => !!app.webhookSmartApp -export const hasSubscription = (app: AppResponse): boolean => !!app.apiOnly?.subscription - -export const tableFieldDefinitions: TableFieldDefinition[] = [ - 'displayName', - 'appId', - 'appName', - 'description', - 'singleInstance', - { prop: 'classifications', include: app => !!app.classifications }, - { path: 'installMetadata.certified', include: app => !!app.installMetadata?.certified }, - { path: 'installMetadata.maxInstalls', include: app => !!app.installMetadata?.maxInstalls }, - 'appType', - { path: 'webhookSmartApp.signatureType', include: isWebhookSmartApp }, - { path: 'webhookSmartApp.targetUrl', include: isWebhookSmartApp }, - { path: 'webhookSmartApp.targetStatus', include: isWebhookSmartApp }, - { - label: 'Public Key', - include: app => !!app.webhookSmartApp?.publicKey, - value: app => app.webhookSmartApp?.publicKey?.replace(/\r\n/g, '\n') ?? '', - }, - { - label: 'Lambda Function', - include: app => !!app.lambdaSmartApp?.functions, - value: app => app.lambdaSmartApp?.functions?.join('\n') ?? '', - }, - { path: 'apiOnly.subscription.targetUrl', include: hasSubscription }, - { path: 'apiOnly.subscription.targetStatus', include: hasSubscription }, -] - -export const oauthTableFieldDefinitions: TableFieldDefinition[] = [ - 'clientName', - 'scope', - 'redirectUris', -] - -export const chooseAppFn = (): ChooseFunction => createChooseFn( - { - itemName: 'app', - primaryKeyName: 'appId', - sortKeyName: 'displayName', - listTableFieldDefinitions: ['displayName', 'appType', 'appId'], - }, - command => command.client.apps.list(), -) - -export const chooseApp = chooseAppFn() - -export const buildTableOutput = (tableGenerator: TableGenerator, appSettings: AppSettingsResponse): string => { - if (!appSettings.settings || Object.keys(appSettings.settings).length === 0) { - return 'No application settings.' - } - - const table = tableGenerator.newOutputTable({ head: ['Key', 'Value'] }) - for (const key of Object.keys(appSettings.settings)) { - table.push([key, appSettings.settings[key]]) - } - return table.toString() -} - -export const verboseApps = async ( - client: SmartThingsClient, - listOptions: AppListOptions, -): Promise => { - const apps = await client.apps.list(listOptions) - return Promise.all(apps.map(app => client.apps.get(app.appId))) -} - -export const shortARNorURL = (app: PagedApp & Partial): string => { - const uri = (app.webhookSmartApp - ? app.webhookSmartApp.targetUrl - : (app.lambdaSmartApp - ? (app.lambdaSmartApp.functions?.length ? app.lambdaSmartApp.functions[0] : '') - : (app.apiOnly?.subscription?.targetUrl))) ?? '' - - return uri.length < 96 ? uri : uri.slice(0, 95) + '...' -} - -export const authorizeApp = async ( - app: AppUpdateRequest, - principal: string | undefined, - statement: string | undefined, -): Promise => { - if (!app.lambdaSmartApp) { - return fatalError('Authorization is only applicable to Lambda SmartApps.') - } - - if (app.lambdaSmartApp.functions) { - const requests = app.lambdaSmartApp.functions.map((functionArn) => { - return addPermission(functionArn, principal, statement) - }) - await Promise.all(requests) - } -} diff --git a/src/lib/command/util/smartapps-choose.ts b/src/lib/command/util/smartapps-choose.ts new file mode 100644 index 00000000..523e92b3 --- /dev/null +++ b/src/lib/command/util/smartapps-choose.ts @@ -0,0 +1,18 @@ +import { type SmartAppListOptions, type PagedSmartApp } from '@smartthings/core-sdk' + +import { type ChooseFunction, createChooseFn } from './util-util.js' + + +export const chooseSmartAppFn = ( + smartAppListOptions?: SmartAppListOptions, +): ChooseFunction => createChooseFn( + { + itemName: 'Smart App', + primaryKeyName: 'appId', + sortKeyName: 'displayName', + listTableFieldDefinitions: ['displayName', 'appType', 'appId'], + }, + command => command.client.smartapps.list(smartAppListOptions), +) + +export const chooseSmartApp = chooseSmartAppFn() diff --git a/src/lib/command/util/apps-input-primitives.ts b/src/lib/command/util/smartapps-input-primitives.ts similarity index 69% rename from src/lib/command/util/apps-input-primitives.ts rename to src/lib/command/util/smartapps-input-primitives.ts index 89074214..0db875ac 100644 --- a/src/lib/command/util/apps-input-primitives.ts +++ b/src/lib/command/util/smartapps-input-primitives.ts @@ -27,12 +27,20 @@ export const oauthAppScopeDef = checkboxDef('Scopes', availableScopes, { validate: (chosen: readonly CheckboxChoice[]) => chosen.length > 0 || 'At least one scope is required.', }) -const redirectUriHelpText = 'More information on redirect URIs can be found at:\n' + +const redirectUrisHelpText = 'More information on redirect URIs can be found at:\n' + ' https://www.oauth.com/oauth2-servers/redirect-uris/' export const redirectUrisDef = arrayDef( 'Redirect URIs', - stringDef('Redirect URI', { validate: localhostOrHTTPSValidate, helpText: redirectUriHelpText }), - { minItems: 0, maxItems: 10, helpText: redirectUriHelpText }, + stringDef('Redirect URI', { validate: localhostOrHTTPSValidate, helpText: redirectUrisHelpText }), + { minItems: 0, maxItems: 10, helpText: redirectUrisHelpText }, +) + +const corsDomainsHelpText = 'More information on CORS domains can be found at:\n' + + 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS' +export const corsDomainsDef = arrayDef( + 'CORS Domains', + stringDef('CORS Domain', { validate: localhostOrHTTPSValidate, helpText: corsDomainsHelpText }), + { minItems: 0, maxItems: 10, helpText: corsDomainsHelpText }, ) export const smartAppHelpText = 'More information on writing SmartApps can be found at\n' + diff --git a/src/lib/command/util/smartapps-table.ts b/src/lib/command/util/smartapps-table.ts new file mode 100644 index 00000000..6af912e4 --- /dev/null +++ b/src/lib/command/util/smartapps-table.ts @@ -0,0 +1,58 @@ +import { + type SmartAppOAuthRequest, + type SmartAppResponse, + type SmartAppSettingsResponse, +} from '@smartthings/core-sdk' + +import { type TableFieldDefinition, type TableGenerator } from '../../table-generator.js' + + +export const isWebhookSmartApp = (app: SmartAppResponse): boolean => !!app.webhookSmartApp +export const isAPIOnlySmartApp = (app: SmartAppResponse): boolean => !!app.apiOnly +export const hasSubscription = (app: SmartAppResponse): boolean => !!app.apiOnly?.subscription + +export const tableFieldDefinitions: TableFieldDefinition[] = [ + 'displayName', + 'appId', + 'description', + 'singleInstance', + { prop: 'classifications', include: app => !!app.classifications }, + { path: 'installMetadata.certified', include: app => !!app.installMetadata?.certified }, + { path: 'installMetadata.maxInstalls', include: app => !!app.installMetadata?.maxInstalls }, + 'appType', + { path: 'webhookSmartApp.signatureType', include: isWebhookSmartApp }, + { path: 'webhookSmartApp.targetUrl', include: isWebhookSmartApp }, + { path: 'webhookSmartApp.targetStatus', include: isWebhookSmartApp }, + { + label: 'Public Key', + include: app => !!app.webhookSmartApp?.publicKey, + value: app => app.webhookSmartApp?.publicKey?.replace(/\r\n/g, '\n') ?? '', + }, + { + label: 'Lambda Function', + include: app => !!app.lambdaSmartApp?.functions, + value: app => app.lambdaSmartApp?.functions?.join('\n') ?? '', + }, + { path: 'apiOnly.subscription.targetUrl', include: hasSubscription }, + { path: 'apiOnly.subscription.targetStatus', include: hasSubscription }, + { path: 'apiOnly.privacyPolicyUrl', include: isAPIOnlySmartApp }, +] + +export const oauthTableFieldDefinitions: TableFieldDefinition[] = [ + 'clientName', + 'scope', + 'redirectUris', + { prop: 'corsDomains', label: 'CORS Domains' }, +] + +export const buildTableOutput = (tableGenerator: TableGenerator, appSettings: SmartAppSettingsResponse): string => { + if (!appSettings.settings || Object.keys(appSettings.settings).length === 0) { + return 'No application settings.' + } + + const table = tableGenerator.newOutputTable({ head: ['Key', 'Value'] }) + for (const key of Object.keys(appSettings.settings)) { + table.push([key, appSettings.settings[key]]) + } + return table.toString() +} diff --git a/src/lib/command/util/smartapps-user-input-create.ts b/src/lib/command/util/smartapps-user-input-create.ts new file mode 100644 index 00000000..d5fe692e --- /dev/null +++ b/src/lib/command/util/smartapps-user-input-create.ts @@ -0,0 +1,82 @@ +import { v4 as uuid } from 'uuid' + +import { type SmartAppCreateRequest } from '@smartthings/core-sdk' + +import { sanitize } from '../../util.js' +import { httpsURLValidate, stringValidateFn } from '../../validate-util.js' +import { + computedDef, + createFromUserInput, + objectDef, + optionalStringDef, + staticDef, + stringDef, +} from '../../item-input/index.js' +import { corsDomainsDef, oauthAppScopeDef, redirectUrisDef, smartAppHelpText } from './smartapps-input-primitives.js' +import type { InputAndOutputItemFlags } from '../input-and-output-item.js' +import { type SmartThingsCommandFlags, type SmartThingsCommand } from '../smartthings-command.js' + + +const appNameDef = computedDef((context?: unknown[]): string => { + if (!context || context.length === 0) { + throw Error('invalid context for appName computed input definition') + } + const displayName = (context[0] as Pick).displayName + + const retVal = `${sanitize(displayName)}-${uuid()}`.toLowerCase() + // the app name has to start with a letter or number + return retVal.match(/^[a-z]/) ? retVal : 'a' + retVal +}) + +const clientNameDef = computedDef((context?: unknown[]): string => { + if (!context || context.length !== 2) { + throw Error('invalid context for clientName computed input definition') + } + return (context[1] as Pick).displayName +}) + +const oauthAppCreateRequestInputDefinition = objectDef( + 'API Access Smart App', + { + displayName: stringDef('Display Name', { validate: stringValidateFn({ maxLength: 75 }) }), + description: stringDef('Description', { validate: stringValidateFn({ maxLength: 250 }) }), + appName: appNameDef, + appType: staticDef('API_ONLY'), + classifications: staticDef(['CONNECTED_SERVICE']), + singleInstance: staticDef(true), + iconImage: objectDef( + 'Icon Image', + { url: optionalStringDef('Icon Image URL', { validate: httpsURLValidate }) }, + ), + apiOnly: objectDef( + 'API Only', + { + targetUrl: optionalStringDef('Target URL', { validate: httpsURLValidate }), + privacyPolicyUrl: optionalStringDef( + 'Privacy Policy URL (required for business accounts)', + { validate: httpsURLValidate }, + ), + }, + ), + principalType: staticDef('LOCATION'), + oauth: objectDef('OAuth', { + clientName: clientNameDef, + scope: oauthAppScopeDef, + redirectUris: redirectUrisDef, + corsDomains: corsDomainsDef, + }, { rollup: true }), + }, + { helpText: smartAppHelpText }, +) + +export const getAppCreateRequestFromUser = async ( + command: SmartThingsCommand, +): Promise => { + console.error('You are creating an API Access app.') + + return createFromUserInput( + command, + oauthAppCreateRequestInputDefinition, + { dryRun: !!command.flags.dryRun }, + ) +} diff --git a/src/lib/command/util/smartapps-user-input-update.ts b/src/lib/command/util/smartapps-user-input-update.ts new file mode 100644 index 00000000..3971a425 --- /dev/null +++ b/src/lib/command/util/smartapps-user-input-update.ts @@ -0,0 +1,69 @@ +import { type SmartAppUpdateRequest } from '@smartthings/core-sdk' + +import { httpsURLValidate } from '../../validate-util.js' +import { + type InputDefsByProperty, + objectDef, + optionalStringDef, + staticDef, + stringDef, + updateFromUserInput, +} from '../../item-input/index.js' +import { type APICommand } from '../api-command.js' +import { smartAppHelpText } from './smartapps-input-primitives.js' +import { type InputAndOutputItemFlags } from '../input-and-output-item.js' +import { fatalError } from '../../util.js' + + +export const getAppUpdateRequestFromUser = async ( + command: APICommand, + appId: string, +): Promise => { + const { + singleInstance, apiOnly, ui, iconImage, + appType, classifications, displayName, description, + } = await command.client.smartapps.get(appId) + + if (appType === 'LAMBDA_SMART_APP' || appType === 'WEBHOOK_SMART_APP') { + return fatalError('Only API_ONLY apps can be modified.') + } + + const startingRequest: SmartAppUpdateRequest = { + appType, + classifications, + displayName, + description, + singleInstance, + iconImage: iconImage ?? {}, + apiOnly: { + targetUrl: apiOnly?.subscription?.targetUrl, + privacyPolicyUrl: apiOnly?.privacyPolicyUrl, + }, + ui, + } + const propertyInputDefs: InputDefsByProperty = { + displayName: stringDef('Display Name'), + description: stringDef('Description'), + appType: staticDef(appType), + classifications: staticDef(startingRequest.classifications), + singleInstance: staticDef(startingRequest.singleInstance), + iconImage: objectDef( + 'Icon Image URL', + { url: optionalStringDef('Icon Image URL', { validate: httpsURLValidate }) }, + ), + apiOnly: objectDef( + 'API-Only', + { + targetUrl: optionalStringDef('Target URL', { validate: httpsURLValidate }), + privacyPolicyUrl: optionalStringDef( + 'Privacy Policy URL (required for business accounts)', + { validate: httpsURLValidate }, + ), + }, + ), + ui: staticDef(ui), + } + const appUpdateDef = objectDef('App Update', propertyInputDefs, { helpText: smartAppHelpText }) + + return updateFromUserInput(command, appUpdateDef, startingRequest, { dryRun: !!command.flags.dryRun }) +} diff --git a/src/lib/command/util/smartapps-util.ts b/src/lib/command/util/smartapps-util.ts new file mode 100644 index 00000000..dad10a83 --- /dev/null +++ b/src/lib/command/util/smartapps-util.ts @@ -0,0 +1,25 @@ +import type { + SmartAppListOptions, + SmartAppResponse, + PagedSmartApp, + SmartThingsClient, +} from '@smartthings/core-sdk' + + +export const verboseSmartApps = async ( + client: SmartThingsClient, + listOptions: SmartAppListOptions, +): Promise => { + const apps = await client.smartapps.list(listOptions) + return Promise.all(apps.map(app => client.smartapps.get(app.appId))) +} + +export const shortARNorURL = (app: PagedSmartApp & Partial): string => { + const uri = (app.webhookSmartApp + ? app.webhookSmartApp.targetUrl + : (app.lambdaSmartApp + ? (app.lambdaSmartApp.functions?.length ? app.lambdaSmartApp.functions[0] : '') + : (app.apiOnly?.subscription?.targetUrl))) ?? '' + + return uri.length < 96 ? uri : uri.slice(0, 95) + '...' +} diff --git a/src/lib/command/util/util-util.ts b/src/lib/command/util/util-util.ts index 7e8c5821..00c0b97a 100644 --- a/src/lib/command/util/util-util.ts +++ b/src/lib/command/util/util-util.ts @@ -1,6 +1,11 @@ import { type APICommand } from '../api-command.js' import { stringTranslateToId } from '../command-util.js' -import { type SelectFromListConfig, type SelectFromListFlags, SelectOptions, selectFromList } from '../select.js' +import { + type SelectFromListConfig, + type SelectFromListFlags, + type SelectOptions, + selectFromList, +} from '../select.js' export type ListItemPredicate = (value: T, index: number, array: T[]) => boolean @@ -20,6 +25,7 @@ export type ChooseOptions = { autoChoose?: boolean listFilter?: ListItemPredicate promptMessage?: string + notATTYMessage?: string } export const chooseOptionsDefaults = (): ChooseOptions => ({ @@ -62,12 +68,12 @@ export const createChooseFn = ( // Listing items usually makes an API call which we only want to happen once so we do it // now and just use stub functions that return these items later as needed. - let items: T[] | undefined = undefined + let filteredItems: T[] | undefined = undefined const listItemsWrapper = async (): Promise => { - if (!items) { - items = await (opts.listItems ?? listItems)(command) + if (!filteredItems) { + const items = await (opts.listItems ?? listItems)(command) + filteredItems = opts.listFilter ? items.filter(opts.listFilter) : items } - const filteredItems = opts.listFilter ? items.filter(opts.listFilter) : items return filteredItems } @@ -81,6 +87,7 @@ export const createChooseFn = ( listItems: listItemsWrapper, promptMessage: opts.promptMessage, customNotFoundMessage: createOptions?.customNotFoundMessage, + notATTYMessage: opts.notATTYMessage, } if (opts.useConfigDefault) { diff --git a/src/lib/login-authenticator.ts b/src/lib/login-authenticator.ts index 09a917ef..914029eb 100644 --- a/src/lib/login-authenticator.ts +++ b/src/lib/login-authenticator.ts @@ -165,9 +165,9 @@ export const loginAuthenticator = ( app.get('/finish', (req, res) => { if ('error' in req.query) { - logger.error('error trying to authenticate', req.query.error) + logger.error('error trying to authenticate: ', req.query.error) if ('error_description' in req.query) { - logger.error(`${req.query.error_description}`) + logger.error(`error description: ${req.query.error_description}`) } loginFailed = true @@ -197,7 +197,7 @@ export const loginAuthenticator = ( if (error.isAxiosError) { const axiosError = error as AxiosError if (axiosError.response) { - logger.error(axiosError.response.data) + logger.error('axios error:', axiosError.response.data) } } diff --git a/src/lib/table-generator.ts b/src/lib/table-generator.ts index aa4ccbf0..fb7704f9 100644 --- a/src/lib/table-generator.ts +++ b/src/lib/table-generator.ts @@ -197,7 +197,7 @@ export const defaultTableGenerator = (tableOptions: Pick text.toUpperCase()) - .replace(/\bUri\b/, 'URI') + .replace(/\bUris?\b/, match => match === 'Uri' ? 'URI' : 'URIs') .replace(/\bUrl\b/, 'URL') .replace(/\bArn\b/, 'ARN') .replace(/\bO ?[Aa]uth\b/, 'OAuth')