From 8edbf6ef7777908d26e7127734111fa57b68287e Mon Sep 17 00:00:00 2001 From: DB Date: Fri, 24 Oct 2025 11:29:44 +0100 Subject: [PATCH 01/17] feat: webhooks export --- docs/WEBHOOK.md | 63 ++++++++++ src/__snapshots__/cli.spec.ts.snap | 3 +- src/commands/webhook.ts | 18 +++ src/commands/webhook/export.spec.ts | 189 ++++++++++++++++++++++++++++ src/commands/webhook/export.ts | 113 +++++++++++++++++ src/common/filter/filter.ts | 25 ++++ src/interfaces/sdk-model-base.ts | 3 + 7 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 docs/WEBHOOK.md create mode 100644 src/commands/webhook.ts create mode 100644 src/commands/webhook/export.spec.ts create mode 100644 src/commands/webhook/export.ts create mode 100644 src/interfaces/sdk-model-base.ts diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md new file mode 100644 index 00000000..39a1b2da --- /dev/null +++ b/docs/WEBHOOK.md @@ -0,0 +1,63 @@ +# webhook + +## Description + +The **webhook** command category includes a number of interactions with webhooks. + +These commands can be used to export, import and delete webhooks from an individual hub. + +Run `dc-cli webhook --help` to get a list of available commands. + +Return to [README.md](../README.md) for information on other command categories. + + + +- [Common Options](#common-options) +- [Commands](#commands) + - [export](#export) + + + +## Common Options + +The following options are available for all **webhook** commands. + +| Option Name | Type | Description | +| -------------- | ---------------------------------------------------------- | -------------------------------- | +| --version | [boolean] | Show version number | +| --clientId | [string]
[required] | Client ID for the source hub | +| --clientSecret | [string]
[required] | Client secret for the source hub | +| --hubId | [string]
[required] | Hub ID for the source hub | +| --config | [string]
[default: "~/.amplience/dc-cli-config.json"] | Path to JSON config file | +| --help | [boolean] | Show help | + +## Commands + +### export + +Exports webhooks from the targeted Dynamic Content hub into a folder called **exported_webhooks** at the user specified file path. + +``` +dc-cli webhook export +``` + +#### Options + +| Option Name | Type | Description | +| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --id | [string] | The ID of the webhook to be exported.
If no --id option is given, all webhooks for the hub are exported.
A single --id option may be given to export a single webhook.
Multiple --id options may be given to delete multiple webhooks at the same time. | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | + +#### Examples + +##### Export all webhooks from a hub + +`dc-cli webhook export ./myDirectory/content` + +##### Export a single webhook from a hub + +`dc-cli webhook export ./myDirectory/content --id 1111111111` + +##### Export multiple webhooks from a hub + +`dc-cli webhook export ./myDirectory/content --id 1111111111 --id 2222222222` diff --git a/src/__snapshots__/cli.spec.ts.snap b/src/__snapshots__/cli.spec.ts.snap index c1d8338c..6e9d6e28 100644 --- a/src/__snapshots__/cli.spec.ts.snap +++ b/src/__snapshots__/cli.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`cli should create a yarg instance if one is not supplied 1`] = ` "dc-cli @@ -14,6 +14,7 @@ Commands: dc-cli hub Hub dc-cli search-index Search Index dc-cli settings Settings + dc-cli webhook Webhook Options: --help Show help [boolean] diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts new file mode 100644 index 00000000..e397573e --- /dev/null +++ b/src/commands/webhook.ts @@ -0,0 +1,18 @@ +import { Argv } from 'yargs'; +import { readConfig } from '../cli'; +import YargsCommandBuilderOptions from '../common/yargs/yargs-command-builder-options'; +import { configureCommandOptions } from './configure'; + +export const command = 'webhook'; + +export const desc = 'Webhook'; + +export const builder = (yargs: Argv): Argv => + yargs + .commandDir('webhook', YargsCommandBuilderOptions) + .options(configureCommandOptions) + .config('config', readConfig) + .demandCommand() + .help(); + +export const handler = (): void => {}; diff --git a/src/commands/webhook/export.spec.ts b/src/commands/webhook/export.spec.ts new file mode 100644 index 00000000..bd991f1b --- /dev/null +++ b/src/commands/webhook/export.spec.ts @@ -0,0 +1,189 @@ +import Yargs from 'yargs/yargs'; +import * as exportWebhooksModule from './export'; +import { builder, command, handler, LOG_FILENAME } from './export'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import readline from 'readline'; +import rmdir from 'rimraf'; +import { FileLog } from '../../common/file-log'; +import { Webhook } from 'dc-management-sdk-js'; +import MockPage from '../../common/dc-management-sdk-js/mock-page'; +import { filterById } from '../../common/filter/filter'; +import { open, close } from 'fs'; + +jest.mock('readline'); +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../common/log-helpers'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +async function itemsExist(baseDir: string, webhooks: Webhook[]): Promise { + for (const wh of webhooks) { + open(`${baseDir}${wh.label}.json`, 'r', (err, fd) => { + if (err) { + if (err.code === 'ENOENT') { + console.error('webhook not written'); + return; + } + + throw err; + } + + close(fd, err => { + if (err) throw err; + }); + }); + } +} + +describe('webhook export command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + + it('should command should defined', function () { + expect(command).toEqual('export '); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function () { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('webhook', 'export', process.platform); + }); + + describe('builder tests', function () { + it('should configure yargs', function () { + 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', { + type: 'string', + describe: + 'The id of a the webhook to be exported. If id is not provided, this command will export ALL webhooks in the hub.' + }); + + expect(spyPositional).toHaveBeenCalledWith('dir', { + describe: 'Output directory for the exported webhooks', + type: 'string', + requiresArg: true + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); + }); + }); + + describe('handler tests', function () { + const yargArgs = { + $0: 'test', + _: ['test'], + json: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id', + logFile: new FileLog() + }; + + const webhooksToExport: Webhook[] = [ + new Webhook({ + id: '1', + label: 'WH1', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + secret: 'xxxx', + method: 'POST' + }), + new Webhook({ + id: '2', + label: 'WH2', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + secret: 'xxxx', + method: 'POST' + }) + ]; + + beforeAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/export/`); + }); + + let mockGetHub: jest.Mock; + let mockList: jest.Mock; + + const webhookIdsToExport = (id: unknown) => (id ? (Array.isArray(id) ? id : [id]) : []); + + beforeEach((): void => { + const listResponse = new MockPage(Webhook, webhooksToExport); + mockList = jest.fn().mockResolvedValue(listResponse); + + mockGetHub = jest.fn().mockResolvedValue({ + related: { + webhooks: { + list: mockList + } + } + }); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + hubs: { + get: mockGetHub + } + }); + }); + + it('should export all webhooks when given only an output directory', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (readline as any).setResponses(['y']); + + const id: string[] | undefined = undefined; + + const argv = { + ...yargArgs, + ...config, + id, + logFile: new FileLog(), + dir: `temp_${process.env.JEST_WORKER_ID}/export/` + }; + + const filteredWebhooksToExport = filterById( + webhooksToExport, + webhookIdsToExport(id), + undefined, + 'webhook' + ); + + jest.spyOn(exportWebhooksModule, 'handler'); + await handler(argv); + + expect(mockGetHub).toHaveBeenCalledWith('hub-id'); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith({ size: 100 }); + + const spy = jest.spyOn(exportWebhooksModule, 'exportWebhooks'); + await exportWebhooksModule.exportWebhooks(filteredWebhooksToExport, argv.dir, argv.logFile); + + expect(spy).toHaveBeenCalledWith(filteredWebhooksToExport, argv.dir, argv.logFile); + + await itemsExist(`temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/`, filteredWebhooksToExport); + + await rimraf(`temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/`); + + spy.mockRestore(); + }); + }); +}); diff --git a/src/commands/webhook/export.ts b/src/commands/webhook/export.ts new file mode 100644 index 00000000..00bc4637 --- /dev/null +++ b/src/commands/webhook/export.ts @@ -0,0 +1,113 @@ +import { Arguments, Argv } from 'yargs'; +import { ConfigurationParameters } from '../configure'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; +import { asyncQuestion } from '../../common/question-helpers'; +import { nothingExportedExit, uniqueFilenamePath, writeJsonToFile } from '../../services/export.service'; +import paginator from '../../common/dc-management-sdk-js/paginator'; +import { filterById } from '../../common/filter/filter'; +import { Webhook } from 'dc-management-sdk-js'; +import { FileLog } from '../../common/file-log'; +import { join } from 'path'; +import sanitize from 'sanitize-filename'; +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { progressBar } from '../../common/progress-bar/progress-bar'; + +export const command = 'export '; + +export const desc = 'Export Webhooks'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('webhook', 'export', platform); + +export const builder = (yargs: Argv): void => { + yargs + .positional('id', { + type: 'string', + describe: + 'The id of a the webhook to be exported. If id is not provided, this command will export ALL webhooks in the hub.' + }) + .positional('dir', { + describe: 'Output directory for the exported webhooks', + type: 'string', + requiresArg: true + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); +}; + +export const exportWebhooks = async (webhooks: Webhook[], dir: string, log: FileLog): Promise => { + const progress = progressBar(webhooks.length, 0, { + title: `Exporting ${webhooks.length} webhooks.` + }); + + const filenames: string[] = []; + + for (let i = 0; i < webhooks.length; i++) { + const webhook = webhooks[i]; + + try { + let resolvedPath: string; + resolvedPath = 'exported_webhooks'; + + const directory = join(dir, resolvedPath); + resolvedPath = uniqueFilenamePath(directory, `${sanitize(webhook.label as string)}`, 'json', filenames); + filenames.push(resolvedPath); + + await ensureDirectoryExists(directory); + + writeJsonToFile(resolvedPath, webhook); + log.addComment(`Successfully exported "${webhook.label}"`); + progress.increment(); + } catch (e) { + log.addComment(`Failed to export ${webhook.label}: ${e.toString()}`); + progress.increment(); + } + } + + progress.stop(); +}; + +export const handler = async (argv: Arguments): Promise => { + const { id, logFile, dir } = argv; + + const client = dynamicContentClientFactory(argv); + + const allWebhooks = !id; + + const hub = await client.hubs.get(argv.hubId); + + const webhooks = await paginator(hub.related.webhooks.list); + + const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; + const webhooksToExport = filterById(webhooks, idArray, undefined, 'webhooks'); + + const log = logFile.open(); + + if (webhooksToExport.length === 0) { + nothingExportedExit(log, 'No webhooks to export from this hub, exiting.'); + return; + } + + const yes = await asyncQuestion( + allWebhooks + ? `Providing no ID/s will export all webhooks! Are you sure you want to do this? (Y/n)\n` + : `${webhooksToExport.length} webhooks will be exported. Would you like to continue? (Y/n)\n` + ); + if (!yes) { + return; + } + + log.addComment(`Exporting ${webhooksToExport.length} webhooks.`); + + await exportWebhooks(webhooksToExport, dir, log); + + log.appendLine(`Finished successfully exporting ${webhooksToExport.length} webhooks`); + + await log.close(); +}; diff --git a/src/common/filter/filter.ts b/src/common/filter/filter.ts index 872aeafa..5ef02838 100644 --- a/src/common/filter/filter.ts +++ b/src/common/filter/filter.ts @@ -1,3 +1,5 @@ +import { Id } from '../../interfaces/sdk-model-base'; + export function equalsOrRegex(value: string, compare: string): boolean { if (compare.length > 1 && compare[0] === '/' && compare[compare.length - 1] === '/') { // Regex format, try parse as a regex and return if the value is a match. @@ -11,3 +13,26 @@ export function equalsOrRegex(value: string, compare: string): boolean { } return value === compare; } + +export const filterById = ( + listToFilter: T[], + uriList: string[], + deleted: boolean = false, + typeName: string = '' +): T[] => { + if (uriList.length === 0) { + return listToFilter; + } + + const unmatchedUriList: string[] = uriList.filter(id => !listToFilter.some(type => type.id === id)); + + if (unmatchedUriList.length > 0) { + throw new Error( + `The following ${typeName} URI(s) could not be found: [${unmatchedUriList + .map(u => `'${u}'`) + .join(', ')}].\nNothing was ${!deleted ? 'exported' : 'deleted'}, exiting.` + ); + } + + return listToFilter.filter(type => uriList.some(id => type.id === id)); +}; diff --git a/src/interfaces/sdk-model-base.ts b/src/interfaces/sdk-model-base.ts new file mode 100644 index 00000000..e70e3a28 --- /dev/null +++ b/src/interfaces/sdk-model-base.ts @@ -0,0 +1,3 @@ +export interface Id { + id?: string; +} From c6ee382b09cfba81ff168dbca264ce20f41d2ed9 Mon Sep 17 00:00:00 2001 From: DB Date: Fri, 24 Oct 2025 12:46:07 +0100 Subject: [PATCH 02/17] docs: add note --- docs/WEBHOOK.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md index 39a1b2da..ad238f3c 100644 --- a/docs/WEBHOOK.md +++ b/docs/WEBHOOK.md @@ -37,6 +37,8 @@ The following options are available for all **webhook** commands. Exports webhooks from the targeted Dynamic Content hub into a folder called **exported_webhooks** at the user specified file path. +**Note**: No secret or auth header values will be exported. + ``` dc-cli webhook export ``` From 2d0c2d69705f3221635a331d0f167cd7b87ea567 Mon Sep 17 00:00:00 2001 From: DB Date: Thu, 30 Oct 2025 11:08:47 +0000 Subject: [PATCH 03/17] feat: first pass webhook import --- src/commands/webhook/import.ts | 254 +++++++++++++++++++++++++++++++++ src/common/content-mapping.ts | 15 +- 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/commands/webhook/import.ts diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts new file mode 100644 index 00000000..6b56a2a0 --- /dev/null +++ b/src/commands/webhook/import.ts @@ -0,0 +1,254 @@ +import { Arguments, Argv } from 'yargs'; +import { ConfigurationParameters } from '../configure'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; +import { FileLog } from '../../common/file-log'; +import { dirname, basename, join, relative, resolve, extname } from 'path'; + +import { lstat, readdir, readFile } from 'graceful-fs'; +import { promisify } from 'util'; +import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; +import { DynamicContent, Hub, Webhook } from 'dc-management-sdk-js'; +import { ContentMapping } from '../../common/content-mapping'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { asyncQuestion } from '../../common/question-helpers'; +import { progressBar } from '../../common/progress-bar/progress-bar'; +import PublishOptions from '../../common/publish/publish-options'; + +export function getDefaultMappingPath(name: string, platform: string = process.platform): string { + return join( + process.env[platform == 'win32' ? 'USERPROFILE' : 'HOME'] || __dirname, + '.amplience', + `imports/`, + `${name}.json` + ); +} + +export const command = 'import '; + +export const desc = 'Import Webhooks'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('webhook', 'import', platform); + +export const builder = (yargs: Argv): void => { + yargs + .positional('dir', { + describe: 'Directory containing webhooks to import.', + type: 'string', + requiresArg: true + }) + .option('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: + 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); +}; + +// interface ImportContext { +// client: DynamicContent; +// hub: Hub; +// baseDir: string; +// pathToFolderMap: Map>; +// mapping: ContentMapping; +// log: FileLog; +// } + +interface WebhookImportResult { + newItem: Webhook; + state: 'UPDATED' | 'CREATED'; +} + +const createOrUpdateContent = async ( + hub: Hub, + item: Webhook, + existing: string | Webhook | null +): Promise => { + let oldItem: Webhook | null = null; + if (typeof existing === 'string') { + oldItem = await hub.related.webhooks.get(existing); + } else { + oldItem = existing; + } + + let result: WebhookImportResult; + + if (oldItem == null) { + result = { newItem: await hub.related.webhooks.create(item), state: 'CREATED' }; + } else { + result = { newItem: await oldItem.related.update(item), state: 'UPDATED' }; + } + + return result; +}; + +const trySaveMapping = async (mapFile: string | undefined, mapping: ContentMapping, log: FileLog): Promise => { + if (mapFile != null) { + try { + await mapping.save(mapFile); + } catch (e) { + log.appendLine(`Failed to save the mapping. ${e.toString()}`); + } + } +}; + +const prepareContentForImport = async ( + //client: DynamicContent, + hub: Hub, + baseDirContents: string[], + mapping: ContentMapping, + log: FileLog, + argv: Arguments +): Promise => { + const { force } = argv; + + let contentItems: Webhook[] = []; + + for (let i = 0; i < baseDirContents.length; i++) { + log.appendLine(`Scanning webhook data in '${baseDirContents[i]}' for hub '${hub.label}'...`); + + if (extname(baseDirContents[i]) !== '.json') { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let contentJSON: any; + try { + //TODO: FIX THIS PATH ISSUE!!!!!!! + const contentText = await promisify(readFile)( + '/Users/dbhari/Desktop/Export/exported_webhooks/' + baseDirContents[i], + { + encoding: 'utf8' + } + ); + contentJSON = JSON.parse(contentText); + } catch (e) { + log.appendLine(`Couldn't read content item at '${baseDirContents[i]}': ${e.toString()}`); + return null; + } + + contentItems.push(new Webhook(contentJSON)); + } + + log.appendLine('Done. Validating content...'); + + const alreadyExists = contentItems.filter(item => mapping.getWebhook(item.id) != null); + if (alreadyExists.length > 0) { + const updateExisting = + force || + (await asyncQuestion( + `${alreadyExists.length} of the webhooks being imported already exist in the mapping. Would you like to update these webhooks instead of skipping them? (y/n) `, + log + )); + + if (!updateExisting) { + contentItems = contentItems.filter(item => mapping.getWebhook(item.id) == null); + } + } + + return contentItems; +}; + +const importTree = async (hub: Hub, tree: Webhook[], mapping: ContentMapping, log: FileLog): Promise => { + const abort = (error: Error): void => { + log.appendLine(`Importing webhook failed, aborting. Error: ${error.toString()}`); + }; + + const importProgress = progressBar(tree.length, 0, { + title: 'Importing content items' + }); + + for (let j = 0; j < tree.length; j++) { + const item = tree[j]; + + const originalId = item.id; + item.id = mapping.getWebhook(item.id as string) || ''; + + if (!item.id) { + delete item.id; + } + + let newItem: Webhook; + let state: 'CREATED' | 'UPDATED'; + + try { + const result = await createOrUpdateContent(hub, item, mapping.getWebhook(originalId as string) || null); + + newItem = result.newItem; + state = result.state; + } catch (e) { + importProgress.stop(); + log.error(`Failed creating ${item.label}:`, e); + abort(e); + return false; + } + + log.addComment(`${state} ${item.label}.`); + log.addAction(state, (newItem.id || 'unknown') + (state === 'UPDATED' ? ` ${newItem.label}` : '')); + + mapping.registerWebhook(originalId as string, newItem.id as string); + + importProgress.increment(); + } + + importProgress.stop(); + + return true; +}; + +export const handler = async ( + argv: Arguments +): Promise => { + const { dir, logFile } = argv; + let { mapFile } = argv; + + const client = dynamicContentClientFactory(argv); + const log = logFile.open(); + + const closeLog = async (): Promise => { + await log.close(); + }; + + const hub: Hub = await client.hubs.get(argv.hubId); + const importTitle = `hub-${hub.id}`; + + const mapping = new ContentMapping(); + if (mapFile == null) { + mapFile = getDefaultMappingPath(importTitle); + } + + if (await mapping.load(mapFile)) { + log.appendLine(`Existing mapping loaded from '${mapFile}', changes will be saved back to it.`); + } else { + log.appendLine(`Creating new mapping file at '${mapFile}'.`); + } + + const baseDirContents = await promisify(readdir)(dir); + + const webhooks = await prepareContentForImport(/* client, */ hub, baseDirContents, mapping, log, argv); + + let result = true; + + if (webhooks != null) { + result = await importTree(hub, webhooks, mapping, log); + } else { + log.appendLine('No webhooks found to import.'); + } + + trySaveMapping(mapFile, mapping, log); + + closeLog(); + return result; +}; diff --git a/src/common/content-mapping.ts b/src/common/content-mapping.ts index 50e09711..b834c725 100644 --- a/src/common/content-mapping.ts +++ b/src/common/content-mapping.ts @@ -9,6 +9,7 @@ export class ContentMapping { editions: Map; slots: Map; snapshots: Map; + webhooks: Map; constructor() { this.contentItems = new Map(); @@ -17,6 +18,7 @@ export class ContentMapping { this.editions = new Map(); this.slots = new Map(); this.snapshots = new Map(); + this.webhooks = new Map(); } getContentItem(id: string | undefined): string | undefined { @@ -71,6 +73,14 @@ export class ContentMapping { this.snapshots.set(fromId, toId); } + getWebhook(id: string | undefined): string | undefined { + return id === undefined ? undefined : this.webhooks.get(id); + } + + registerWebhook(fromId: string, toId: string): void { + this.webhooks.set(fromId, toId); + } + async save(filename: string): Promise { const obj: SerializedContentMapping = { contentItems: Array.from(this.contentItems), @@ -78,7 +88,8 @@ export class ContentMapping { events: Array.from(this.events), editions: Array.from(this.editions), slots: Array.from(this.slots), - snapshots: Array.from(this.snapshots) + snapshots: Array.from(this.snapshots), + webhooks: Array.from(this.webhooks) }; const text = JSON.stringify(obj); @@ -101,6 +112,7 @@ export class ContentMapping { this.editions = obj.editions ? new Map(obj.editions) : new Map(); this.slots = obj.slots ? new Map(obj.slots) : new Map(); this.snapshots = obj.snapshots ? new Map(obj.snapshots) : new Map(); + this.webhooks = obj.webhooks ? new Map(obj.webhooks) : new Map(); return true; } catch (e) { return false; @@ -115,4 +127,5 @@ interface SerializedContentMapping { editions?: [string, string][]; slots?: [string, string][]; snapshots?: [string, string][]; + webhooks?: [string, string][]; } From 76808e85346cd6a79c0ebbe0b098025d87cbf220 Mon Sep 17 00:00:00 2001 From: DB Date: Thu, 30 Oct 2025 12:28:08 +0000 Subject: [PATCH 04/17] refactor: use existsSync, re-introduce force, slient --- src/commands/webhook/export.spec.ts | 27 +++++-------------- src/commands/webhook/export.ts | 42 ++++++++++++++++------------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/commands/webhook/export.spec.ts b/src/commands/webhook/export.spec.ts index bd991f1b..7dc7db3d 100644 --- a/src/commands/webhook/export.spec.ts +++ b/src/commands/webhook/export.spec.ts @@ -9,7 +9,7 @@ import { FileLog } from '../../common/file-log'; import { Webhook } from 'dc-management-sdk-js'; import MockPage from '../../common/dc-management-sdk-js/mock-page'; import { filterById } from '../../common/filter/filter'; -import { open, close } from 'fs'; +import { existsSync } from 'fs'; jest.mock('readline'); jest.mock('../../services/dynamic-content-client-factory'); @@ -21,25 +21,6 @@ function rimraf(dir: string): Promise { }); } -async function itemsExist(baseDir: string, webhooks: Webhook[]): Promise { - for (const wh of webhooks) { - open(`${baseDir}${wh.label}.json`, 'r', (err, fd) => { - if (err) { - if (err.code === 'ENOENT') { - console.error('webhook not written'); - return; - } - - throw err; - } - - close(fd, err => { - if (err) throw err; - }); - }); - } -} - describe('webhook export command', () => { afterEach((): void => { jest.restoreAllMocks(); @@ -179,7 +160,11 @@ describe('webhook export command', () => { expect(spy).toHaveBeenCalledWith(filteredWebhooksToExport, argv.dir, argv.logFile); - await itemsExist(`temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/`, filteredWebhooksToExport); + filteredWebhooksToExport.forEach(webhook => { + const path = `temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/${webhook.label}.json`; + + expect(existsSync(path)).toBe(true); + }); await rimraf(`temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/`); diff --git a/src/commands/webhook/export.ts b/src/commands/webhook/export.ts index 00bc4637..4ca188fe 100644 --- a/src/commands/webhook/export.ts +++ b/src/commands/webhook/export.ts @@ -3,7 +3,6 @@ import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; -import { asyncQuestion } from '../../common/question-helpers'; import { nothingExportedExit, uniqueFilenamePath, writeJsonToFile } from '../../services/export.service'; import paginator from '../../common/dc-management-sdk-js/paginator'; import { filterById } from '../../common/filter/filter'; @@ -13,6 +12,7 @@ import { join } from 'path'; import sanitize from 'sanitize-filename'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { progressBar } from '../../common/progress-bar/progress-bar'; +import { confirmAllContent } from '../../common/content-item/confirm-all-content'; export const command = 'export '; @@ -38,6 +38,18 @@ export const builder = (yargs: Argv): void => { default: LOG_FILENAME, describe: 'Path to a log file to write to.', coerce: createLog + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'If present, there will be no confirmation prompt before exporting the webhooks.' + }) + .alias('s', 'silent') + .option('s', { + type: 'boolean', + boolean: true, + describe: 'If present, no log file will be produced.' }); }; @@ -74,40 +86,34 @@ export const exportWebhooks = async (webhooks: Webhook[], dir: string, log: File }; export const handler = async (argv: Arguments): Promise => { - const { id, logFile, dir } = argv; - + const { id, logFile, dir, force, silent } = argv; const client = dynamicContentClientFactory(argv); - const allWebhooks = !id; - const hub = await client.hubs.get(argv.hubId); - const webhooks = await paginator(hub.related.webhooks.list); - const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; const webhooksToExport = filterById(webhooks, idArray, undefined, 'webhooks'); - const log = logFile.open(); - if (webhooksToExport.length === 0) { + if (!webhooksToExport.length) { nothingExportedExit(log, 'No webhooks to export from this hub, exiting.'); return; } - const yes = await asyncQuestion( - allWebhooks - ? `Providing no ID/s will export all webhooks! Are you sure you want to do this? (Y/n)\n` - : `${webhooksToExport.length} webhooks will be exported. Would you like to continue? (Y/n)\n` - ); - if (!yes) { - return; + log.appendLine(`Found ${webhooksToExport.length} webhooks to export`); + + if (!force) { + const yes = await confirmAllContent('export', 'webhooks', allWebhooks, false); + if (!yes) { + return; + } } - log.addComment(`Exporting ${webhooksToExport.length} webhooks.`); + log.appendLine(`Exporting ${webhooksToExport.length} webhooks.`); await exportWebhooks(webhooksToExport, dir, log); log.appendLine(`Finished successfully exporting ${webhooksToExport.length} webhooks`); - await log.close(); + await log.close(!silent); }; From 4fc6b2ed5d359f7e16f2f165172b80d7c22dcbcc Mon Sep 17 00:00:00 2001 From: DB Date: Thu, 30 Oct 2025 16:42:12 +0000 Subject: [PATCH 05/17] feat: webhook transform data --- src/commands/webhook/export.spec.ts | 2 +- src/commands/webhook/import.ts | 129 +++++++++++++++------------- 2 files changed, 69 insertions(+), 62 deletions(-) diff --git a/src/commands/webhook/export.spec.ts b/src/commands/webhook/export.spec.ts index 7dc7db3d..f54c126f 100644 --- a/src/commands/webhook/export.spec.ts +++ b/src/commands/webhook/export.spec.ts @@ -127,7 +127,7 @@ describe('webhook export command', () => { }); }); - it('should export all webhooks when given only an output directory', async () => { + it('should export all webhooks to specified directory', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (readline as any).setResponses(['y']); diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index 6b56a2a0..b0f06333 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -2,12 +2,11 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { FileLog } from '../../common/file-log'; -import { dirname, basename, join, relative, resolve, extname } from 'path'; - -import { lstat, readdir, readFile } from 'graceful-fs'; +import { join, extname } from 'path'; +import { readdir, readFile } from 'graceful-fs'; import { promisify } from 'util'; import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; -import { DynamicContent, Hub, Webhook } from 'dc-management-sdk-js'; +import { Hub, Webhook } from 'dc-management-sdk-js'; import { ContentMapping } from '../../common/content-mapping'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { asyncQuestion } from '../../common/question-helpers'; @@ -40,38 +39,34 @@ export const builder = (yargs: Argv): void => { .option('mapFile', { type: 'string', describe: - 'Mapping file to use when updating content that already exists. Updated with any new mappings that are generated. If not present, will be created.' - }) - .alias('f', 'force') - .option('f', { - type: 'boolean', - boolean: true, - describe: - 'Overwrite content, create and assign content types, and ignore content with missing types/references without asking.' + 'Mapping file to use when updating webhooks that already exists. Updated with any new mappings that are generated. If not present, will be created.' }) .option('logFile', { type: 'string', default: LOG_FILENAME, describe: 'Path to a log file to write to.', coerce: createLog + }) + .alias('f', 'force') + .option('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite webhooks.' + }) + .alias('s', 'silent') + .option('s', { + type: 'boolean', + boolean: true, + describe: 'If present, no log file will be produced.' }); }; -// interface ImportContext { -// client: DynamicContent; -// hub: Hub; -// baseDir: string; -// pathToFolderMap: Map>; -// mapping: ContentMapping; -// log: FileLog; -// } - interface WebhookImportResult { newItem: Webhook; state: 'UPDATED' | 'CREATED'; } -const createOrUpdateContent = async ( +const createOrUpdateWebhook = async ( hub: Hub, item: Webhook, existing: string | Webhook | null @@ -104,47 +99,57 @@ const trySaveMapping = async (mapFile: string | undefined, mapping: ContentMappi } }; -const prepareContentForImport = async ( - //client: DynamicContent, +const prepareWebhooksForImport = async ( hub: Hub, - baseDirContents: string[], + webhookFiles: string[], mapping: ContentMapping, log: FileLog, argv: Arguments ): Promise => { const { force } = argv; - let contentItems: Webhook[] = []; + let webhooks: Webhook[] = []; - for (let i = 0; i < baseDirContents.length; i++) { - log.appendLine(`Scanning webhook data in '${baseDirContents[i]}' for hub '${hub.label}'...`); + for (let i = 0; i < webhookFiles.length; i++) { + log.appendLine(`Reading webhook data in '${webhookFiles[i]}' for hub '${hub.label}'...`); - if (extname(baseDirContents[i]) !== '.json') { + if (extname(webhookFiles[i]) !== '.json') { return null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - let contentJSON: any; + let webhookJSON: any; try { - //TODO: FIX THIS PATH ISSUE!!!!!!! - const contentText = await promisify(readFile)( - '/Users/dbhari/Desktop/Export/exported_webhooks/' + baseDirContents[i], - { - encoding: 'utf8' - } - ); - contentJSON = JSON.parse(contentText); + const webhookText = await promisify(readFile)(webhookFiles[i], { + encoding: 'utf8' + }); + webhookJSON = JSON.parse(webhookText); + + if (webhookJSON?.secret) delete webhookJSON.secret; + if (webhookJSON?.createdDate) delete webhookJSON.createdDate; + if (webhookJSON?.lastModifiedDate) delete webhookJSON.lastModifiedDate; + + if (webhookJSON?.headers) { + webhookJSON.headers = webhookJSON.headers.filter((h: { secret: string }) => !h.secret); + } + + if (webhookJSON?.customPayload?.value) { + webhookJSON.customPayload.value = webhookJSON.customPayload.value + .replace(/account="([^"]*)"/g, `account="${hub.name}"`) + .replace( + /stagingEnvironment="([^"]*)"/g, + `stagingEnvironment="${hub.settings?.virtualStagingEnvironment?.hostname}"` + ); + } } catch (e) { - log.appendLine(`Couldn't read content item at '${baseDirContents[i]}': ${e.toString()}`); + log.appendLine(`Couldn't read webhook at '${webhookFiles[i]}': ${e.toString()}`); return null; } - contentItems.push(new Webhook(contentJSON)); + webhooks.push(new Webhook(webhookJSON)); } - log.appendLine('Done. Validating content...'); - - const alreadyExists = contentItems.filter(item => mapping.getWebhook(item.id) != null); + const alreadyExists = webhooks.filter(item => mapping.getWebhook(item.id) != null); if (alreadyExists.length > 0) { const updateExisting = force || @@ -154,24 +159,29 @@ const prepareContentForImport = async ( )); if (!updateExisting) { - contentItems = contentItems.filter(item => mapping.getWebhook(item.id) == null); + webhooks = webhooks.filter(item => mapping.getWebhook(item.id) == null); } } - return contentItems; + return webhooks; }; -const importTree = async (hub: Hub, tree: Webhook[], mapping: ContentMapping, log: FileLog): Promise => { +const importWebhooks = async ( + hub: Hub, + webhooks: Webhook[], + mapping: ContentMapping, + log: FileLog +): Promise => { const abort = (error: Error): void => { log.appendLine(`Importing webhook failed, aborting. Error: ${error.toString()}`); }; - const importProgress = progressBar(tree.length, 0, { - title: 'Importing content items' + const importProgress = progressBar(webhooks.length, 0, { + title: 'Importing webhooks' }); - for (let j = 0; j < tree.length; j++) { - const item = tree[j]; + for (let j = 0; j < webhooks.length; j++) { + const item = webhooks[j]; const originalId = item.id; item.id = mapping.getWebhook(item.id as string) || ''; @@ -184,7 +194,7 @@ const importTree = async (hub: Hub, tree: Webhook[], mapping: ContentMapping, lo let state: 'CREATED' | 'UPDATED'; try { - const result = await createOrUpdateContent(hub, item, mapping.getWebhook(originalId as string) || null); + const result = await createOrUpdateWebhook(hub, item, mapping.getWebhook(originalId as string) || null); newItem = result.newItem; state = result.state; @@ -211,16 +221,10 @@ const importTree = async (hub: Hub, tree: Webhook[], mapping: ContentMapping, lo export const handler = async ( argv: Arguments ): Promise => { - const { dir, logFile } = argv; + const { dir, logFile, silent } = argv; let { mapFile } = argv; - const client = dynamicContentClientFactory(argv); const log = logFile.open(); - - const closeLog = async (): Promise => { - await log.close(); - }; - const hub: Hub = await client.hubs.get(argv.hubId); const importTitle = `hub-${hub.id}`; @@ -237,18 +241,21 @@ export const handler = async ( const baseDirContents = await promisify(readdir)(dir); - const webhooks = await prepareContentForImport(/* client, */ hub, baseDirContents, mapping, log, argv); + const webhookFiles = baseDirContents.map(wh => { + return `${dir}/${wh}`; + }); + const webhooks = await prepareWebhooksForImport(hub, webhookFiles, mapping, log, argv); let result = true; if (webhooks != null) { - result = await importTree(hub, webhooks, mapping, log); + result = await importWebhooks(hub, webhooks, mapping, log); } else { log.appendLine('No webhooks found to import.'); } trySaveMapping(mapFile, mapping, log); - closeLog(); + await log.close(!silent); return result; }; From c754c0f2ca856777a39af92cbdfec3e8df5ffbf7 Mon Sep 17 00:00:00 2001 From: DB Date: Thu, 30 Oct 2025 17:27:38 +0000 Subject: [PATCH 06/17] docs: webhook docs --- docs/WEBHOOK.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md index ad238f3c..9cb1663d 100644 --- a/docs/WEBHOOK.md +++ b/docs/WEBHOOK.md @@ -63,3 +63,42 @@ dc-cli webhook export ##### Export multiple webhooks from a hub `dc-cli webhook export ./myDirectory/content --id 1111111111 --id 2222222222` + +### import + +Imports webhooks from the specified filesystem location to the targeted Dynamic Content hub. + +**Note**: The following values will not be included during the import: + +- secret +- createdDate +- lastModifiedDate +- any header objects that are secrets. + +Also for any **customPayload** the following property values will be replaced by those in the destination hub: + +- account +- stagingEnvironment + +``` +dc-cli webhook import +``` + +#### Options + +| Option Name | Type | Description | +| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --mapFile | [string] | Mapping file to use when updating content that already exists.
Updated with any new mappings that are generated. If not present, will be created.
For more information, see [mapping files](#MAPPING-FILES). | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | +| -s
--silent | [boolean] | If present, no log file will be produced. | +| -f
--force | [boolean] | Overwrite webhooks without asking. | + +#### Examples + +##### Import content from the filesystem + +`dc-cli webhook import ./myDirectory/webhooks` + +##### Specify a mapping file when importing + +`dc-cli webhook import ./myDirectory/webhooks --mapFile ./myDirectory/mappingFile.json` From 288ffac96d1c1b5da4b6c0e1fef4ebae7e371998 Mon Sep 17 00:00:00 2001 From: DB Date: Tue, 4 Nov 2025 15:36:27 +0000 Subject: [PATCH 07/17] feat: updates to webhook import --- docs/WEBHOOK.md | 14 +- src/commands/webhook/import.spec.ts | 231 ++++++++++++++++++ src/commands/webhook/import.ts | 23 +- src/commands/webhook/webhook-test-helpers.ts | 103 ++++++++ .../import-item-builder-options.interface.ts | 2 +- 5 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 src/commands/webhook/import.spec.ts create mode 100644 src/commands/webhook/webhook-test-helpers.ts diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md index 9cb1663d..4be64982 100644 --- a/docs/WEBHOOK.md +++ b/docs/WEBHOOK.md @@ -68,14 +68,16 @@ dc-cli webhook export Imports webhooks from the specified filesystem location to the targeted Dynamic Content hub. -**Note**: The following values will not be included during the import: +**Note**: The following values will be stripped out / not included during the import: -- secret -- createdDate -- lastModifiedDate -- any header objects that are secrets. +- **secret** - this will be recreated for the webhook in the destination hub during import. +- **createdDate** - this will be assigned during import (if webhook is being created). +- **lastModifiedDate** - this will be assigned during import (if webhook is being updated). +- **any header objects that are secrets** - these need to be manually assigned for the webhook in the destination hub. -Also for any **customPayload** the following property values will be replaced by those in the destination hub: +Please see [the content-management API reference for Webhooks](https://amplience.com/developers/docs/apis/content-management-reference/#tag/Webhooks) for more information. + +For any **customPayload** the following property values will be replaced by those in the destination hub: - account - stagingEnvironment diff --git a/src/commands/webhook/import.spec.ts b/src/commands/webhook/import.spec.ts new file mode 100644 index 00000000..fda74334 --- /dev/null +++ b/src/commands/webhook/import.spec.ts @@ -0,0 +1,231 @@ +import { builder, command, getDefaultMappingPath, handler, LOG_FILENAME } from './import'; +import * as importModule from './import'; +import { Hub, Webhook } from 'dc-management-sdk-js'; +import Yargs from 'yargs/yargs'; +import rmdir from 'rimraf'; +import { FileLog } from '../../common/file-log'; +import { ContentMapping } from '../../common/content-mapping'; +import { mockValues } from './webhook-test-helpers'; +import { createLog, getDefaultLogPath } from '../../common/log-helpers'; +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import { exportWebhooks } from './export'; +import readline from 'readline'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../services/import.service'); +jest.mock('readline'); + +jest.mock('../../common/log-helpers', () => ({ + ...jest.requireActual('../../common/log-helpers'), + getDefaultLogPath: jest.fn() +})); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('webhook import command', () => { + afterEach((): void => { + jest.restoreAllMocks(); + }); + + it('should command should defined', function () { + expect(command).toEqual('import '); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function () { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('webhook', 'import', process.platform); + }); + + it('should generate a default mapping path containing the given name', function () { + expect(getDefaultMappingPath('hub-1').indexOf('hub-1')).not.toEqual(-1); + }); + + describe('builder tests', function () { + it('should configure yargs', function () { + 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('dir', { + describe: 'Directory containing webhooks to import.', + type: 'string', + requiresArg: true + }); + + expect(spyOption).toHaveBeenCalledWith('mapFile', { + type: 'string', + describe: + 'Mapping file to use when updating webhooks that already exists. Updated with any new mappings that are generated. If not present, will be created.' + }); + + expect(spyOption).toHaveBeenCalledWith('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: createLog + }); + + expect(spyOption).toHaveBeenCalledWith('f', { + type: 'boolean', + boolean: true, + describe: 'Overwrite webhooks.' + }); + + expect(spyOption).toHaveBeenCalledWith('s', { + type: 'boolean', + boolean: true, + describe: 'If present, no log file will be produced.' + }); + }); + }); + + describe('handler tests', function () { + beforeAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/importWebhook/`); + }); + + afterAll(async () => { + await rimraf(`temp_${process.env.JEST_WORKER_ID}/importWebhook/`); + }); + + it('should call importWebhooks with the loaded webhook, then save the mapping', async function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (readline as any).setResponses(['y']); + + const { getHubMock } = mockValues({}); + const logFile = new FileLog(); + + const yargArgs = { + $0: 'test', + _: ['test'], + json: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-1', + logFile + }; + + const webhookObj = { + id: '1', + label: 'WH1', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + method: 'POST' + }; + + const webhooks = [new Webhook(webhookObj)]; + + await exportWebhooks(webhooks, `temp_${process.env.JEST_WORKER_ID}/importWebhook/`, logFile); + + const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(true); + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + + const getDefaultMappingPathSpy = jest.spyOn(importModule, 'getDefaultMappingPath'); + const mappingPath = importModule.getDefaultMappingPath('hub-1'); + + const argv = { + ...yargArgs, + ...config, + dir: `temp_${process.env.JEST_WORKER_ID}/importWebhook/exported_webhooks` + }; + + await handler(argv); + + expect(getHubMock).toHaveBeenCalledWith('hub-1'); + + expect(importWebhooks).toHaveBeenCalledWith( + expect.any(Hub), + expect.arrayContaining([expect.objectContaining(webhookObj)]), + expect.any(ContentMapping), + logFile + ); + + expect(getDefaultMappingPathSpy).toHaveBeenCalledWith('hub-1'); + + expect(trySaveMapping).toHaveBeenCalledWith( + expect.stringContaining('hub-1.json'), + expect.any(ContentMapping), + logFile + ); + expect(logFile.closed).toBeTruthy(); + + rimraf(mappingPath); + }); + + it('should load an existing mapping file', async function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (readline as any).setResponses(['y']); + + const { getHubMock } = mockValues({}); + const logFile = new FileLog(); + + const yargArgs = { + $0: 'test', + _: ['test'], + json: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-1', + logFile + }; + + const webhookObj = { + id: '1', + label: 'WH1', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + method: 'POST' + }; + + const webhooks = [new Webhook(webhookObj)]; + + await exportWebhooks(webhooks, `temp_${process.env.JEST_WORKER_ID}/importWebhook/`, logFile); + + const argv = { + ...yargArgs, + ...config, + logFile, + mapFile: `temp_${process.env.JEST_WORKER_ID}/importWebhook/hub-1(existing-mapping).json`, + dir: `temp_${process.env.JEST_WORKER_ID}/importWebhook/exported_webhooks` + }; + + const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(true); + const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); + + const getDefaultMappingPathSpy = jest.spyOn(importModule, 'getDefaultMappingPath'); + + await ensureDirectoryExists(`temp_${process.env.JEST_WORKER_ID}/importWebhook/`); + + const existingMapping = new ContentMapping(); + await existingMapping.save(argv.mapFile); + + await handler(argv); + + expect(getHubMock).toHaveBeenCalledWith('hub-1'); + + expect(importWebhooks).toHaveBeenCalledWith( + expect.any(Hub), + expect.arrayContaining([expect.objectContaining(webhookObj)]), + expect.any(ContentMapping), + logFile + ); + + expect(getDefaultMappingPathSpy).not.toHaveBeenCalled(); + expect(trySaveMapping).toHaveBeenCalledWith(argv.mapFile, expect.any(ContentMapping), logFile); + expect(logFile.closed).toBeTruthy(); + }); + }); +}); diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index b0f06333..e30838de 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -66,7 +66,7 @@ interface WebhookImportResult { state: 'UPDATED' | 'CREATED'; } -const createOrUpdateWebhook = async ( +export const createOrUpdateWebhook = async ( hub: Hub, item: Webhook, existing: string | Webhook | null @@ -89,7 +89,11 @@ const createOrUpdateWebhook = async ( return result; }; -const trySaveMapping = async (mapFile: string | undefined, mapping: ContentMapping, log: FileLog): Promise => { +export const trySaveMapping = async ( + mapFile: string | undefined, + mapping: ContentMapping, + log: FileLog +): Promise => { if (mapFile != null) { try { await mapping.save(mapFile); @@ -99,7 +103,7 @@ const trySaveMapping = async (mapFile: string | undefined, mapping: ContentMappi } }; -const prepareWebhooksForImport = async ( +export const prepareWebhooksForImport = async ( hub: Hub, webhookFiles: string[], mapping: ContentMapping, @@ -166,7 +170,7 @@ const prepareWebhooksForImport = async ( return webhooks; }; -const importWebhooks = async ( +export const importWebhooks = async ( hub: Hub, webhooks: Webhook[], mapping: ContentMapping, @@ -221,7 +225,7 @@ const importWebhooks = async ( export const handler = async ( argv: Arguments ): Promise => { - const { dir, logFile, silent } = argv; + const { dir, logFile, force, silent } = argv; let { mapFile } = argv; const client = dynamicContentClientFactory(argv); const log = logFile.open(); @@ -248,7 +252,14 @@ export const handler = async ( const webhooks = await prepareWebhooksForImport(hub, webhookFiles, mapping, log, argv); let result = true; - if (webhooks != null) { + if (webhooks !== null) { + const proceedImport = + force || + (await asyncQuestion(`${webhooks.length} webhook/s will be imported, do you wish to continue? (y/n) `, log)); + if (!proceedImport) { + return false; + } + result = await importWebhooks(hub, webhooks, mapping, log); } else { log.appendLine('No webhooks found to import.'); diff --git a/src/commands/webhook/webhook-test-helpers.ts b/src/commands/webhook/webhook-test-helpers.ts new file mode 100644 index 00000000..de9db462 --- /dev/null +++ b/src/commands/webhook/webhook-test-helpers.ts @@ -0,0 +1,103 @@ +import { Hub, Webhook } from 'dc-management-sdk-js'; +import MockPage from '../../common/dc-management-sdk-js/mock-page'; +import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; + +export const mockValues = ({ + getHubError = false, + getWebhookError = false, + listWebhookError = false +}): { + mockGet: () => void; + getHubMock: () => void; + mockWebhooksList: () => void; + mockWebhookUpdate: () => void; + mockWebhookCreate: () => void; +} => { + const mockGet = jest.fn(); + const getHubMock = jest.fn(); + const mockWebhooksList = jest.fn(); + const mockWebhookUpdate = jest.fn(); + const mockWebhookCreate = jest.fn(); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + hubs: { + get: getHubMock + }, + webhooks: { + get: mockGet + } + }); + + const hub = new Hub({ + name: '1', + id: '1', + _links: { + webhooks: { + href: 'https://api.amplience.net/v2/content/webhooks', + templated: true + } + } + }); + + getHubMock.mockResolvedValue(hub); + + hub.related.webhooks.list = mockWebhooksList; + hub.related.webhooks.create = mockWebhookCreate; + + const webhooks = [ + new Webhook({ + id: '1', + label: 'WH1', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + secret: 'xxxx', + method: 'POST' + }), + new Webhook({ + id: '2', + label: 'WH2', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + secret: 'xxxx', + method: 'POST' + }) + ]; + + mockWebhooksList.mockResolvedValue(new MockPage(Webhook, webhooks)); + + const webhook = new Webhook({ + id: '1', + label: 'WH1', + events: ['dynamic-content.content-item.updated'], + active: true, + handlers: ['https://test.this/webhook'], + secret: 'xxxx', + method: 'POST' + }); + + mockGet.mockResolvedValue(webhook); + + webhook.related.update = mockWebhookUpdate; + + if (getHubError) { + getHubMock.mockRejectedValue(new Error('Error')); + } + + if (getWebhookError) { + mockGet.mockRejectedValue(new Error('Error')); + } + + if (listWebhookError) { + mockWebhooksList.mockRejectedValue(new Error('Error')); + } + + return { + mockGet, + getHubMock, + mockWebhooksList, + mockWebhookUpdate, + mockWebhookCreate + }; +}; diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index f9cbdafd..bb902e05 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -13,6 +13,6 @@ export interface ImportItemBuilderOptions { excludeKeys?: boolean; media?: boolean; logFile: FileLog; - revertLog: Promise; + revertLog?: Promise; ignoreSchemaValidation?: boolean; } From d3ebc9af4abb9ad5f9152393192d9c43ad07b9bc Mon Sep 17 00:00:00 2001 From: DB Date: Wed, 5 Nov 2025 12:53:17 +0000 Subject: [PATCH 08/17] feat: webhook import optimisations, build up obj --- src/commands/webhook/import.spec.ts | 4 +- src/commands/webhook/import.ts | 108 ++++++++++++++-------------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/src/commands/webhook/import.spec.ts b/src/commands/webhook/import.spec.ts index fda74334..9aaca261 100644 --- a/src/commands/webhook/import.spec.ts +++ b/src/commands/webhook/import.spec.ts @@ -127,7 +127,7 @@ describe('webhook import command', () => { await exportWebhooks(webhooks, `temp_${process.env.JEST_WORKER_ID}/importWebhook/`, logFile); - const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(true); + const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(); const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); const getDefaultMappingPathSpy = jest.spyOn(importModule, 'getDefaultMappingPath'); @@ -202,7 +202,7 @@ describe('webhook import command', () => { dir: `temp_${process.env.JEST_WORKER_ID}/importWebhook/exported_webhooks` }; - const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(true); + const importWebhooks = jest.spyOn(importModule, 'importWebhooks').mockResolvedValue(); const trySaveMapping = jest.spyOn(importModule, 'trySaveMapping').mockResolvedValue(); const getDefaultMappingPathSpy = jest.spyOn(importModule, 'getDefaultMappingPath'); diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index e30838de..3565971b 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -62,7 +62,7 @@ export const builder = (yargs: Argv): void => { }; interface WebhookImportResult { - newItem: Webhook; + webhook: Webhook; state: 'UPDATED' | 'CREATED'; } @@ -81,9 +81,9 @@ export const createOrUpdateWebhook = async ( let result: WebhookImportResult; if (oldItem == null) { - result = { newItem: await hub.related.webhooks.create(item), state: 'CREATED' }; + result = { webhook: await hub.related.webhooks.create(item), state: 'CREATED' }; } else { - result = { newItem: await oldItem.related.update(item), state: 'UPDATED' }; + result = { webhook: await oldItem.related.update(item), state: 'UPDATED' }; } return result; @@ -114,31 +114,42 @@ export const prepareWebhooksForImport = async ( let webhooks: Webhook[] = []; - for (let i = 0; i < webhookFiles.length; i++) { - log.appendLine(`Reading webhook data in '${webhookFiles[i]}' for hub '${hub.label}'...`); + for (const webhookFile of webhookFiles) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let webhook: any = {}; + + log.appendLine(`Reading webhook data in '${webhookFile}' for hub '${hub.label}'...`); - if (extname(webhookFiles[i]) !== '.json') { + if (extname(webhookFile) !== '.json') { return null; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let webhookJSON: any; try { - const webhookText = await promisify(readFile)(webhookFiles[i], { + const webhookText = await promisify(readFile)(webhookFile, { encoding: 'utf8' }); - webhookJSON = JSON.parse(webhookText); - - if (webhookJSON?.secret) delete webhookJSON.secret; - if (webhookJSON?.createdDate) delete webhookJSON.createdDate; - if (webhookJSON?.lastModifiedDate) delete webhookJSON.lastModifiedDate; - if (webhookJSON?.headers) { - webhookJSON.headers = webhookJSON.headers.filter((h: { secret: string }) => !h.secret); + const webhookJSON = JSON.parse(webhookText); + + webhook = { + ...(webhookJSON.id && { id: webhookJSON.id }), + ...(webhookJSON.label && { label: webhookJSON.label }), + ...(webhookJSON.events && { events: webhookJSON.events }), + ...(webhookJSON.active && { active: webhookJSON.active }), + ...(webhookJSON.handlers && { handlers: webhookJSON.handlers }), + ...(webhookJSON.notifications && { notifications: webhookJSON.notifications }), + ...(webhookJSON.headers && { headers: webhookJSON.headers }), + ...(webhookJSON.filters && { filters: webhookJSON.filters }), + ...(webhookJSON.customPayload && { customPayload: webhookJSON.customPayload }), + ...(webhookJSON.method && { method: webhookJSON.method }) + }; + + if (webhook?.headers) { + webhook.headers = webhook.headers.filter((h: { secret: string }) => !h.secret); } - if (webhookJSON?.customPayload?.value) { - webhookJSON.customPayload.value = webhookJSON.customPayload.value + if (webhook?.customPayload?.value) { + webhook.customPayload.value = webhook.customPayload.value .replace(/account="([^"]*)"/g, `account="${hub.name}"`) .replace( /stagingEnvironment="([^"]*)"/g, @@ -146,11 +157,11 @@ export const prepareWebhooksForImport = async ( ); } } catch (e) { - log.appendLine(`Couldn't read webhook at '${webhookFiles[i]}': ${e.toString()}`); + log.appendLine(`Couldn't read webhook at '${webhookFile}': ${e.toString()}`); return null; } - webhooks.push(new Webhook(webhookJSON)); + webhooks.push(new Webhook(webhook as Webhook)); } const alreadyExists = webhooks.filter(item => mapping.getWebhook(item.id) != null); @@ -175,56 +186,45 @@ export const importWebhooks = async ( webhooks: Webhook[], mapping: ContentMapping, log: FileLog -): Promise => { - const abort = (error: Error): void => { - log.appendLine(`Importing webhook failed, aborting. Error: ${error.toString()}`); - }; - +): Promise => { const importProgress = progressBar(webhooks.length, 0, { title: 'Importing webhooks' }); - for (let j = 0; j < webhooks.length; j++) { - const item = webhooks[j]; + for (const webhookToImport of webhooks) { + const originalId = webhookToImport.id; + webhookToImport.id = mapping.getWebhook(webhookToImport.id as string) || ''; - const originalId = item.id; - item.id = mapping.getWebhook(item.id as string) || ''; - - if (!item.id) { - delete item.id; + if (!webhookToImport.id) { + delete webhookToImport.id; } - let newItem: Webhook; - let state: 'CREATED' | 'UPDATED'; - try { - const result = await createOrUpdateWebhook(hub, item, mapping.getWebhook(originalId as string) || null); + const { webhook, state } = await createOrUpdateWebhook( + hub, + webhookToImport, + mapping.getWebhook(originalId as string) || null + ); - newItem = result.newItem; - state = result.state; + log.addComment(`${state} ${webhook.label}.`); + log.addAction(state, (webhook.id || 'unknown') + (state === 'UPDATED' ? ` ${webhook.label}` : '')); + + mapping.registerWebhook(originalId as string, webhook.id as string); + + importProgress.increment(); } catch (e) { importProgress.stop(); - log.error(`Failed creating ${item.label}:`, e); - abort(e); - return false; + log.error(`Failed creating ${webhookToImport.label}:`, e); + throw Error(`Importing webhook failed. Error: ${e.toString()}`); } - - log.addComment(`${state} ${item.label}.`); - log.addAction(state, (newItem.id || 'unknown') + (state === 'UPDATED' ? ` ${newItem.label}` : '')); - - mapping.registerWebhook(originalId as string, newItem.id as string); - - importProgress.increment(); } importProgress.stop(); - - return true; }; export const handler = async ( argv: Arguments -): Promise => { +): Promise => { const { dir, logFile, force, silent } = argv; let { mapFile } = argv; const client = dynamicContentClientFactory(argv); @@ -250,17 +250,16 @@ export const handler = async ( }); const webhooks = await prepareWebhooksForImport(hub, webhookFiles, mapping, log, argv); - let result = true; if (webhooks !== null) { const proceedImport = force || (await asyncQuestion(`${webhooks.length} webhook/s will be imported, do you wish to continue? (y/n) `, log)); if (!proceedImport) { - return false; + return; } - result = await importWebhooks(hub, webhooks, mapping, log); + await importWebhooks(hub, webhooks, mapping, log); } else { log.appendLine('No webhooks found to import.'); } @@ -268,5 +267,4 @@ export const handler = async ( trySaveMapping(mapFile, mapping, log); await log.close(!silent); - return result; }; From 71de3a0237c66cea3767335e0ec4425b2888d108 Mon Sep 17 00:00:00 2001 From: DB Date: Wed, 5 Nov 2025 15:11:22 +0000 Subject: [PATCH 09/17] refactor: try catch --- src/commands/webhook/import.ts | 63 +++++++++++++++++----------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index 3565971b..1b0ab7c5 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -115,53 +115,54 @@ export const prepareWebhooksForImport = async ( let webhooks: Webhook[] = []; for (const webhookFile of webhookFiles) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let webhook: any = {}; - log.appendLine(`Reading webhook data in '${webhookFile}' for hub '${hub.label}'...`); if (extname(webhookFile) !== '.json') { return null; } + let webhookJSON; + try { const webhookText = await promisify(readFile)(webhookFile, { encoding: 'utf8' }); - const webhookJSON = JSON.parse(webhookText); - - webhook = { - ...(webhookJSON.id && { id: webhookJSON.id }), - ...(webhookJSON.label && { label: webhookJSON.label }), - ...(webhookJSON.events && { events: webhookJSON.events }), - ...(webhookJSON.active && { active: webhookJSON.active }), - ...(webhookJSON.handlers && { handlers: webhookJSON.handlers }), - ...(webhookJSON.notifications && { notifications: webhookJSON.notifications }), - ...(webhookJSON.headers && { headers: webhookJSON.headers }), - ...(webhookJSON.filters && { filters: webhookJSON.filters }), - ...(webhookJSON.customPayload && { customPayload: webhookJSON.customPayload }), - ...(webhookJSON.method && { method: webhookJSON.method }) - }; - - if (webhook?.headers) { - webhook.headers = webhook.headers.filter((h: { secret: string }) => !h.secret); - } - - if (webhook?.customPayload?.value) { - webhook.customPayload.value = webhook.customPayload.value - .replace(/account="([^"]*)"/g, `account="${hub.name}"`) - .replace( - /stagingEnvironment="([^"]*)"/g, - `stagingEnvironment="${hub.settings?.virtualStagingEnvironment?.hostname}"` - ); - } + webhookJSON = JSON.parse(webhookText); } catch (e) { log.appendLine(`Couldn't read webhook at '${webhookFile}': ${e.toString()}`); return null; } - webhooks.push(new Webhook(webhook as Webhook)); + const webhook = new Webhook({ + ...(webhookJSON.id && { id: webhookJSON.id }), + ...(webhookJSON.label && { label: webhookJSON.label }), + ...(webhookJSON.events && { events: webhookJSON.events }), + ...(webhookJSON.active && { active: webhookJSON.active }), + ...(webhookJSON.handlers && { handlers: webhookJSON.handlers }), + ...(webhookJSON.notifications && { notifications: webhookJSON.notifications }), + ...(webhookJSON.headers && { headers: webhookJSON.headers }), + ...(webhookJSON.filters && { filters: webhookJSON.filters }), + ...(webhookJSON.customPayload && { customPayload: webhookJSON.customPayload }), + ...(webhookJSON.method && { method: webhookJSON.method }) + }); + + if (webhook?.headers) { + webhook.headers = webhook.headers.filter(header => { + return !header.secret; + }); + } + + if (webhook?.customPayload?.value) { + webhook.customPayload.value = webhook.customPayload.value + .replace(/account="([^"]*)"/g, `account="${hub.name}"`) + .replace( + /stagingEnvironment="([^"]*)"/g, + `stagingEnvironment="${hub.settings?.virtualStagingEnvironment?.hostname}"` + ); + } + + webhooks.push(new Webhook(webhook)); } const alreadyExists = webhooks.filter(item => mapping.getWebhook(item.id) != null); From 4050fdce2ca45140c946a9595e4c1eac9aed3a07 Mon Sep 17 00:00:00 2001 From: DB Date: Thu, 6 Nov 2025 15:15:10 +0000 Subject: [PATCH 10/17] feat: webhook delete --- docs/WEBHOOK.md | 35 +++++ src/commands/extension/delete.spec.ts | 2 +- src/commands/extension/delete.ts | 2 +- src/commands/webhook/delete.spec.ts | 144 ++++++++++++++++++ src/commands/webhook/delete.ts | 112 ++++++++++++++ .../delete-webhook-builder-options.ts | 6 + 6 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 src/commands/webhook/delete.spec.ts create mode 100644 src/commands/webhook/delete.ts create mode 100644 src/interfaces/delete-webhook-builder-options.ts diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md index 4be64982..e45df033 100644 --- a/docs/WEBHOOK.md +++ b/docs/WEBHOOK.md @@ -15,6 +15,8 @@ Return to [README.md](../README.md) for information on other command categories. - [Common Options](#common-options) - [Commands](#commands) - [export](#export) + - [import](#import) + - [delete](#delete) @@ -104,3 +106,36 @@ dc-cli webhook import ##### Specify a mapping file when importing `dc-cli webhook import ./myDirectory/webhooks --mapFile ./myDirectory/mappingFile.json` + +### delete + +Deletes webhooks from the targeted Dynamic Content hub. + +``` +dc-cli webhook delete +``` + +#### Options + +| Option Name | Type | Description | +| --------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --id | [string] | The ID of an Webhook to be deleted.
If no --id option is given, all webhooks for the hub are deleted.
A single --id option may be given to delete a single webhook.
Multiple --id options may be given to delete multiple webhooks at the same time. | +| -f
--force | [boolean] | Delete webhooks without asking. | + +#### Examples + +##### Delete all webhooks from a Hub + +`dc-cli webhook delete` + +##### Delete a single webhook with the ID of 'foo' + +`dc-cli webhook delete foo` + +or + +`dc-cli webhook delete --id foo` + +##### Delete multiple webhooks with the IDs of 'foo' & 'bar' + +`dc-cli webhook delete --id foo --id bar` diff --git a/src/commands/extension/delete.spec.ts b/src/commands/extension/delete.spec.ts index a620e9c1..b677c3a5 100644 --- a/src/commands/extension/delete.spec.ts +++ b/src/commands/extension/delete.spec.ts @@ -28,7 +28,7 @@ describe('delete extensions', () => { 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.', + 'The ID of 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', { diff --git a/src/commands/extension/delete.ts b/src/commands/extension/delete.ts index b3c329cc..f07304e1 100644 --- a/src/commands/extension/delete.ts +++ b/src/commands/extension/delete.ts @@ -25,7 +25,7 @@ export const builder = (yargs: Argv): void => { .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.' + 'The ID of the extension to be deleted. If id is not provided, this command will delete ALL extensions in the hub.' }) .alias('f', 'force') .option('f', { diff --git a/src/commands/webhook/delete.spec.ts b/src/commands/webhook/delete.spec.ts new file mode 100644 index 00000000..8a7ced1c --- /dev/null +++ b/src/commands/webhook/delete.spec.ts @@ -0,0 +1,144 @@ +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 { Webhook } 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 * as questionHelpers from '../../common/question-helpers'; +import { filterById } from '../../common/filter/filter'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../common/log-helpers'); +jest.mock('../../common/question-helpers'); + +describe('delete webhooks', () => { + 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 the webhook to be deleted. If id is not provided, this command will delete ALL webhooks 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 webhooks.' + }); + 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 webhooksToDelete: Webhook[] = [ + new Webhook({ + id: 'webhook-id-1', + label: 'webhook-label-1' + }), + new Webhook({ + id: 'webhook-id-2', + label: 'webhook-label-2' + }) + ]; + + let mockGetHub: jest.Mock; + let mockList: jest.Mock; + + const webhookIdsToDelete = (id: unknown) => (id ? (Array.isArray(id) ? id : [id]) : []); + + beforeEach((): void => { + const listResponse = new MockPage(Webhook, webhooksToDelete); + mockList = jest.fn().mockResolvedValue(listResponse); + + mockGetHub = jest.fn().mockResolvedValue({ + related: { + webhooks: { + list: mockList + } + } + }); + + (dynamicContentClientFactory as jest.Mock).mockReturnValue({ + hubs: { + get: mockGetHub + } + }); + + jest.spyOn(deleteModule, 'processWebhooks').mockResolvedValue(); + }); + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function () { + LOG_FILENAME(); + expect(getDefaultLogPath).toHaveBeenCalledWith('webhook', 'delete', process.platform); + }); + + it('should delete all webhooks in a hub', async (): Promise => { + const id: string[] | undefined = undefined; + const argv = { ...yargArgs, ...config, id, logFile: new FileLog() }; + + const filteredWebhooksToDelete = filterById(webhooksToDelete, webhookIdsToDelete(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.processWebhooks).toHaveBeenCalledWith(filteredWebhooksToDelete, argv.logFile); + }); + + it('should delete an webhook by id', async (): Promise => { + const id: string[] | undefined = ['webhook-id-2']; + const argv = { + ...yargArgs, + ...config, + id, + logFile: new FileLog() + }; + + const filteredWebhooksToDelete = filterById(webhooksToDelete, webhookIdsToDelete(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.processWebhooks).toHaveBeenCalledWith(filteredWebhooksToDelete, argv.logFile); + }); + }); +}); diff --git a/src/commands/webhook/delete.ts b/src/commands/webhook/delete.ts new file mode 100644 index 00000000..4c3b9e94 --- /dev/null +++ b/src/commands/webhook/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 { Webhook } from 'dc-management-sdk-js'; +import { asyncQuestion } from '../../common/question-helpers'; +import { progressBar } from '../../common/progress-bar/progress-bar'; +import { filterById } from '../../common/filter/filter'; +import { DeleteWebhookBuilderOptions } from '../../interfaces/delete-webhook-builder-options'; + +export const command = 'delete [id]'; + +export const desc = 'Delete Webhook'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('webhook', 'delete', platform); + +export const coerceLog = (logFile: string): FileLog => createLog(logFile, 'Webhook Delete Log'); + +export const builder = (yargs: Argv): void => { + yargs + .positional('id', { + type: 'string', + describe: + 'The ID of the webhook to be deleted. If id is not provided, this command will delete ALL webhooks 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 webhooks.' + }) + .option('logFile', { + type: 'string', + default: LOG_FILENAME, + describe: 'Path to a log file to write to.', + coerce: coerceLog + }); +}; + +export const processWebhooks = async (webhooksToDelete: Webhook[], log: FileLog): Promise => { + const failedWebhooks: Webhook[] = []; + + const progress = progressBar(webhooksToDelete.length, 0, { + title: `Deleting ${webhooksToDelete.length} webhook/s.` + }); + + for (const [i, webhook] of webhooksToDelete.entries()) { + try { + await webhook.related.delete(); + log.addComment(`Successfully deleted "${webhook.label}"`); + progress.increment(); + } catch (e) { + failedWebhooks.push(webhook); + webhooksToDelete.splice(i, 1); + log.addComment(`Failed to delete ${webhook.label}: ${e.toString()}`); + progress.increment(); + } + } + + progress.stop(); + + if (failedWebhooks.length > 0) { + log.appendLine(`Failed to delete ${failedWebhooks.length} webhooks`); + } +}; + +export const handler = async ( + argv: Arguments +): Promise => { + const { id, logFile, force } = argv; + + const client = dynamicContentClientFactory(argv); + + const allWebhooks = !id; + + const hub = await client.hubs.get(argv.hubId); + + const storedWebhooks = await paginator(hub.related.webhooks.list); + + const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; + const webhooksToDelete = filterById(storedWebhooks, idArray, true); + + const log = logFile.open(); + + if (webhooksToDelete.length === 0) { + nothingToDeleteExit(log, 'No webhooks to delete from this hub, exiting.'); + return; + } + + if (!force) { + const yes = await asyncQuestion( + allWebhooks + ? `Providing no ID/s will delete ALL webhooks! Are you sure you want to do this? (Y/n)\n` + : `${webhooksToDelete.length} webhook/s will be deleted. Would you like to continue? (Y/n)\n` + ); + if (!yes) { + return; + } + } + + log.addComment(`Deleting ${webhooksToDelete.length} webhook/s.`); + + await processWebhooks(webhooksToDelete, log); + + log.appendLine(`Finished successfully deleting ${webhooksToDelete.length} webhook/s`); + + await log.close(); +}; diff --git a/src/interfaces/delete-webhook-builder-options.ts b/src/interfaces/delete-webhook-builder-options.ts new file mode 100644 index 00000000..9bdf3881 --- /dev/null +++ b/src/interfaces/delete-webhook-builder-options.ts @@ -0,0 +1,6 @@ +import { FileLog } from '../common/file-log'; + +export interface DeleteWebhookBuilderOptions { + logFile: FileLog; + force?: boolean; +} From f71557c1b034accec91044cc7d9da3c7fff526f3 Mon Sep 17 00:00:00 2001 From: DB Date: Mon, 10 Nov 2025 12:35:55 +0000 Subject: [PATCH 11/17] refactor: optimisations --- package-lock.json | 22 +++++++++-- package.json | 2 +- src/commands/webhook/delete.spec.ts | 25 +++++------- src/commands/webhook/delete.ts | 38 ++++++++++--------- src/commands/webhook/export.spec.ts | 16 ++------ src/commands/webhook/export.ts | 22 +++++++---- src/commands/webhook/import.ts | 11 ++---- src/common/filter/filter.ts | 25 ------------ src/common/webhooks/get-all-webhook.ts | 6 +++ src/common/webhooks/get-webhooks-by-ids.ts | 15 ++++++++ .../export-item-builder-options.interface.ts | 1 - .../export-webhook-builder-options.ts | 9 +++++ .../import-webhook-builder-options.ts | 9 +++++ src/interfaces/sdk-model-base.ts | 3 -- 14 files changed, 109 insertions(+), 95 deletions(-) create mode 100644 src/common/webhooks/get-all-webhook.ts create mode 100644 src/common/webhooks/get-webhooks-by-ids.ts create mode 100644 src/interfaces/export-webhook-builder-options.ts create mode 100644 src/interfaces/import-webhook-builder-options.ts delete mode 100644 src/interfaces/sdk-model-base.ts diff --git a/package-lock.json b/package-lock.json index 7dd51dc1..21532ebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "bottleneck": "2.19.5", "chalk": "2.4.2", "cli-progress": "3.12.0", - "dc-management-sdk-js": "3.1.0", + "dc-management-sdk-js": "^3.2.0", "enquirer": "2.3.6", "fs-extra": "10.1.0", "graceful-fs": "4.2.11", @@ -124,6 +124,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -939,6 +940,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3902,6 +3904,7 @@ "integrity": "sha512-LEwC7o1ifqg/6r2gn9Dns0f1rhK+fPFDoMiceTJ6kWmVk6bgXBI/9IOWfVan4WiAavK9pIVWdX0/e3J+eEUh5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -4004,6 +4007,7 @@ "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.24.1", "@typescript-eslint/types": "8.24.1", @@ -4639,6 +4643,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4806,6 +4811,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -5015,6 +5021,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", @@ -5584,9 +5591,9 @@ } }, "node_modules/dc-management-sdk-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-3.1.0.tgz", - "integrity": "sha512-UP0C0V9b6f2Te8DiJBWmPIOFpSIwjouf7uIGWkbwK0/AF5EghFYeDnF7JYV1z1mpAINntNkjJ+QXIhzuaLo2/A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/dc-management-sdk-js/-/dc-management-sdk-js-3.2.0.tgz", + "integrity": "sha512-S8aoObfEYlTtOvoo1Gt7Jt3ezNc4EDoK1t7fMPpk38XGuM8w12hSuKo6QhUpJZrV2agzbsyli2G9a4NgIsuT8g==", "license": "Apache-2.0", "dependencies": { "axios": "1.12.2", @@ -5935,6 +5942,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5996,6 +6004,7 @@ "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "build/bin/cli.js" }, @@ -7989,6 +7998,7 @@ "integrity": "sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.0.5", "@jest/types": "30.0.5", @@ -13048,6 +13058,7 @@ "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14116,6 +14127,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14264,6 +14276,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14369,6 +14382,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index e91aa086..72d1797a 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "bottleneck": "2.19.5", "chalk": "2.4.2", "cli-progress": "3.12.0", - "dc-management-sdk-js": "3.1.0", + "dc-management-sdk-js": "^3.2.0", "enquirer": "2.3.6", "fs-extra": "10.1.0", "graceful-fs": "4.2.11", diff --git a/src/commands/webhook/delete.spec.ts b/src/commands/webhook/delete.spec.ts index 8a7ced1c..eb9f9381 100644 --- a/src/commands/webhook/delete.spec.ts +++ b/src/commands/webhook/delete.spec.ts @@ -7,7 +7,6 @@ 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 * as questionHelpers from '../../common/question-helpers'; -import { filterById } from '../../common/filter/filter'; jest.mock('../../services/dynamic-content-client-factory'); jest.mock('../../common/log-helpers'); @@ -70,17 +69,18 @@ describe('delete webhooks', () => { let mockGetHub: jest.Mock; let mockList: jest.Mock; - - const webhookIdsToDelete = (id: unknown) => (id ? (Array.isArray(id) ? id : [id]) : []); + let mockGet: jest.Mock; beforeEach((): void => { const listResponse = new MockPage(Webhook, webhooksToDelete); mockList = jest.fn().mockResolvedValue(listResponse); + mockGet = jest.fn().mockResolvedValue(webhooksToDelete[1]); mockGetHub = jest.fn().mockResolvedValue({ related: { webhooks: { - list: mockList + list: mockList, + get: mockGet } } }); @@ -91,7 +91,7 @@ describe('delete webhooks', () => { } }); - jest.spyOn(deleteModule, 'processWebhooks').mockResolvedValue(); + jest.spyOn(deleteModule, 'processWebhooks').mockResolvedValue({ failedWebhooks: [] }); }); it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function () { @@ -100,10 +100,7 @@ describe('delete webhooks', () => { }); it('should delete all webhooks in a hub', async (): Promise => { - const id: string[] | undefined = undefined; - const argv = { ...yargArgs, ...config, id, logFile: new FileLog() }; - - const filteredWebhooksToDelete = filterById(webhooksToDelete, webhookIdsToDelete(id)); + const argv = { ...yargArgs, ...config, logFile: new FileLog() }; jest.spyOn(deleteModule, 'handler'); @@ -115,10 +112,10 @@ describe('delete webhooks', () => { expect(mockList).toHaveBeenCalledTimes(1); expect(mockList).toHaveBeenCalledWith({ size: 100 }); - expect(deleteModule.processWebhooks).toHaveBeenCalledWith(filteredWebhooksToDelete, argv.logFile); + expect(deleteModule.processWebhooks).toHaveBeenCalledWith(webhooksToDelete, argv.logFile); }); - it('should delete an webhook by id', async (): Promise => { + it('should delete a webhook by id', async (): Promise => { const id: string[] | undefined = ['webhook-id-2']; const argv = { ...yargArgs, @@ -127,8 +124,6 @@ describe('delete webhooks', () => { logFile: new FileLog() }; - const filteredWebhooksToDelete = filterById(webhooksToDelete, webhookIdsToDelete(id)); - jest.spyOn(deleteModule, 'handler'); (questionHelpers.asyncQuestion as jest.Mock).mockResolvedValue(true); @@ -136,9 +131,9 @@ describe('delete webhooks', () => { await handler(argv); expect(mockGetHub).toHaveBeenCalledWith('hub-id'); - expect(mockList).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledTimes(1); - expect(deleteModule.processWebhooks).toHaveBeenCalledWith(filteredWebhooksToDelete, argv.logFile); + expect(deleteModule.processWebhooks).toHaveBeenCalledWith([webhooksToDelete[1]], argv.logFile); }); }); }); diff --git a/src/commands/webhook/delete.ts b/src/commands/webhook/delete.ts index 4c3b9e94..25490d8b 100644 --- a/src/commands/webhook/delete.ts +++ b/src/commands/webhook/delete.ts @@ -3,13 +3,13 @@ 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 { Webhook } from 'dc-management-sdk-js'; import { asyncQuestion } from '../../common/question-helpers'; import { progressBar } from '../../common/progress-bar/progress-bar'; -import { filterById } from '../../common/filter/filter'; import { DeleteWebhookBuilderOptions } from '../../interfaces/delete-webhook-builder-options'; +import { getWebhooksByIds } from '../../common/webhooks/get-webhooks-by-ids'; +import { getAllWebhooks } from '../../common/webhooks/get-all-webhook'; export const command = 'delete [id]'; @@ -41,21 +41,23 @@ export const builder = (yargs: Argv): void => { }); }; -export const processWebhooks = async (webhooksToDelete: Webhook[], log: FileLog): Promise => { +export const processWebhooks = async ( + webhooksToDelete: Webhook[], + log: FileLog +): Promise<{ failedWebhooks: Webhook[] }> => { const failedWebhooks: Webhook[] = []; const progress = progressBar(webhooksToDelete.length, 0, { title: `Deleting ${webhooksToDelete.length} webhook/s.` }); - for (const [i, webhook] of webhooksToDelete.entries()) { + for (const webhook of webhooksToDelete) { try { await webhook.related.delete(); log.addComment(`Successfully deleted "${webhook.label}"`); progress.increment(); } catch (e) { failedWebhooks.push(webhook); - webhooksToDelete.splice(i, 1); log.addComment(`Failed to delete ${webhook.label}: ${e.toString()}`); progress.increment(); } @@ -63,28 +65,24 @@ export const processWebhooks = async (webhooksToDelete: Webhook[], log: FileLog) progress.stop(); - if (failedWebhooks.length > 0) { - log.appendLine(`Failed to delete ${failedWebhooks.length} webhooks`); - } + return { failedWebhooks }; }; export const handler = async ( argv: Arguments ): Promise => { const { id, logFile, force } = argv; - + const log = logFile.open(); const client = dynamicContentClientFactory(argv); - const allWebhooks = !id; - const hub = await client.hubs.get(argv.hubId); + let ids: string[] = []; - const storedWebhooks = await paginator(hub.related.webhooks.list); - - const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; - const webhooksToDelete = filterById(storedWebhooks, idArray, true); + if (id) { + ids = Array.isArray(id) ? id : [id]; + } - const log = logFile.open(); + const webhooksToDelete = ids.length > 0 ? await getWebhooksByIds(hub, ids) : await getAllWebhooks(hub); if (webhooksToDelete.length === 0) { nothingToDeleteExit(log, 'No webhooks to delete from this hub, exiting.'); @@ -104,9 +102,13 @@ export const handler = async ( log.addComment(`Deleting ${webhooksToDelete.length} webhook/s.`); - await processWebhooks(webhooksToDelete, log); + const { failedWebhooks } = await processWebhooks(webhooksToDelete, log); + + const failedWebhooksMessage = failedWebhooks.length + ? `with ${failedWebhooks.length} failed webhooks - check logs for details` + : ``; - log.appendLine(`Finished successfully deleting ${webhooksToDelete.length} webhook/s`); + log.appendLine(`Webhooks delete complete ${failedWebhooksMessage}`); await log.close(); }; diff --git a/src/commands/webhook/export.spec.ts b/src/commands/webhook/export.spec.ts index f54c126f..db2f4aed 100644 --- a/src/commands/webhook/export.spec.ts +++ b/src/commands/webhook/export.spec.ts @@ -8,7 +8,6 @@ import rmdir from 'rimraf'; import { FileLog } from '../../common/file-log'; import { Webhook } from 'dc-management-sdk-js'; import MockPage from '../../common/dc-management-sdk-js/mock-page'; -import { filterById } from '../../common/filter/filter'; import { existsSync } from 'fs'; jest.mock('readline'); @@ -106,8 +105,6 @@ describe('webhook export command', () => { let mockGetHub: jest.Mock; let mockList: jest.Mock; - const webhookIdsToExport = (id: unknown) => (id ? (Array.isArray(id) ? id : [id]) : []); - beforeEach((): void => { const listResponse = new MockPage(Webhook, webhooksToExport); mockList = jest.fn().mockResolvedValue(listResponse); @@ -141,13 +138,6 @@ describe('webhook export command', () => { dir: `temp_${process.env.JEST_WORKER_ID}/export/` }; - const filteredWebhooksToExport = filterById( - webhooksToExport, - webhookIdsToExport(id), - undefined, - 'webhook' - ); - jest.spyOn(exportWebhooksModule, 'handler'); await handler(argv); @@ -156,11 +146,11 @@ describe('webhook export command', () => { expect(mockList).toHaveBeenCalledWith({ size: 100 }); const spy = jest.spyOn(exportWebhooksModule, 'exportWebhooks'); - await exportWebhooksModule.exportWebhooks(filteredWebhooksToExport, argv.dir, argv.logFile); + await exportWebhooksModule.exportWebhooks(webhooksToExport, argv.dir, argv.logFile); - expect(spy).toHaveBeenCalledWith(filteredWebhooksToExport, argv.dir, argv.logFile); + expect(spy).toHaveBeenCalledWith(webhooksToExport, argv.dir, argv.logFile); - filteredWebhooksToExport.forEach(webhook => { + webhooksToExport.forEach(webhook => { const path = `temp_${process.env.JEST_WORKER_ID}/export/exported_webhooks/${webhook.label}.json`; expect(existsSync(path)).toBe(true); diff --git a/src/commands/webhook/export.ts b/src/commands/webhook/export.ts index 4ca188fe..781d6918 100644 --- a/src/commands/webhook/export.ts +++ b/src/commands/webhook/export.ts @@ -2,10 +2,7 @@ import { Arguments, Argv } from 'yargs'; import { ConfigurationParameters } from '../configure'; import dynamicContentClientFactory from '../../services/dynamic-content-client-factory'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; -import { ExportBuilderOptions } from '../../interfaces/export-builder-options.interface'; import { nothingExportedExit, uniqueFilenamePath, writeJsonToFile } from '../../services/export.service'; -import paginator from '../../common/dc-management-sdk-js/paginator'; -import { filterById } from '../../common/filter/filter'; import { Webhook } from 'dc-management-sdk-js'; import { FileLog } from '../../common/file-log'; import { join } from 'path'; @@ -13,6 +10,9 @@ import sanitize from 'sanitize-filename'; import { ensureDirectoryExists } from '../../common/import/directory-utils'; import { progressBar } from '../../common/progress-bar/progress-bar'; import { confirmAllContent } from '../../common/content-item/confirm-all-content'; +import { getWebhooksByIds } from '../../common/webhooks/get-webhooks-by-ids'; +import { getAllWebhooks } from '../../common/webhooks/get-all-webhook'; +import { ExportWebhookBuilderOptions } from '../../interfaces/export-webhook-builder-options'; export const command = 'export '; @@ -85,15 +85,21 @@ export const exportWebhooks = async (webhooks: Webhook[], dir: string, log: File progress.stop(); }; -export const handler = async (argv: Arguments): Promise => { +export const handler = async ( + argv: Arguments +): Promise => { const { id, logFile, dir, force, silent } = argv; + const log = logFile.open(); const client = dynamicContentClientFactory(argv); const allWebhooks = !id; const hub = await client.hubs.get(argv.hubId); - const webhooks = await paginator(hub.related.webhooks.list); - const idArray: string[] = id ? (Array.isArray(id) ? id : [id]) : []; - const webhooksToExport = filterById(webhooks, idArray, undefined, 'webhooks'); - const log = logFile.open(); + let ids: string[] = []; + + if (id) { + ids = Array.isArray(id) ? id : [id]; + } + + const webhooksToExport = ids.length > 0 ? await getWebhooksByIds(hub, ids) : await getAllWebhooks(hub); if (!webhooksToExport.length) { nothingExportedExit(log, 'No webhooks to export from this hub, exiting.'); diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index 1b0ab7c5..045e93ca 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -12,6 +12,7 @@ import { createLog, getDefaultLogPath } from '../../common/log-helpers'; import { asyncQuestion } from '../../common/question-helpers'; import { progressBar } from '../../common/progress-bar/progress-bar'; import PublishOptions from '../../common/publish/publish-options'; +import { ImportWebhookBuilderOptions } from '../../interfaces/import-webhook-builder-options'; export function getDefaultMappingPath(name: string, platform: string = process.platform): string { return join( @@ -224,19 +225,15 @@ export const importWebhooks = async ( }; export const handler = async ( - argv: Arguments + argv: Arguments ): Promise => { - const { dir, logFile, force, silent } = argv; - let { mapFile } = argv; + const { dir, logFile, force, silent, mapFile: mapFileArg } = argv; const client = dynamicContentClientFactory(argv); const log = logFile.open(); const hub: Hub = await client.hubs.get(argv.hubId); const importTitle = `hub-${hub.id}`; - const mapping = new ContentMapping(); - if (mapFile == null) { - mapFile = getDefaultMappingPath(importTitle); - } + const mapFile = mapFileArg ? mapFileArg : getDefaultMappingPath(importTitle); if (await mapping.load(mapFile)) { log.appendLine(`Existing mapping loaded from '${mapFile}', changes will be saved back to it.`); diff --git a/src/common/filter/filter.ts b/src/common/filter/filter.ts index 5ef02838..872aeafa 100644 --- a/src/common/filter/filter.ts +++ b/src/common/filter/filter.ts @@ -1,5 +1,3 @@ -import { Id } from '../../interfaces/sdk-model-base'; - export function equalsOrRegex(value: string, compare: string): boolean { if (compare.length > 1 && compare[0] === '/' && compare[compare.length - 1] === '/') { // Regex format, try parse as a regex and return if the value is a match. @@ -13,26 +11,3 @@ export function equalsOrRegex(value: string, compare: string): boolean { } return value === compare; } - -export const filterById = ( - listToFilter: T[], - uriList: string[], - deleted: boolean = false, - typeName: string = '' -): T[] => { - if (uriList.length === 0) { - return listToFilter; - } - - const unmatchedUriList: string[] = uriList.filter(id => !listToFilter.some(type => type.id === id)); - - if (unmatchedUriList.length > 0) { - throw new Error( - `The following ${typeName} URI(s) could not be found: [${unmatchedUriList - .map(u => `'${u}'`) - .join(', ')}].\nNothing was ${!deleted ? 'exported' : 'deleted'}, exiting.` - ); - } - - return listToFilter.filter(type => uriList.some(id => type.id === id)); -}; diff --git a/src/common/webhooks/get-all-webhook.ts b/src/common/webhooks/get-all-webhook.ts new file mode 100644 index 00000000..0a882817 --- /dev/null +++ b/src/common/webhooks/get-all-webhook.ts @@ -0,0 +1,6 @@ +import { Hub } from 'dc-management-sdk-js'; +import paginator from '../dc-management-sdk-js/paginator'; + +export const getAllWebhooks = async (hub: Hub) => { + return await paginator(hub.related.webhooks.list); +}; diff --git a/src/common/webhooks/get-webhooks-by-ids.ts b/src/common/webhooks/get-webhooks-by-ids.ts new file mode 100644 index 00000000..0d268ffe --- /dev/null +++ b/src/common/webhooks/get-webhooks-by-ids.ts @@ -0,0 +1,15 @@ +import { Hub } from 'dc-management-sdk-js'; + +export const getWebhooksByIds = async (hub: Hub, ids: string[]) => { + const webhooks = []; + + for (const id of ids) { + try { + webhooks.push(await hub.related.webhooks.get(id)); + } catch (e) { + // silently fail missing webhooks + } + } + + return webhooks; +}; diff --git a/src/interfaces/export-item-builder-options.interface.ts b/src/interfaces/export-item-builder-options.interface.ts index bddae8e5..a6d767e7 100644 --- a/src/interfaces/export-item-builder-options.interface.ts +++ b/src/interfaces/export-item-builder-options.interface.ts @@ -7,6 +7,5 @@ export interface ExportItemBuilderOptions { facet?: string; logFile: FileLog; publish?: boolean; - exportedIds?: string[]; } diff --git a/src/interfaces/export-webhook-builder-options.ts b/src/interfaces/export-webhook-builder-options.ts new file mode 100644 index 00000000..3e130237 --- /dev/null +++ b/src/interfaces/export-webhook-builder-options.ts @@ -0,0 +1,9 @@ +import { FileLog } from '../common/file-log'; + +export interface ExportWebhookBuilderOptions { + dir: string; + id?: string | string[]; + logFile: FileLog; + force?: boolean; + silent?: boolean; +} diff --git a/src/interfaces/import-webhook-builder-options.ts b/src/interfaces/import-webhook-builder-options.ts new file mode 100644 index 00000000..b8bf0db3 --- /dev/null +++ b/src/interfaces/import-webhook-builder-options.ts @@ -0,0 +1,9 @@ +import { FileLog } from '../common/file-log'; + +export interface ImportWebhookBuilderOptions { + dir: string; + logFile: FileLog; + force?: boolean; + silent?: boolean; + mapFile?: string; +} diff --git a/src/interfaces/sdk-model-base.ts b/src/interfaces/sdk-model-base.ts deleted file mode 100644 index e70e3a28..00000000 --- a/src/interfaces/sdk-model-base.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Id { - id?: string; -} From 3e5a55175727254505ec07b775cf294bcd834ffe Mon Sep 17 00:00:00 2001 From: DB Date: Mon, 10 Nov 2025 12:39:47 +0000 Subject: [PATCH 12/17] fix: fix sdk version --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21532ebd..28147549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "bottleneck": "2.19.5", "chalk": "2.4.2", "cli-progress": "3.12.0", - "dc-management-sdk-js": "^3.2.0", + "dc-management-sdk-js": "3.2.0", "enquirer": "2.3.6", "fs-extra": "10.1.0", "graceful-fs": "4.2.11", diff --git a/package.json b/package.json index 72d1797a..dd4ccf72 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "bottleneck": "2.19.5", "chalk": "2.4.2", "cli-progress": "3.12.0", - "dc-management-sdk-js": "^3.2.0", + "dc-management-sdk-js": "3.2.0", "enquirer": "2.3.6", "fs-extra": "10.1.0", "graceful-fs": "4.2.11", From c26b74ba0f2a3318924ffcbed69bea75e9e04422 Mon Sep 17 00:00:00 2001 From: DB Date: Mon, 10 Nov 2025 15:18:38 +0000 Subject: [PATCH 13/17] fix: revert back, make non-optional --- src/commands/webhook/import.ts | 3 +-- src/interfaces/import-item-builder-options.interface.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/webhook/import.ts b/src/commands/webhook/import.ts index 045e93ca..11cdeda5 100644 --- a/src/commands/webhook/import.ts +++ b/src/commands/webhook/import.ts @@ -5,7 +5,6 @@ import { FileLog } from '../../common/file-log'; import { join, extname } from 'path'; import { readdir, readFile } from 'graceful-fs'; import { promisify } from 'util'; -import { ImportItemBuilderOptions } from '../../interfaces/import-item-builder-options.interface'; import { Hub, Webhook } from 'dc-management-sdk-js'; import { ContentMapping } from '../../common/content-mapping'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; @@ -109,7 +108,7 @@ export const prepareWebhooksForImport = async ( webhookFiles: string[], mapping: ContentMapping, log: FileLog, - argv: Arguments + argv: Arguments ): Promise => { const { force } = argv; diff --git a/src/interfaces/import-item-builder-options.interface.ts b/src/interfaces/import-item-builder-options.interface.ts index bb902e05..f9cbdafd 100644 --- a/src/interfaces/import-item-builder-options.interface.ts +++ b/src/interfaces/import-item-builder-options.interface.ts @@ -13,6 +13,6 @@ export interface ImportItemBuilderOptions { excludeKeys?: boolean; media?: boolean; logFile: FileLog; - revertLog?: Promise; + revertLog: Promise; ignoreSchemaValidation?: boolean; } From 35b7f1a72b774eac79c72453875814fc79c97f4b Mon Sep 17 00:00:00 2001 From: DB Date: Tue, 11 Nov 2025 10:35:37 +0000 Subject: [PATCH 14/17] refactor: update confirm message --- src/commands/extension/delete.ts | 5 +++-- src/commands/webhook/delete.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/extension/delete.ts b/src/commands/extension/delete.ts index f07304e1..7ce147eb 100644 --- a/src/commands/extension/delete.ts +++ b/src/commands/extension/delete.ts @@ -92,10 +92,11 @@ export const handler = async ( } if (!force) { + const baseMessage = 'This action cannot be undone. Are you sure you want to continue? (Y/n)\n'; 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` + ? `Providing no ID/s will permanently delete ALL extensions! ${baseMessage}` + : `${extensionsToDelete.length} extensions will be permanently deleted. ${baseMessage}` ); if (!yes) { return; diff --git a/src/commands/webhook/delete.ts b/src/commands/webhook/delete.ts index 25490d8b..86569bba 100644 --- a/src/commands/webhook/delete.ts +++ b/src/commands/webhook/delete.ts @@ -90,10 +90,11 @@ export const handler = async ( } if (!force) { + const baseMessage = 'This action cannot be undone. Are you sure you want to continue? (Y/n)\n'; const yes = await asyncQuestion( allWebhooks - ? `Providing no ID/s will delete ALL webhooks! Are you sure you want to do this? (Y/n)\n` - : `${webhooksToDelete.length} webhook/s will be deleted. Would you like to continue? (Y/n)\n` + ? `Providing no ID/s will permanently delete ALL webhooks! ${baseMessage}` + : `${webhooksToDelete.length} webhook/s will be permanently deleted. ${baseMessage}` ); if (!yes) { return; From 6a7bcac2293dffb101827224bb5c29e9d7477119 Mon Sep 17 00:00:00 2001 From: DB Date: Wed, 12 Nov 2025 12:40:53 +0000 Subject: [PATCH 15/17] docs: update docs --- docs/WEBHOOK.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/WEBHOOK.md b/docs/WEBHOOK.md index e45df033..2fe4ec75 100644 --- a/docs/WEBHOOK.md +++ b/docs/WEBHOOK.md @@ -47,10 +47,12 @@ dc-cli webhook export #### Options -| Option Name | Type | Description | -| ----------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| --id | [string] | The ID of the webhook to be exported.
If no --id option is given, all webhooks for the hub are exported.
A single --id option may be given to export a single webhook.
Multiple --id options may be given to delete multiple webhooks at the same time. | -| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | +| Option Name | Type | Description | +| ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --id | [string] | The ID of the webhook to be exported.
If no --id option is given, all webhooks for the hub are exported.
A single --id option may be given to export a single webhook.
Multiple --id options may be given to export multiple webhooks at the same time. | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | +| -s
--silent | [boolean] | If present, no log file will be produced. | +| -f
--force | [boolean] | Export webhooks without asking. | #### Examples @@ -117,10 +119,11 @@ dc-cli webhook delete #### Options -| Option Name | Type | Description | -| --------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| --id | [string] | The ID of an Webhook to be deleted.
If no --id option is given, all webhooks for the hub are deleted.
A single --id option may be given to delete a single webhook.
Multiple --id options may be given to delete multiple webhooks at the same time. | -| -f
--force | [boolean] | Delete webhooks without asking. | +| Option Name | Type | Description | +| --------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --id | [string] | The ID of an Webhook to be deleted.
If no --id option is given, all webhooks for the hub are deleted.
A single --id option may be given to delete a single webhook.
Multiple --id options may be given to delete multiple webhooks at the same time. | +| -f
--force | [boolean] | Delete webhooks without asking. | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | #### Examples From 6c5abfd39c0d5f92a236776edbed9fcd6e546512 Mon Sep 17 00:00:00 2001 From: DB Date: Wed, 12 Nov 2025 16:01:38 +0000 Subject: [PATCH 16/17] docs: update --- docs/EXTENSION.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/EXTENSION.md b/docs/EXTENSION.md index 1f4b8707..d1c690c5 100644 --- a/docs/EXTENSION.md +++ b/docs/EXTENSION.md @@ -44,10 +44,11 @@ dc-cli extension export #### Options -| 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. | +| 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. | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | #### Examples @@ -87,10 +88,11 @@ 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. | +| 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. | +| --logFile | [string]
[default: (generated-value)] | Path to a log file to write to. | #### Examples From 1a0f1de958801b85a2f25b121a7e91ac9d61e79d Mon Sep 17 00:00:00 2001 From: DB Date: Fri, 14 Nov 2025 10:57:56 +0000 Subject: [PATCH 17/17] docs: main readme --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c3f96dc3..1f1db80b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Run `dc-cli --help` to get a list of available commands. - [hub](#hub) - [job](#job) - [linked-content-repository](#linked-content-repository) + - [webhook](#webhook) - [Building the CLI](#building-the-cli) - [Required permissions](#required-permissions) @@ -137,7 +138,7 @@ Before importing, copying, or moving content you must ensure that a valid [conte ### extension -This category includes interactions with Dynamic Content's UI Extensions, and can be used to export and import extensions from an individual hub. +This category includes interactions with Dynamic Content's UI Extensions, and can be used to export, import and delete extensions from an individual hub. [View commands for **extension**](docs/EXTENSION.md) @@ -191,6 +192,12 @@ These commands can be used to list multiple linked content repository. [View commands for **linked-content-repository**](docs/LINKED-CONTENT-REPOSITORY.md) +### webhook + +This category includes interactions with Dynamic Content's UI Webhooks, and can be used to export, import and delete webhooks from an individual hub. + +[View commands for **webhook**](docs/WEBHOOK.md) + ## Building the CLI We have included some NPM scripts to help create various installations of the CLI.