Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Q&A Schema App input #539

Merged
merged 1 commit into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-chicken-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smartthings/cli-lib": minor
---

Miscellaneous updates to item-input module.
5 changes: 5 additions & 0 deletions .changeset/green-fireants-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smartthings/cli": minor
---

Added support for Q&A input and updating of Schema Apps.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ We're always looking for more opinions on discussions in the issue tracker. It's
- For ambitious tasks, you should try to get your work in front of the community for feedback as soon as possible. Open a pull request as soon as you have done the minimum needed to demonstrate your idea. At this early stage, don't worry about making things perfect, or 100% complete. Describe what you still need to do and submit a [draft pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/). This lets reviewers know not to nit-pick small details or point out improvements you already know you need to make.
- Don't include unrelated changes
- New features should be accompanied with tests and documentation
- Pull requests should include only a single commit. You can use `git rebase -i main` to combine multiple commits into a single one if necessary.
- Commit messages
- Use a clear and descriptive title for the pull request and commits
- Commit messages must be formatted properly using [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). Our CI will check this and fail any PRs that are formatted incorrectly.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5465,13 +5465,14 @@ update an ST Schema connector

```
USAGE
$ smartthings schema:update [ID] [-h] [-p <value>] [-t <value>] [--language <value>] [-j] [-y] [-i <value>]
$ smartthings schema:update [ID] [-h] [-p <value>] [-t <value>] [--language <value>] [-j] [-y] [-i <value>] [-d]
[--authorize] [--principal <value>] [--statement <value>]

ARGUMENTS
ID the app id

FLAGS
-d, --dry-run produce JSON but don't actually submit
--authorize authorize Lambda functions to be called by SmartThings
--principal=<value> use this principal instead of the default when authorizing lambda functions
--statement=<value> use this statement id instead of the default when authorizing lambda functions
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/commands/schema/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('SchemaAppCreateCommand', () => {
tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'],
}),
expect.any(Function),
expect.anything(),
)
})

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/commands/schema/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('SchemaUpdateCommand', () => {

expect(inputItemMock).toBeCalledWith(
expect.any(SchemaUpdateCommand),
expect.anything(),
)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/apps/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export default class AppCreateCommand extends APICommand<typeof AppCreateCommand
name: 'action',
message: 'What kind of app do you want to create? (Currently, only OAuth-In apps are supported.)',
choices: [
{ name: 'OAuth-Inn App', value: 'oauth-in' },
{ name: 'OAuth-In App', value: 'oauth-in' },
{ name: 'Cancel', value: 'cancel' },
],
default: 'oauth-in',
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/apps/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
arrayDef,
InputDefsByProperty,
} from '@smartthings/cli-lib'
import { addPermission } from '../../lib/aws-utils'
import { addPermission, awsHelpText } from '../../lib/aws-utils'
import { chooseApp, smartAppHelpText, tableFieldDefinitions } from '../../lib/commands/apps-util'


Expand Down Expand Up @@ -82,8 +82,7 @@ export default class AppUpdateCommand extends APICommand<typeof AppUpdateCommand
}
if (appType === AppType.LAMBDA_SMART_APP) {
startingRequest.lambdaSmartApp = lambdaSmartApp
const helpText = 'More information on AWS Lambdas can be found at:\n' +
' https://docs.aws.amazon.com/lambda/latest/dg/welcome.html'
const helpText = awsHelpText
propertyInputDefs.lambdaSmartApp = objectDef('Lambda SmartApp',
{ functions: arrayDef('Lambda Functions', stringDef('Lambda Function', { helpText }), { helpText }) })
}
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/schema/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { Flags } from '@oclif/core'

import { SchemaAppRequest, SchemaCreateResponse } from '@smartthings/core-sdk'

import { APICommand, inputAndOutputItem, lambdaAuthFlags } from '@smartthings/cli-lib'
import {
APICommand,
inputAndOutputItem,
lambdaAuthFlags,
userInputProcessor,
} from '@smartthings/cli-lib'

import { addSchemaPermission } from '../../lib/aws-utils'
import { SCHEMA_AWS_PRINCIPAL } from '../../lib/commands/schema-util'
import { SCHEMA_AWS_PRINCIPAL, getSchemaAppCreateFromUser } from '../../lib/commands/schema-util'


export default class SchemaAppCreateCommand extends APICommand<typeof SchemaAppCreateCommand.flags> {
Expand Down Expand Up @@ -48,6 +53,7 @@ export default class SchemaAppCreateCommand extends APICommand<typeof SchemaAppC
}
await inputAndOutputItem(this,
{ tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'] },
createApp)
createApp, userInputProcessor(() => getSchemaAppCreateFromUser(this)),
)
}
}
15 changes: 13 additions & 2 deletions packages/cli/src/commands/schema/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { SchemaApp, SchemaAppRequest } from '@smartthings/core-sdk'

import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig } from '@smartthings/cli-lib'
import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig, userInputProcessor, inputAndOutputItem } from '@smartthings/cli-lib'

Check warning on line 5 in packages/cli/src/commands/schema/update.ts

View workflow job for this annotation

GitHub Actions / build (18, ubuntu-latest)

'inputAndOutputItem' is defined but never used

import { addSchemaPermission } from '../../lib/aws-utils'
import { getSchemaAppUpdateFromUser } from '../../lib/commands/schema-util'


export default class SchemaUpdateCommand extends APICommand<typeof SchemaUpdateCommand.flags> {
Expand All @@ -14,6 +15,11 @@
static flags = {
...APICommand.flags,
...inputItem.flags,
// eslint-disable-next-line @typescript-eslint/naming-convention
'dry-run': Flags.boolean({
char: 'd',
description: "produce JSON but don't actually submit",
}),
authorize: Flags.boolean({
description: 'authorize Lambda functions to be called by SmartThings',
}),
Expand All @@ -36,7 +42,12 @@
listItems: () => this.client.schema.list(),
})

const [request] = await inputItem<SchemaAppRequest>(this)
const getInputFromUser = async (): Promise<SchemaAppRequest> => {
const original = await this.client.schema.get(id)
return getSchemaAppUpdateFromUser(this, original, this.flags['dry-run'])
}

const [request] = await inputItem<SchemaAppRequest>(this, userInputProcessor(getInputFromUser))
if (this.flags.authorize) {
if (request.hostingType === 'lambda') {
if (request.lambdaArn) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/aws-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ export async function addPermission(arn: string, principal = '906037444270', sta
export function addSchemaPermission(arn: string, principal = '148790070172', statementId = 'smartthings'): Promise<string> {
return addPermission(arn, principal, statementId)
}

/**
* Help text for use in `InputDefinition` instances.
*/
export const awsHelpText = 'More information on AWS Lambdas can be found at:\n' +
' https://docs.aws.amazon.com/lambda/latest/dg/welcome.html'
134 changes: 134 additions & 0 deletions packages/cli/src/lib/commands/schema-util.ts
Original file line number Diff line number Diff line change
@@ -1 +1,135 @@
import { SchemaApp, SchemaAppRequest, SmartThingsURLProvider, ViperAppLinks } from '@smartthings/core-sdk'

import {
InputDefinition,
SmartThingsCommandInterface,
booleanDef,
clipToMaximum,
createFromUserInput,
emailValidate,
httpsURLValidate,
listSelectionDef,
maxItemValueLength,
objectDef,
optionalDef,
optionalStringDef,
staticDef,
stringDef,
undefinedDef,
updateFromUserInput,
} from '@smartthings/cli-lib'
import { awsHelpText } from '../aws-utils'


export const SCHEMA_AWS_PRINCIPAL = '148790070172'

const arnDef = (name: string, inChina: boolean, initialValue?: SchemaAppRequest, options?: { forChina?: boolean }): InputDefinition<string | undefined> => {
if (inChina && !options?.forChina || !inChina && options?.forChina) {
return undefinedDef
}

const helpText = awsHelpText
const initiallyActive = initialValue?.hostingType === 'lambda'

// In China there is only one ARN field so we can make it required. Globally there are three
// and at least one of the three is required, but individually all are optional.
// (See `validateFinal` function below for the validation requiring at least one.)
return optionalDef(inChina ? stringDef(name, { helpText }) : optionalStringDef(name, { helpText }),
(context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'hostingType'>)?.hostingType === 'lambda',
{ initiallyActive })
}

const webHookUrlDef = (inChina: boolean, initialValue?: SchemaAppRequest): InputDefinition<string | undefined> => {
if (inChina) {
return undefinedDef
}

const initiallyActive = initialValue?.hostingType === 'webhook'
return optionalDef(stringDef('Webhook URL'),
(context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'hostingType'>)?.hostingType === 'webhook',
{ initiallyActive })
}

// Create a type with some extra temporary fields.
type InputData = SchemaAppRequest & { includeAppLinks: boolean }

const validateFinal = (schemaAppRequest: InputData): true | string => {
if ( schemaAppRequest.hostingType === 'lambda'
&& !schemaAppRequest.lambdaArn
&& !schemaAppRequest.lambdaArnEU
&& !schemaAppRequest.lambdaArnAP
&& !schemaAppRequest.lambdaArnCN) {
return 'At least one lambda ARN is required.'
}
return true
}

const appLinksDefSummarize = (value?: ViperAppLinks): string =>
clipToMaximum(`android: ${value?.android}, ios: ${value?.ios}`, maxItemValueLength)
const appLinksDef = objectDef<ViperAppLinks>('App-to-app Links', {
android: stringDef('Android Link'),
ios: stringDef('iOS Link'),
isLinkingEnabled: staticDef(true),
}, { summarizeForEdit: appLinksDefSummarize })

const buildInputDefinition = (command: SmartThingsCommandInterface, initialValue?: SchemaAppRequest): InputDefinition<InputData> => {
// TODO: should do more type checking on this, perhaps using zod or
const baseURL = (command.profile.clientIdProvider as SmartThingsURLProvider | undefined)?.baseURL
const inChina = typeof baseURL === 'string' && baseURL.endsWith('cn')

const hostingTypeDef = inChina
? staticDef('lambda')
: listSelectionDef('Hosting Type', ['lambda', 'webhook'], { default: 'webhook' })

return objectDef<InputData>('Schema App', {
partnerName: stringDef('Partner Name'),
userEmail: stringDef('User email', { validate: emailValidate }),
appName: optionalStringDef('App Name', {
default: (context?: unknown[]) => (context?.[0] as Pick<SchemaAppRequest, 'partnerName'>)?.partnerName ?? '',
}),
oAuthAuthorizationUrl: stringDef('OAuth Authorization URL', { validate: httpsURLValidate }),
oAuthTokenUrl: stringDef('Partner OAuth Refresh Token URL', { validate: httpsURLValidate }),
icon: optionalStringDef('Icon URL', { validate: httpsURLValidate }),
icon2x: optionalStringDef('2x Icon URL', { validate: httpsURLValidate }),
icon3x: optionalStringDef('3x Icon URL', { validate: httpsURLValidate }),
oAuthClientId: stringDef('Partner OAuth Client Id'),
oAuthClientSecret: stringDef('Partner OAuth Client Secret'),
oAuthScope: optionalStringDef('Partner OAuth Scope'),
schemaType: staticDef('st-schema'),
hostingType: hostingTypeDef,
lambdaArn: arnDef('Lambda ARN for US region', inChina, initialValue),
lambdaArnEU: arnDef('Lambda ARN for EU region', inChina, initialValue),
lambdaArnCN: arnDef('Lambda ARN for CN region', inChina, initialValue, { forChina: true }),
lambdaArnAP: arnDef('Lambda ARN for AP region', inChina, initialValue),
webhookUrl: webHookUrlDef(inChina),
includeAppLinks: booleanDef('Enable app-to-app linking?', { default: false }),
viperAppLinks: optionalDef(appLinksDef,
(context?: unknown[]) => (context?.[0] as Pick<InputData, 'includeAppLinks'>)?.includeAppLinks,
{ initiallyActive: !!initialValue?.viperAppLinks }),
}, { validateFinal })
}

const stripTempInputFields = (inputData: InputData): SchemaAppRequest => {
// Strip out extra temporary data to make the `InputData` into a `SchemaAppRequest`.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { includeAppLinks, ...result } = inputData

return result
}

export const getSchemaAppUpdateFromUser = async (command: SmartThingsCommandInterface, original: SchemaApp, dryRun: boolean): Promise<SchemaAppRequest> => {
const inputDef = buildInputDefinition(command, original)

const inputData = await updateFromUserInput(command, inputDef,
{ ...original, includeAppLinks: !!original.viperAppLinks }, { dryRun })

return stripTempInputFields(inputData)
}

export const getSchemaAppCreateFromUser = async (command: SmartThingsCommandInterface): Promise<SchemaAppRequest> => {
const inputDef = buildInputDefinition(command)

const inputData = await createFromUserInput(command, inputDef, { dryRun: command.flags['dry-run'] })

return stripTempInputFields(inputData)
}
9 changes: 7 additions & 2 deletions packages/lib/src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,13 @@ export type UserInputCommand<T> = {
* always be the last one in the list since input processors are checked in order and this can
* always provide data.
*/
export function userInputProcessor<T>(command: UserInputCommand<T>): InputProcessor<T> {
return inputProcessor(() => true, () => command.getInputFromUser())
export function userInputProcessor<T>(command: UserInputCommand<T>): InputProcessor<T>
export function userInputProcessor<T>(readFn: () => Promise<T>): InputProcessor<T>
export function userInputProcessor<T>(commandOrReadFn: UserInputCommand<T> | (() => Promise<T>)): InputProcessor<T> {
if (typeof commandOrReadFn === 'function') {
return inputProcessor(() => true, commandOrReadFn)
}
return inputProcessor(() => true, () => commandOrReadFn.getInputFromUser())
}

export class CombinedInputProcessor<T> implements InputProcessor<T> {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/item-input/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export type CheckboxDefItem<T> = T extends string ? string | ComplexCheckboxDefI

export function checkboxDef<T>(name: string, items: CheckboxDefItem<T>[], options?: CheckboxDefOptions<T>): InputDefinition<T[]> {
const editValues = async (values: T[]): Promise<T[] | CancelAction> => {
// We can't add help to the inquirer `checkbox` so, at least for now, we'll just display
// We can't add help to the inquirer `checkbox` so, at least for now, we'll display
// the help before we display the checkbox.
if (options?.helpText) {
console.log(`\n${options.helpText}\n`)
Expand Down
18 changes: 16 additions & 2 deletions packages/lib/src/item-input/command-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
previewJSONAction,
previewYAMLAction,
} from './defs'
import { red } from 'chalk'


export type UpdateFromUserInputOptions = {
Expand All @@ -33,6 +34,7 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings
// TODO: this should probably be moved to someplace more common
const indent = command.flags.indent ?? command.cliConfig.profile.indent ?? (formatter === yamlFormatter ? 2 : 4)
const output = formatter(indent)(retVal)
// TODO: use `askForBoolean`
const editAgain = (await inquirer.prompt({
type: 'confirm',
name: 'editAgain',
Expand All @@ -49,11 +51,23 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings

// eslint-disable-next-line no-constant-condition
while (true) {
const validationResult = inputDefinition.validateFinal ? inputDefinition.validateFinal(retVal) : true
if (validationResult !== true) {
console.log(red(validationResult))
const answer = await inputDefinition.updateFromUserInput(retVal)
if (answer !== cancelAction) {
retVal = answer
}
continue
}
const choices: ChoiceCollection = [
editOption(inputDefinition.name),
{ name: 'Preview JSON.', value: previewJSONAction },
{ name: 'Preview YAML.', value: previewYAMLAction },
{ name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`, value: finishAction },
{
name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`,
value: finishAction,
},
{ name: `Cancel creating ${inputDefinition.name}.`, value: cancelAction },
]

Expand All @@ -62,7 +76,7 @@ export const updateFromUserInput = async <T extends object>(command: SmartThings
name: 'action',
message: 'Choose an action.',
choices,
default: finishAction,
default: validationResult === true ? finishAction : editAction,
})).action

if (action === editAction) {
Expand Down
Loading