diff --git a/README.md b/README.md index 53e6e409..27e2b20e 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ smartthings edge:channels:create --help | devices:rename [id] [new-label] | rename a device | | devices:status [id-or-index] | get the current status of all of a device's component's attributes | | devices:update [id] | update a device's label and room | +| edge | edge-specific commands | | edge:channels [id-or-index] | list all channels owned by you or retrieve a single channel | | edge:channels:assign [driver-id] [driver-version] | assign a driver to a channel | | edge:channels:create | create a channel | diff --git a/src/__tests__/commands/apps.test.ts b/src/__tests__/commands/apps.test.ts index a481b97a..5edd35d9 100644 --- a/src/__tests__/commands/apps.test.ts +++ b/src/__tests__/commands/apps.test.ts @@ -11,6 +11,7 @@ import { 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' @@ -22,7 +23,12 @@ import { apiCommandMocks } from '../test-lib/api-command-mock.js' import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js' -const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../..') +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks('../..') const outputItemOrListMock = jest.fn>() const outputItemOrListBuilderMock = jest.fn() @@ -69,7 +75,7 @@ describe('builder', () => { expect(positionalMock).toHaveBeenCalledTimes(1) expect(optionMock).toHaveBeenCalledTimes(3) expect(exampleMock).toHaveBeenCalledTimes(1) - expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) }) diff --git a/src/__tests__/commands/edge/channels.test.ts b/src/__tests__/commands/edge/channels.test.ts index 5efc007a..239a1c00 100644 --- a/src/__tests__/commands/edge/channels.test.ts +++ b/src/__tests__/commands/edge/channels.test.ts @@ -6,6 +6,7 @@ import type { Channel, ChannelsEndpoint } from '@smartthings/core-sdk' import type { CommandArgs } from '../../../commands/edge/channels.js' import type { WithOrganization } from '../../../lib/api-helpers.js' +import type { buildEpilog } from '../../../lib/help.js' import type { APIOrganizationCommand, APIOrganizationCommandFlags, @@ -18,11 +19,13 @@ import type { } from '../../../lib/command/common-flags.js' import type { outputItemOrList, outputItemOrListBuilder } from '../../../lib/command/listing-io.js' import { listChannels } from '../../../lib/command/util/edge/channels.js' -import { apiCommandMocks } from '../../test-lib/api-command-mock.js' import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' -const { apiDocsURLMock } = apiCommandMocks('../../..') +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) const apiOrganizationCommandMock = jest.fn() const apiOrganizationCommandBuilderMock = jest.fn() @@ -83,7 +86,7 @@ describe('builder', () => { expect(optionMock).toHaveBeenCalledTimes(3) expect(positionalMock).toHaveBeenCalledTimes(2) expect(exampleMock).toHaveBeenCalledTimes(1) - expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) }) diff --git a/src/__tests__/commands/locations.test.ts b/src/__tests__/commands/locations.test.ts index ddf0fafb..270db50e 100644 --- a/src/__tests__/commands/locations.test.ts +++ b/src/__tests__/commands/locations.test.ts @@ -4,6 +4,7 @@ import type { ArgumentsCamelCase, Argv } from 'yargs' import type { Location, LocationsEndpoint, 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/locations.js' @@ -11,7 +12,12 @@ import { apiCommandMocks } from '../test-lib/api-command-mock.js' import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js' -const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../..') +const buildEpilogMock = jest.fn() +jest.unstable_mockModule('../../lib/help.js', () => ({ + buildEpilog: buildEpilogMock, +})) + +const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks('../..') const outputItemOrListMock = jest.fn() const outputItemOrListBuilderMock = jest.fn() @@ -47,7 +53,7 @@ test('builder', () => { expect(positionalMock).toHaveBeenCalledTimes(1) expect(exampleMock).toHaveBeenCalledTimes(1) - expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) }) diff --git a/src/__tests__/lib/command-util.test.ts b/src/__tests__/lib/command-util.test.ts new file mode 100644 index 00000000..095b6dec --- /dev/null +++ b/src/__tests__/lib/command-util.test.ts @@ -0,0 +1,42 @@ +import { jest } from '@jest/globals' + +import { type CommandModule } from 'yargs' + + +// Single consolidated mock command set covering all test scenarios +const noop = (): void => { /* unused */ } +const devicesStatusCommand = { command: 'devices:status', describe: 'device status', handler: noop } +const devicesUpdateCommand = { command: 'devices:update', describe: 'update device', handler: noop } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockCommands: CommandModule[] = [ + { command: 'other:thing', describe: 'other thing', handler: noop }, + { command: 'unrelated', describe: 'unrelated', handler: noop }, + devicesStatusCommand, + { command: 'devices:history:list', describe: 'device history list', handler: noop }, + devicesUpdateCommand, + { command: 'devices:history:list:detail', describe: 'history detail', handler: noop }, + { describe: 'a command without a command', handler: noop }, + { command: ['aliased', 'alias'], describe: 'a command with more than one name', handler: noop }, +] +jest.unstable_mockModule('../../commands/index.js', () => ({ + commands: mockCommands, +})) + +const { findTopicsAndSubcommands } = await import('../../lib/command-util.js') + +describe('findTopicsAndSubcommands', () => { + it('returns no topics or sub-commands for a leaf command', () => { + expect(findTopicsAndSubcommands('devices:update')).toStrictEqual({ topics: [], subCommands: [] }) + }) + + it('returns direct, and only direct, sub-commands and topics for devices', () => { + const result = findTopicsAndSubcommands('devices') + expect(result).toStrictEqual({ + topics: ['devices::history'], + subCommands: [ + { relatedName: 'devices:status', command: devicesStatusCommand }, + { relatedName: 'devices:update', command: devicesUpdateCommand }, + ], + }) + }) +}) diff --git a/src/__tests__/lib/command/api-command.test.ts b/src/__tests__/lib/command/api-command.test.ts index 56aa2887..47c632bc 100644 --- a/src/__tests__/lib/command/api-command.test.ts +++ b/src/__tests__/lib/command/api-command.test.ts @@ -100,13 +100,13 @@ const { describe('apiDocsURL', () => { it('produces URL', () => { expect(apiDocsURL('getDevice')) - .toBe('For API information, see:\n\n' + + .toBe('For API information, see:\n' + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice') }) it('joins multiple pages with line breaks', () => { expect(apiDocsURL('getDevice', 'getDevices')) - .toBe('For API information, see:\n\n' + + .toBe('For API information, see:\n' + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' + ' https://developer.smartthings.com/docs/api/public/#operation/getDevices') }) diff --git a/src/__tests__/lib/help.test.ts b/src/__tests__/lib/help.test.ts new file mode 100644 index 00000000..fc47cf61 --- /dev/null +++ b/src/__tests__/lib/help.test.ts @@ -0,0 +1,149 @@ +import { jest } from '@jest/globals' + +import type { BorderConfig, getBorderCharacters, table } from 'table' +import type { findTopicsAndSubcommands } from '../../lib/command-util.js' + + +const borderConfig = { topLeft: 'top-left' } as BorderConfig +const getBorderCharactersMock = jest.fn() + .mockReturnValue(borderConfig) +const tableMock = jest.fn() +jest.unstable_mockModule('table', () => ({ + getBorderCharacters: getBorderCharactersMock, + table: tableMock, +})) + +const findTopicsAndSubcommandsMock = jest.fn() + .mockReturnValue({ topics: [], subCommands: [] }) +jest.unstable_mockModule('../../lib/command-util.js', () => ({ + findTopicsAndSubcommands: findTopicsAndSubcommandsMock, +})) + + +const { buildEpilog, apiDocsURL, itemInputHelpText } = await import('../../lib/help.js') + + +describe('apiDocsURL', () => { + it('builds URL stanza for single api name', () => { + const result = apiDocsURL('getDevice') + expect(result).toBe('For API information, see:\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice') + }) + + it('builds URL stanza for multiple api names', () => { + const result = apiDocsURL(['getDevice', 'listDevices']) + expect(result).toBe( + 'For API information, see:\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/listDevices', + ) + }) + + it('passes through existing URLs', () => { + const result = apiDocsURL(['http://example.com/doc', 'https://example.com/ssl-doc', 'getDevice']) + expect(result).toBe( + 'For API information, see:\n' + + ' http://example.com/doc\n' + + ' https://example.com/ssl-doc\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice', + ) + }) +}) + +describe('itemInputHelpText', () => { + it('builds help text for a single name', () => { + const result = itemInputHelpText('getDevice') + expect(result).toBe('More information can be found at:\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice') + }) + + it('builds help text for multiple names and URLs', () => { + const result = itemInputHelpText('getDevice', 'http://example.com/doc') + expect(result).toBe( + 'More information can be found at:\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' + + ' http://example.com/doc', + ) + }) +}) + +describe('buildEpilog', () => { + it('returns empty string when no options provided', () => { + expect(buildEpilog({ command: 'test' })).toBe('') + }) + + it('includes note from note provided via `notes`', () => { + const epilog = buildEpilog({ command: 'test', notes: 'Single note' }) + expect(epilog).toBe('Notes:\n Single note') + }) + + it('includes note from formattedNotes', () => { + expect(buildEpilog({ command: 'test', formattedNotes: 'formatted note' })).toBe('Notes:\nformatted note') + }) + + it('includes all notes from multiple notes provided via `notes`', () => { + expect(buildEpilog({ command: 'test', notes: ['First note', 'Second note', 'Third note'] })) + .toBe('Notes:\n First note\n Second note\n Third note') + }) + + it('includes notes from both notes and formattedNotes', () => { + expect(buildEpilog({ command: 'test', notes: ['note 1', 'note 2'], formattedNotes: 'formatted notes' })) + .toBe('Notes:\n note 1\n note 2\nformatted notes') + }) + + it('includes topics section when topics found', () => { + findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['test::topic'], subCommands: [] }) + expect(buildEpilog({ command: 'test' })).toBe('Topics:\n test::topic') + + findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['topic1', 'topic2'], subCommands: [] }) + expect(buildEpilog({ command: 'test' })).toBe('Topics:\n topic1\n topic2') + }) + + it('includes apiDocs section when apiDocs provided', () => { + expect(buildEpilog({ command: 'devices', apiDocs: ['getDevice', 'listDevices'] })) + .toBe( + 'For API information, see:\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' + + ' https://developer.smartthings.com/docs/api/public/#operation/listDevices', + ) + }) + + it('includes sub-commands section when sub-commands found', () => { + tableMock.mockReturnValueOnce('sub-command table output') + const subCommands = [ + { + relatedName: 'test:sub1', + command: { + describe: 'sub 1 description', + handler: () => { /* noop */ }, + }, + }, + { + relatedName: 'test:sub2', + command: { + describe: 'sub 2 description', + handler: () => { /* noop */ }, + }, + }, + ] + findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: [], subCommands }) + + expect(buildEpilog({ command: 'test' })).toBe(('Sub-Commands:\nsub-command table output')) + + expect(getBorderCharactersMock).toHaveBeenCalledExactlyOnceWith('void') + expect(tableMock).toHaveBeenCalledExactlyOnceWith([ + [' test:sub1', 'sub 1 description' ], + [' test:sub2', 'sub 2 description' ], + ], expect.objectContaining({ border: borderConfig })) + + // Call this trivial function to fulfill test coverage. :-) + expect(tableMock.mock.calls[0][1]?.drawHorizontalLine?.(0, 0)).toBe(false) + }) + + it('joins sections correctly when multiple present', () => { + findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['test::topic'], subCommands: [] }) + + expect(buildEpilog({ command: 'test', formattedNotes: 'formatted note' })) + .toBe('Notes:\nformatted note\n\nTopics:\n test::topic') + }) +}) diff --git a/src/commands/apps.ts b/src/commands/apps.ts index a00073dd..579ac6ba 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -8,12 +8,12 @@ import { 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, - apiDocsURL, } from '../lib/command/api-command.js' import { type OutputItemOrListConfig, @@ -61,7 +61,7 @@ const builder = (yargs: Argv): Argv => ['$0 apps --classification SERVICE', 'list SERVICE classification apps'], ['$0 apps --type API_ONLY', 'list API-only apps'], ]) - .epilog(apiDocsURL('listApps', 'getApp')) + .epilog(buildEpilog({ command, apiDocs: ['listApps', 'getApp'] })) const handler = async (argv: ArgumentsCamelCase): Promise => { const command = await apiCommand(argv) diff --git a/src/commands/deviceprofiles/view/update.ts b/src/commands/deviceprofiles/view/update.ts index 0dea3a3c..dcc20669 100644 --- a/src/commands/deviceprofiles/view/update.ts +++ b/src/commands/deviceprofiles/view/update.ts @@ -1,6 +1,6 @@ import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' -import { apiDocsURL } from '../../../lib/command/api-command.js' +import { buildEpilog } from '../../../lib/help.js' import { apiOrganizationCommand, apiOrganizationCommandBuilder, @@ -47,32 +47,34 @@ const builder = (yargs: Argv): Argv => 'update the specified device profile and its device configuration as defined in test.json', ], ]) - .epilog( - 'Updates a device profile and device configuration and sets the vid of the profile ' + - 'to the vid of the updated configuration. Unlike deviceprofiles:update this ' + - 'command accepts a consolidated object that can include a device configuration ' + - 'in a property named "view".\n\n' + - 'This sample file adds the powerMeter capability to the device and makes it available in' + - 'the device detail view but not the rule builder:\n\n' + - 'components:\n' + - ' - id: main\n' + - ' capabilities:\n' + - ' - id: switch\n' + - ' - id: powerMeter\n' + - 'view:\n' + - ' dashboard:\n' + - ' states:\n' + - ' - capability: switch\n' + - ' actions:\n' + - ' - capability: switch\n' + - ' detailView:\n' + - ' - capability: switch\n' + - ' - capability: powerMeter\n' + - ' automation:\n' + - ' conditions:\n' + - ' - capability: switch \n\n' + - apiDocsURL('createDeviceConfiguration', 'updateDeviceProfile', 'generateDeviceConfig'), - ) + .epilog(buildEpilog({ + command, + formattedNotes: + ' Updates a device profile and device configuration and sets the vid of the profile ' + + 'to the vid of the updated configuration. Unlike deviceprofiles:update this ' + + 'command accepts a consolidated object that can include a device configuration ' + + 'in a property named "view".\n\n' + + ' This sample file adds the powerMeter capability to the device and makes it available in' + + 'the device detail view but not the rule builder:\n\n' + + ' components:\n' + + ' - id: main\n' + + ' capabilities:\n' + + ' - id: switch\n' + + ' - id: powerMeter\n' + + ' view:\n' + + ' dashboard:\n' + + ' states:\n' + + ' - capability: switch\n' + + ' actions:\n' + + ' - capability: switch\n' + + ' detailView:\n' + + ' - capability: switch\n' + + ' - capability: powerMeter\n' + + ' automation:\n' + + ' conditions:\n' + + ' - capability: switch', + apiDocs: ['createDeviceConfiguration', 'updateDeviceProfile', 'generateDeviceConfig'], + })) const handler = async (argv: ArgumentsCamelCase): Promise => { const command = await apiOrganizationCommand(argv) diff --git a/src/commands/edge.ts b/src/commands/edge.ts new file mode 100644 index 00000000..159f254d --- /dev/null +++ b/src/commands/edge.ts @@ -0,0 +1,19 @@ +import { type Argv, type CommandModule } from 'yargs' + + +import { buildEpilog } from '../lib/help.js' + + +const command = 'edge' + +const describe = 'edge-specific commands' + +const builder = (yargs: Argv): Argv => + yargs.epilog(buildEpilog({ command })) + +const handler = (): void => { + // Handler is required by yargs but we leave it empty because `edge` is only a topic. +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/edge/channels.ts b/src/commands/edge/channels.ts index 10d7dd9c..6ada2e87 100644 --- a/src/commands/edge/channels.ts +++ b/src/commands/edge/channels.ts @@ -1,7 +1,9 @@ import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' -import { Channel, SubscriberType } from '@smartthings/core-sdk' +import { type Channel, type SubscriberType } from '@smartthings/core-sdk' +import { type WithOrganization } from '../../lib/api-helpers.js' +import { buildEpilog } from '../../lib/help.js' import { apiOrganizationCommand, apiOrganizationCommandBuilder, @@ -17,8 +19,6 @@ import { type OutputItemOrListConfig, type OutputItemOrListFlags, } from '../../lib/command/listing-io.js' -import { apiDocsURL } from '../../lib/command/api-command.js' -import { WithOrganization } from '../../lib/api-helpers.js' import { listTableFieldDefinitions, tableFieldDefinitions } from '../../lib/command/util/edge/channels-table.js' import { listChannels } from '../../lib/command/util/edge/channels.js' @@ -79,13 +79,15 @@ const builder = (yargs: Argv): Argv => 'display channels subscribed to by the specified hub', ], ]) - .epilog( - 'Use this command to list all drivers you own, even if they are not yet assigned to' + - ' a channel.\n\n' + - 'See also drivers:installed to list installed drivers and channels:drivers to list' + - ' drivers that are part of a channel you own or have subscribed to.\n\n' + - apiDocsURL('listChannels', 'channelById'), - ) + .epilog(buildEpilog({ + command, + notes: [ + 'Use this command to list all drivers you own, even if they are not yet assigned to a channel.', + 'See also drivers:installed to list installed drivers and channels:drivers to list' + + ' drivers that are part of a channel you own or have subscribed to.', + ], + apiDocs: ['listChannels', 'channelById'], + })) const handler = async (argv: ArgumentsCamelCase): Promise => { const command = await apiOrganizationCommand(argv) diff --git a/src/commands/edge/channels/invites/create.ts b/src/commands/edge/channels/invites/create.ts index 7a960fd3..a2796d5c 100644 --- a/src/commands/edge/channels/invites/create.ts +++ b/src/commands/edge/channels/invites/create.ts @@ -13,6 +13,7 @@ import { userInputProcessor } from '../../../../lib/command/input-processor.js' import { chooseChannel } from '../../../../lib/command/util/edge/channels-choose.js' import { type Invitation, type InvitationCreate } from '../../../../lib/edge/endpoints/invites.js' import { urlValidate } from '../../../../lib/validate-util.js' +import { buildEpilog } from '../../../../lib/help.js' export type CommandArgs = @@ -41,6 +42,7 @@ const builder = (yargs: Argv): Argv => 'create an invite from prompted input for the specified channel', ], ]) + .epilog(buildEpilog({ command })) const handler = async (argv: ArgumentsCamelCase): Promise => { const command = edgeCommand(await apiCommand(argv)) diff --git a/src/commands/index.ts b/src/commands/index.ts index 69bb6930..d64bc593 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -56,6 +56,7 @@ import devicesPresentationCommand from './devices/presentation.js' import devicesRenameCommand from './devices/rename.js' import devicesStatusCommand from './devices/status.js' import devicesUpdateCommand from './devices/update.js' +import edgeCommand from './edge.js' import edgeChannelsCommand from './edge/channels.js' import edgeChannelsAssignCommand from './edge/channels/assign.js' import edgeChannelsCreateCommand from './edge/channels/create.js' @@ -191,6 +192,7 @@ export const commands: CommandModule[] = [ devicesRenameCommand, devicesStatusCommand, devicesUpdateCommand, + edgeCommand, edgeChannelsCommand, edgeChannelsAssignCommand, edgeChannelsCreateCommand, diff --git a/src/commands/locations.ts b/src/commands/locations.ts index d849e4b8..77d3cff2 100644 --- a/src/commands/locations.ts +++ b/src/commands/locations.ts @@ -2,7 +2,7 @@ import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' import { type Location, type LocationItem } from '@smartthings/core-sdk' -import { type APICommandFlags, apiCommand, apiCommandBuilder, apiDocsURL } from '../lib/command/api-command.js' +import { type APICommandFlags, apiCommand, apiCommandBuilder } from '../lib/command/api-command.js' import { type OutputItemOrListConfig, type OutputItemOrListFlags, @@ -10,6 +10,7 @@ import { outputItemOrListBuilder, } from '../lib/command/listing-io.js' import { tableFieldDefinitions } from '../lib/command/util/locations-util.js' +import { buildEpilog } from '../lib/help.js' export type CommandArgs = APICommandFlags & OutputItemOrListFlags & { @@ -28,7 +29,7 @@ const builder = (yargs: Argv): Argv => ['$0 locations 1', 'display details for the first location in the list retrieved by running "smartthings locations"'], ['$0 locations 5dfd6626-ab1d-42da-bb76-90def3153998', 'display details for a location by id'], ]) - .epilog(apiDocsURL('listLocations', 'getLocation')) + .epilog(buildEpilog({ command, apiDocs: ['listLocations', 'getLocation'] })) const handler = async (argv: ArgumentsCamelCase): Promise => { const command = await apiCommand(argv) diff --git a/src/lib/command-util.ts b/src/lib/command-util.ts new file mode 100644 index 00000000..ec9b76a1 --- /dev/null +++ b/src/lib/command-util.ts @@ -0,0 +1,48 @@ +import { type CommandModule } from 'yargs' + +import { commands } from '../commands/index.js' + + +export type SubCommand = { + command: CommandModule + relatedName: string +} +export type CommandStructure = { + topics: string[] + subCommands: SubCommand[] +} +export const findTopicsAndSubcommands = (commandName: string): CommandStructure => { + const topicName = `${commandName}:` + // Topics are commands that also have sub-commands. + const topics = new Set() + const subCommands: SubCommand[] = [] + const related = (other: CommandModule): false | string => { + if (!other.command) { + return false + } + + if (typeof other.command === 'string' && other.command.startsWith(topicName)) { + return other.command + } + if (typeof other.command === 'object') { + const match = other.command.find(name => name.startsWith(topicName)) + return match ?? false + } + + return false + } + for (const other of commands) { + const relatedCommandCommand = related(other) + if (relatedCommandCommand) { + const relatedCommandName = relatedCommandCommand.split(' ')[0] + const subPart = relatedCommandName.slice(topicName.length) + if (subPart.indexOf(':') !== -1) { + // A sub-command of a command. Grab the first part for a topic. + topics.add(`${topicName}:${subPart.split(':')[0]}`) + } else { + subCommands.push({ command: other, relatedName: relatedCommandName }) + } + } + } + return { topics: (topics.size ? [...topics] : []).sort(), subCommands } +} diff --git a/src/lib/command/api-command.ts b/src/lib/command/api-command.ts index 607d7b53..5e4f431f 100644 --- a/src/lib/command/api-command.ts +++ b/src/lib/command/api-command.ts @@ -12,15 +12,20 @@ import { newBearerTokenAuthenticator, newSmartThingsClient } from './util/st-cli export const userAgent = '@smartthings/cli' +// TODO: BEGIN remove +// In the second phase of this work, we will remove these helper functions in favor of those +// in help.ts. const toURL = (nameOrURL: string): string => nameOrURL.startsWith('http') ? nameOrURL : `https://developer.smartthings.com/docs/api/public/#operation/${nameOrURL}` -export const apiDocsURL = (...names: string[]): string => 'For API information, see:\n\n ' + +export const apiDocsURL = (...names: string[]): string => 'For API information, see:\n ' + names.map(name => toURL(name)).join('\n ') + export const itemInputHelpText = (...namesOrURLs: string[]): string => 'More information can be found at:\n ' + namesOrURLs.map(nameOrURL => toURL(nameOrURL)).join('\n ') +// TODO: END REMOVE export type APICommandFlags = SmartThingsCommandFlags & { token?: string diff --git a/src/lib/help.ts b/src/lib/help.ts new file mode 100644 index 00000000..a9ca123d --- /dev/null +++ b/src/lib/help.ts @@ -0,0 +1,53 @@ +import { getBorderCharacters, table } from 'table' + +import { findTopicsAndSubcommands } from './command-util.js' + + +const toURL = (nameOrURL: string): string => nameOrURL.startsWith('http') + ? nameOrURL + : `https://developer.smartthings.com/docs/api/public/#operation/${nameOrURL}` + +export const apiDocsURL = (names: string | string[]): string => 'For API information, see:\n ' + + (typeof names === 'string' ? [names] : names).map(name => toURL(name)).join('\n ') + +export const itemInputHelpText = (...namesOrURLs: string[]): string => + 'More information can be found at:\n ' + namesOrURLs.map(nameOrURL => toURL(nameOrURL)).join('\n ') + +export type BuildEpilogOptions = { + command: string + apiDocs?: string | string[] + notes?: string | string[] + formattedNotes?: string +} +export const buildEpilog = (options: BuildEpilogOptions): string => { + const commandName = options.command.split(' ')[0] + const { topics, subCommands } = findTopicsAndSubcommands(commandName) + + const parts: string[] = [] + if (options.notes || options.formattedNotes) { + const notesArray = (typeof options.notes === 'string' ? [options.notes] : [...(options.notes ?? [])]) + .map(note => ` ${note}`) + if (options.formattedNotes) { + notesArray.push(options.formattedNotes) + } + parts.push('Notes:\n' + notesArray.join('\n')) + } + if (options.apiDocs) { + parts.push(apiDocsURL(options.apiDocs)) + } + if (topics.length) { + parts.push('Topics:\n' + topics.map(topic => ` ${topic}`).join('\n')) + } + if (subCommands.length) { + const data = subCommands.map(subCommand => [` ${subCommand.relatedName}`, subCommand.command.describe]) + parts.push('Sub-Commands:\n' + table(data, { + border: getBorderCharacters('void'), + columnDefault: { + paddingLeft: 0, + paddingRight: 1, + }, + drawHorizontalLine: () => false, + })) + } + return parts.join('\n\n') +}