diff --git a/docs/EXTENSION.md b/docs/EXTENSION.md index 4254abe7..1f4b8707 100644 --- a/docs/EXTENSION.md +++ b/docs/EXTENSION.md @@ -14,6 +14,7 @@ Return to [README.md](../README.md) for information on other command categories. - [Commands](#commands) - [export](#export) - [import](#import) + - [delete](#delete) @@ -43,10 +44,10 @@ dc-cli extension export #### Options -| Option Name | Type | Description | -| --------------- | --------- | ------------------------------------------------------------ | +| Option Name | Type | Description | +| --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --id | [string] | The ID of an Extension to be exported.
If no --id option is given, all extensions for the hub are exported.
A single --id option may be given to export a single extension.
Multiple --id options may be given to export multiple extensions at the same time. | -| -f
--force | [boolean] | Overwrite extensions without asking. | +| -f
--force | [boolean] | Overwrite extensions without asking. | #### Examples @@ -76,3 +77,35 @@ The import command only uses [common options](#Common Options) `dc-cli extension import ./myDirectory/extension` +### delete + +Deletes extensions from the targeted Dynamic Content hub. + +``` +dc-cli extension delete +``` + +#### Options + +| Option Name | Type | Description | +| --------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| --id | [string] | The ID of an Extension to be deleted.
If no --id option is given, all extensions for the hub are deleted.
A single --id option may be given to delete a single extension.
Multiple --id options may be given to delete multiple extensions at the same time. | +| -f
--force | [boolean] | Delete extensions without asking. | + +#### Examples + +##### Delete all extensions from a Hub + +`dc-cli extension delete` + +##### Delete a single extension with the ID of 'foo' + +`dc-cli extension delete foo` + +or + +`dc-cli extension delete --id foo` + +##### Delete multiple extensions with the IDs of 'foo' & 'bar' + +`dc-cli extension delete --id foo --id bar` diff --git a/src/commands/extension/delete.spec.ts b/src/commands/extension/delete.spec.ts new file mode 100644 index 00000000..a620e9c1 --- /dev/null +++ b/src/commands/extension/delete.spec.ts @@ -0,0 +1,148 @@ +import * as deleteModule from './delete'; +import Yargs from 'yargs/yargs'; +import { builder, coerceLog, LOG_FILENAME, command, handler } from './delete'; +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Extension } from 'dc-management-sdk-js'; +import MockPage from '../../common/dc-management-sdk-js/mock-page'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { FileLog } from '../../common/file-log'; +import { filterExtensionsById } from '../../common/extension/extension-helpers'; +import * as questionHelpers from '../../common/question-helpers'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../common/log-helpers'); +jest.mock('../../common/question-helpers'); + +describe('delete extensions', () => { + it('should implement an export command', () => { + expect(command).toEqual('delete [id]'); + }); + + describe('builder tests', () => { + it('should configure yargs', () => { + const argv = Yargs(process.argv.slice(2)); + const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + const spyOption = jest.spyOn(argv, 'option').mockReturnThis(); + + builder(argv); + + expect(spyPositional).toHaveBeenCalledWith('id', { + describe: + 'The ID of a the extension to be deleted. If id is not provided, this command will delete ALL extensions in the hub.', + type: 'string' + }); + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'If present, there will be no confirmation prompt before deleting the found extensions.' + }); + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: coerceLog + }); + }); + }); + + describe('handler tests', () => { + const yargArgs = { + $0: 'test', + _: ['test'] + }; + + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + const extensionsToDelete: Extension[] = [ + new Extension({ + id: 'extension-id-1', + name: 'extension-name-1', + label: 'extension-label-1', + status: 'ACTIVE' + }), + new Extension({ + id: 'extension-id-2', + name: 'extension-name-2', + label: 'extension-label-2', + status: 'ACTIVE' + }) + ]; + + let mockGetHub: jest.Mock; + let mockList: jest.Mock; + + const extensionIdsToDelete = (id: unknown) => (id ? (Array.isArray(id) ? id : [id]) : []); + + beforeEach((): void => { + const listResponse = new MockPage(Extension, extensionsToDelete); + mockList = jest.fn().mockResolvedValue(listResponse); + + mockGetHub = jest.fn().mockResolvedValue({ + related: { + extensions: { + list: mockList + } + } + }); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + hubs: { + get: mockGetHub + } + }); + + jest.spyOn(deleteModule, 'processExtensions').mockResolvedValue(); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function () { + LOG_FILENAME(); + expect(getDefaultLogPath).toHaveBeenCalledWith('extension', 'delete', process.platform); + }); + + it('should delete all extensions in a hub', async (): Promise => { + const id: string[] | undefined = undefined; + const argv = { ...yargArgs, ...config, id, logFile: new FileLog() }; + + const filteredExtensionsToDelete = filterExtensionsById(extensionsToDelete, extensionIdsToDelete(id)); + + jest.spyOn(deleteModule, 'handler'); + + (questionHelpers.asyncQuestion as jest.Mock).mockResolvedValue(true); + + await handler(argv); + + expect(mockGetHub).toHaveBeenCalledWith('hub-id'); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith({ size: 100 }); + + expect(deleteModule.processExtensions).toHaveBeenCalledWith(filteredExtensionsToDelete, argv.logFile); + }); + + it('should delete an extension by id', async (): Promise => { + const id: string[] | undefined = ['extension-id-2']; + const argv = { + ...yargArgs, + ...config, + id, + logFile: new FileLog() + }; + + const filteredExtensionsToDelete = filterExtensionsById(extensionsToDelete, extensionIdsToDelete(id)); + + jest.spyOn(deleteModule, 'handler'); + + (questionHelpers.asyncQuestion as jest.Mock).mockResolvedValue(true); + + await handler(argv); + + expect(mockGetHub).toHaveBeenCalledWith('hub-id'); + expect(mockList).toHaveBeenCalledTimes(1); + + expect(deleteModule.processExtensions).toHaveBeenCalledWith(filteredExtensionsToDelete, argv.logFile); + }); + }); +}); diff --git a/src/commands/extension/delete.ts b/src/commands/extension/delete.ts new file mode 100644 index 00000000..b3c329cc --- /dev/null +++ b/src/commands/extension/delete.ts @@ -0,0 +1,112 @@ +import { Arguments, Argv } from 'yargs'; +import { FileLog } from '../../common/file-log'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { ConfigurationParameters } from '../configure'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import paginator from '../../common/dc-management-sdk-js/paginator'; +import { nothingExportedExit as nothingToDeleteExit } from '../../services/export.service'; +import { Extension } from 'dc-management-sdk-js'; +import { asyncQuestion } from '../../common/question-helpers'; +import { progressBar } from '../../common/progress-bar/progress-bar'; +import { filterExtensionsById } from '../../common/extension/extension-helpers'; +import { DeleteExtensionBuilderOptions } from '../../interfaces/delete-extension-builder-options'; + +export const command = 'delete [id]'; + +export const desc = 'Delete Extensions'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('extension', 'delete', platform); + +export const coerceLog = (logFile: string): FileLog => createLog(logFile, 'Extensions Delete Log'); + +export const builder = (yargs: Argv): void => { + yargs + .positional('id', { + type: 'string', + describe: + 'The ID of a the extension to be deleted. If id is not provided, this command will delete ALL extensions in the hub.' + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'If present, there will be no confirmation prompt before deleting the found extensions.' + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: coerceLog + }); +}; + +export const processExtensions = async (extensionsToDelete: Extension[], log: FileLog): Promise => { + const failedExtensions: Extension[] = []; + + const progress = progressBar(extensionsToDelete.length, 0, { + title: `Deleting ${extensionsToDelete.length} extensions.` + }); + + for (const [i, extension] of extensionsToDelete.entries()) { + try { + await extension.related.delete(); + log.addComment(`Successfully deleted "${extension.label}"`); + progress.increment(); + } catch (e) { + failedExtensions.push(extension); + extensionsToDelete.splice(i, 1); + log.addComment(`Failed to delete ${extension.label}: ${e.toString()}`); + progress.increment(); + } + } + + progress.stop(); + + if (failedExtensions.length > 0) { + log.appendLine(`Failed to delete ${failedExtensions.length} extensions`); + } +}; + +export const handler = async ( + argv: Arguments +): Promise => { + const { id, logFile, force } = argv; + + const client = dynamicContentClientFactory(argv); + + const allExtensions = !id; + + const hub = await client.hubs.get(argv.hubId); + + const storedExtensions = await paginator(hub.related.extensions.list); + + const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; + const extensionsToDelete = filterExtensionsById(storedExtensions, idArray, true); + + const log = logFile.open(); + + if (extensionsToDelete.length === 0) { + nothingToDeleteExit(log, 'No extensions to delete from this hub, exiting.'); + return; + } + + if (!force) { + const yes = await asyncQuestion( + allExtensions + ? `Providing no ID/s will delete ALL extensions! Are you sure you want to do this? (Y/n)\n` + : `${extensionsToDelete.length} extensions will be deleted. Would you like to continue? (Y/n)\n` + ); + if (!yes) { + return; + } + } + + log.addComment(`Deleting ${extensionsToDelete.length} extensions.`); + + await processExtensions(extensionsToDelete, log); + + log.appendLine(`Finished successfully deleting ${extensionsToDelete.length} extensions`); + + await log.close(); +}; diff --git a/src/commands/extension/export.spec.ts b/src/commands/extension/export.spec.ts index d838f729..678eee22 100644 --- a/src/commands/extension/export.spec.ts +++ b/src/commands/extension/export.spec.ts @@ -1,9 +1,9 @@ import * as exportModule from './export'; import * as directoryUtils from '../../common/import/directory-utils'; +import * as extensionHelpers from '../../common/extension/extension-helpers'; import { builder, command, - filterExtensionsById, getExtensionExports, getExportRecordForExtension, handler, @@ -22,6 +22,7 @@ import { FileLog } from '../../common/file-log'; import { streamTableOptions } from '../../common/table/table.consts'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { validateNoDuplicateExtensionNames } from './import'; +import { filterExtensionsById } from '../../common/extension/extension-helpers'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('./import'); @@ -661,7 +662,7 @@ describe('extension export command', (): void => { const argv = { ...yargArgs, ...config, dir: 'my-dir', extensionId: extensionIdsToExport, logFile: new FileLog() }; const filteredExtensionsToExport = [...extensionsToExport]; - jest.spyOn(exportModule, 'filterExtensionsById').mockReturnValue(filteredExtensionsToExport); + jest.spyOn(extensionHelpers, 'filterExtensionsById').mockReturnValue(filteredExtensionsToExport); await handler(argv); @@ -670,7 +671,7 @@ describe('extension export command', (): void => { expect(mockList).toHaveBeenCalledWith({ size: 100 }); expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, Extension); expect(validateNoDuplicateExtensionNames).toHaveBeenCalled(); - expect(exportModule.filterExtensionsById).toHaveBeenCalledWith(extensionsToExport, []); + expect(extensionHelpers.filterExtensionsById).toHaveBeenCalledWith(extensionsToExport, []); expect(exportModule.processExtensions).toHaveBeenCalledWith( argv.dir, [], @@ -685,7 +686,7 @@ describe('extension export command', (): void => { const argv = { ...yargArgs, ...config, dir: 'my-dir', id: idsToExport, logFile: new FileLog() }; const filteredExtensionsToExport = [extensionsToExport[1]]; - jest.spyOn(exportModule, 'filterExtensionsById').mockReturnValue(filteredExtensionsToExport); + jest.spyOn(extensionHelpers, 'filterExtensionsById').mockReturnValue(filteredExtensionsToExport); await handler(argv); @@ -693,7 +694,7 @@ describe('extension export command', (): void => { expect(mockList).toHaveBeenCalled(); expect(loadJsonFromDirectory).toHaveBeenCalledWith(argv.dir, Extension); expect(validateNoDuplicateExtensionNames).toHaveBeenCalled(); - expect(exportModule.filterExtensionsById).toHaveBeenCalledWith(extensionsToExport, idsToExport); + expect(extensionHelpers.filterExtensionsById).toHaveBeenCalledWith(extensionsToExport, idsToExport); expect(exportModule.processExtensions).toHaveBeenCalledWith( argv.dir, [], diff --git a/src/commands/extension/export.ts b/src/commands/extension/export.ts index a654d50f..88d1fe4a 100644 --- a/src/commands/extension/export.ts +++ b/src/commands/extension/export.ts @@ -20,6 +20,7 @@ import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { FileLog } from '../../common/file-log'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { validateNoDuplicateExtensionNames } from './import'; +import { filterExtensionsById } from '../../common/extension/extension-helpers'; export const command = 'export '; @@ -71,25 +72,6 @@ interface ExportRecord { readonly extension: Extension; } -export const filterExtensionsById = (listToFilter: Extension[], extensionUriList: string[]): Extension[] => { - if (extensionUriList.length === 0) { - return listToFilter; - } - - const unmatchedExtensionUriList: string[] = extensionUriList.filter( - id => !listToFilter.some(extension => extension.id === id) - ); - if (unmatchedExtensionUriList.length > 0) { - throw new Error( - `The following extension URI(s) could not be found: [${unmatchedExtensionUriList - .map(u => `'${u}'`) - .join(', ')}].\nNothing was exported, exiting.` - ); - } - - return listToFilter.filter(extension => extensionUriList.some(id => extension.id === id)); -}; - export const getExportRecordForExtension = ( extension: Extension, outputDir: string, diff --git a/src/common/extension/extension-helpers.ts b/src/common/extension/extension-helpers.ts new file mode 100644 index 00000000..d021be3a --- /dev/null +++ b/src/common/extension/extension-helpers.ts @@ -0,0 +1,24 @@ +import { Extension } from 'dc-management-sdk-js'; + +export const filterExtensionsById = ( + listToFilter: Extension[], + extensionUriList: string[], + deleteExtensions: boolean = false +): Extension[] => { + if (extensionUriList.length === 0) { + return listToFilter; + } + + const unmatchedExtensionUriList: string[] = extensionUriList.filter( + id => !listToFilter.some(extension => extension.id === id) + ); + if (unmatchedExtensionUriList.length > 0) { + throw new Error( + `The following extension URI(s) could not be found: [${unmatchedExtensionUriList + .map(u => `'${u}'`) + .join(', ')}].\nNothing was ${!deleteExtensions ? 'exported' : 'deleted'}, exiting.` + ); + } + + return listToFilter.filter(extension => extensionUriList.some(id => extension.id === id)); +}; diff --git a/src/interfaces/delete-extension-builder-options.ts b/src/interfaces/delete-extension-builder-options.ts new file mode 100644 index 00000000..c07f7153 --- /dev/null +++ b/src/interfaces/delete-extension-builder-options.ts @@ -0,0 +1,6 @@ +import { FileLog } from '../common/file-log'; + +export interface DeleteExtensionBuilderOptions { + logFile: FileLog; + force?: boolean; +}