From 8827f5735f457edbaaa22b2f3048559b3f1be74f Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Sat, 30 May 2026 15:00:29 +0530 Subject: [PATCH 1/2] fix: minor fixes in bulk delete and move ops --- .../src/base-bulk-command.ts | 26 ++++-- .../src/commands/cm/stacks/bulk-am-assets.ts | 55 +++++++------ .../src/interfaces/index.ts | 1 + .../src/messages/index.ts | 19 ++++- .../src/utils/index.ts | 3 +- .../src/utils/interactive.ts | 79 +++++++++++++++++++ 6 files changed, 150 insertions(+), 33 deletions(-) diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index e5f0bc62c..db3e8debb 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -28,6 +28,7 @@ import { buildBulkModeResult, handleOperationError, fillMissingFlags, + fillMissingAmFlags, getLogPaths, clearLogs, generateBulkPublishStatusUrl, @@ -51,7 +52,7 @@ import { * Provides common functionality for bulk-entries and bulk-assets */ export abstract class BaseBulkCommand extends Command { - protected abstract resourceType: ResourceType; + protected resourceType?: ResourceType; // Common flags for all bulk operations static baseFlags: FlagInput = { @@ -145,6 +146,15 @@ export abstract class BaseBulkCommand extends Command { this.parsedFlags = flags; + // AM assets uses a different API surface — prompt for AM-specific flags and skip + // the publish/unpublish stack setup, queue init, and config build. + if (this.resourceType === ResourceType.AM_ASSET) { + this.logger = log; + this.loggerContext = { module: this.id }; + this.parsedFlags = await fillMissingAmFlags(flags); + return; + } + const commandName = `cm:stacks:bulk-${this.resourceType === ResourceType.ENTRY ? 'entries' : 'assets'}`; createLogContext( this.context?.info?.command || commandName, @@ -177,7 +187,7 @@ export abstract class BaseBulkCommand extends Command { await this.setupStack(); await this.initializeComponents(); - this.logger.debug($t(messages.INITIALIZING, { resourceType: this.resourceType }), this.loggerContext); + this.logger.debug($t(messages.INITIALIZING, { resourceType: this.resourceType! }), this.loggerContext); } /** @@ -189,7 +199,7 @@ export abstract class BaseBulkCommand extends Command { const isRetry = !!flags['retry-failed']; // Load config from log file - const logFileConfig = loadConfigFromLogFile(logPath, isRetry, this.resourceType); + const logFileConfig = loadConfigFromLogFile(logPath, isRetry, this.resourceType!); if (!logFileConfig) { throw new Error($t(messages.NO_CONFIG_IN_LOG)); @@ -334,7 +344,7 @@ export abstract class BaseBulkCommand extends Command { batchResults: this.batchResults, logger: this.logger, retryStrategy: this.retryStrategy, - resourceType: this.resourceType, + resourceType: this.resourceType!, logFolderPath: this.bulkOperationConfig.bulkOperationFolder, apiKey: this.bulkOperationConfig.apiKey || this.bulkOperationConfig.stackApiKey, branch: this.bulkOperationConfig.branch, @@ -352,7 +362,7 @@ export abstract class BaseBulkCommand extends Command { const flags = this.parsedFlags || (await this.parse(this.constructor as typeof BaseBulkCommand)).flags; const itemCount = items?.length || 0; - return await confirmOperationUtil(this.bulkOperationConfig, itemCount, this.resourceType, flags.yes); + return await confirmOperationUtil(this.bulkOperationConfig, itemCount, this.resourceType!, flags.yes); } /** @@ -493,7 +503,7 @@ export abstract class BaseBulkCommand extends Command { const result = await handleRevertOrRetry( logPath, isRetry, - this.resourceType, + this.resourceType!, this.bulkOperationConfig, flags.yes, this.executeBulkOperation.bind(this), @@ -518,14 +528,14 @@ export abstract class BaseBulkCommand extends Command { targetEnvs: flags.environments as string[], locales: flags.locales as string[], contentTypes: flags['content-types'] as string[] | undefined, - resourceType: this.resourceType, + resourceType: this.resourceType!, deliveryStack: this.deliveryStack!, // Required: initialized via source-alias delivery token }, this.logger ); if (itemsToPublish.length === 0) { - this.logger.warn($t(messages.NO_ITEMS_FOUND, { resourceType: this.resourceType }), this.loggerContext); + this.logger.warn($t(messages.NO_ITEMS_FOUND, { resourceType: this.resourceType! }), this.loggerContext); return; } diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts index f1b0a7bf2..bde0f65f8 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts @@ -1,15 +1,15 @@ import chalk from 'chalk'; -import { Command } from '@contentstack/cli-command'; -import { flags, log, createLogContext, handleAndLogError, cliux, FlagInput } from '@contentstack/cli-utilities'; +import { flags, log, createLogContext, cliux, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; import messages, { $t } from '../../../messages'; +import { BaseBulkCommand } from '../../../base-bulk-command'; import { AmAssetService } from '../../../services'; import { loadAssetUidsFromFile, loadBulkDeleteItemsFromFile, LoadAssetUidsError, } from '../../../utils/asset-uids-from-file'; -import { AmBulkDeleteItem } from '../../../interfaces'; +import { AmBulkDeleteItem, ResourceType } from '../../../interfaces'; const COMMAND_ID = 'cm:stacks:bulk-am-assets'; @@ -18,7 +18,7 @@ type RegionWithOptionalAmUrl = { csAssetsUrl?: string }; /** * AM bulk delete (job) / bulk move — CS Assets API only; asset UIDs come from a JSON file `{ "uids": [...] }`. */ -export default class BulkAmAssets extends Command { +export default class BulkAmAssets extends BaseBulkCommand { static description = messages.BULK_AM_ASSETS_DESCRIPTION; static examples = [ @@ -31,15 +31,12 @@ export default class BulkAmAssets extends Command { operation: flags.string({ description: messages.AM_OPERATION_FLAG, options: ['delete', 'move'], - required: true, }), 'space-uid': flags.string({ description: messages.AM_SPACE_UID_FLAG, - required: true, }), 'org-uid': flags.string({ description: messages.AM_ORG_UID_FLAG, - required: true, }), workspace: flags.string({ default: 'main', @@ -47,7 +44,6 @@ export default class BulkAmAssets extends Command { }), 'asset-uids-file': flags.string({ description: messages.AM_ASSET_UIDS_FILE_FLAG, - required: true, }), locale: flags.string({ description: messages.AM_LOCALE_FLAG, @@ -62,7 +58,24 @@ export default class BulkAmAssets extends Command { }), }; - private readonly loggerContext = { module: COMMAND_ID }; + protected resourceType = ResourceType.AM_ASSET; + + private printAmSummary(op: 'delete' | 'move', opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string }): void { + if (opts.error) { + log.error($t(messages.AM_OPERATION_FAILED, { operation: op }), this.loggerContext); + log.error(opts.error, this.loggerContext); + } else if (op === 'delete') { + log.success($t(messages.AM_DELETE_SUCCESS), this.loggerContext); + if (opts.jobId) log.info($t(messages.AM_DELETE_JOB_ID, { jobId: opts.jobId }), this.loggerContext); + log.info($t(messages.AM_DELETE_ASYNC_NOTE), this.loggerContext); + } else { + log.success($t(messages.AM_MOVE_SUCCESS), this.loggerContext); + if (opts.count !== undefined && opts.folderUid) { + log.info($t(messages.AM_MOVE_ASSETS_COUNT, { count: opts.count, folderUid: opts.folderUid }), this.loggerContext); + } + } + if (opts.notice) log.info(opts.notice, this.loggerContext); + } private handleAssetUidsFileError(e: LoadAssetUidsError): void { const pathShown = e.filePath; @@ -79,7 +92,7 @@ export default class BulkAmAssets extends Command { async run(): Promise { try { - const { flags: f } = await this.parse(BulkAmAssets); + const f = this.parsedFlags; const amBaseUrl = (this.region as RegionWithOptionalAmUrl).csAssetsUrl?.trim(); if (!amBaseUrl) { @@ -166,16 +179,17 @@ export default class BulkAmAssets extends Command { log.info($t(messages.AM_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), this.loggerContext); const result = await amService.bulkDelete(spaceUid, workspace, deleteRows); if (!result.success) { - log.error(result.error ?? 'AM bulk delete failed', this.loggerContext); + this.printAmSummary('delete', { error: result.error ?? 'AM bulk delete failed' }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - if (result.jobId) { - log.info($t(messages.AM_DELETE_SUBMITTED, { jobId: result.jobId }), this.loggerContext); - } + this.printAmSummary('delete', { jobId: result.jobId, notice: result.notice }); + return; + } + + if (f.locale) { + log.error($t(messages.AM_LOCALE_NOT_ALLOWED_FOR_MOVE), this.loggerContext); + process.exitCode = 1; return; } @@ -231,14 +245,11 @@ export default class BulkAmAssets extends Command { ); const result = await amService.bulkMove(spaceUid, workspace, uids, moveFolderUid); if (!result.success) { - log.error(result.error ?? 'AM bulk move failed', this.loggerContext); + this.printAmSummary('move', { error: result.error ?? 'AM bulk move failed' }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - log.info($t(messages.AM_MOVE_SUBMITTED), this.loggerContext); + this.printAmSummary('move', { count: uids.length, folderUid: moveFolderUid, notice: result.notice }); } catch (error) { handleAndLogError(error); } diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index b5a18fe71..e311f8dcf 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -20,6 +20,7 @@ export enum ResourceType { ENTRY = 'entry', ASSET = 'asset', TAXONOMY = 'taxonomy', + AM_ASSET = 'am-asset', } export enum FilterType { diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index a285f734e..388247738 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -238,10 +238,25 @@ const amBulkAssetsMsg = { AM_WORKSPACE_FLAG: 'AM workspace query parameter (default: main)', AM_ASSET_UIDS_FILE_FLAG: 'Path to UTF-8 JSON file: exactly `{ "uids": ["uid1", "uid2"] }` (non-empty string array, no trimming; large lists: see docs for NODE_OPTIONS)', - AM_LOCALE_FLAG: 'Locale code for bulk delete (single locale per run)', - AM_TARGET_FOLDER_FLAG: 'Destination AM folder UID (required for move)', + AM_LOCALE_FLAG: 'Locale code for bulk delete only (single locale per run). Not applicable for move — move always relocates all locale variants of an asset.', + AM_LOCALE_NOT_ALLOWED_FOR_MOVE: '--locale is not applicable for the move operation. Move always relocates all locale variants of an asset. Remove --locale and try again.', + AM_TARGET_FOLDER_FLAG: 'Destination AM folder UID for bulk move. Use "root" to move assets to the root folder.', AM_INVALID_OPERATION: 'Invalid operation: {operation}. Must be delete or move', AM_CONFIRM_SUMMARY: 'Proceed with AM {operation} on {count} item(s)?', + AM_DELETE_SUCCESS: 'AM bulk delete job submitted successfully!', + AM_DELETE_JOB_ID: 'Job ID: {jobId}', + AM_DELETE_ASYNC_NOTE: 'The job runs asynchronously — check the Asset Management console for status.', + AM_MOVE_SUCCESS: 'AM bulk move completed successfully!', + AM_MOVE_ASSETS_COUNT: '{count} asset(s) moved to folder: {folderUid}', + AM_OPERATION_FAILED: 'AM {operation} failed.', + + // Interactive prompts + AM_SELECT_OPERATION: 'Select AM operation:', + AM_ENTER_SPACE_UID: 'Enter AM space UID:', + AM_ENTER_ORG_UID: 'Enter organization UID:', + AM_ENTER_ASSET_UIDS_FILE: 'Enter path to asset UIDs JSON file (e.g. ./assets.json):', + AM_ENTER_LOCALE: 'Enter locale code for bulk delete (e.g. en-us):', + AM_ENTER_TARGET_FOLDER: 'Enter target folder UID for bulk move (use "root" to move to the root folder):', }; /** diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index 02c334e3e..9f2b6e310 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -35,7 +35,7 @@ import { buildBulkModeResult, handleOperationError, } from './command-helpers'; -import { fillMissingFlags } from './interactive'; +import { fillMissingFlags, fillMissingAmFlags } from './interactive'; import { RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, @@ -98,6 +98,7 @@ export { buildBulkModeResult, handleOperationError, fillMissingFlags, + fillMissingAmFlags, fetchTaxonomyList, RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index 8794efae6..61bb486c5 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -223,3 +223,82 @@ export async function fillMissingFlags(flags: any): Promise { return updatedFlags; } + +/** + * Fills in missing flags for the bulk-am-assets command by prompting the user. + * Handles AM-specific required flags including operation-conditional ones + * (locale for delete, target-folder-uid for move). + */ +export async function fillMissingAmFlags(flags: any): Promise { + const f = { ...flags }; + + const needsLocale = f.operation === 'delete' && !f.locale; + const needsFolderUid = f.operation === 'move' && !f['target-folder-uid']; + const needsPrompt = + !f.operation || !f['space-uid'] || !f['org-uid'] || !f['asset-uids-file'] || needsLocale || needsFolderUid; + + if (!needsPrompt) return f; + + cliux.print(messages.INTERACTIVE_MODE_START, { color: 'cyan' }); + + if (!f.operation) { + f.operation = await cliux.inquire({ + type: 'list', + name: 'operation', + message: messages.AM_SELECT_OPERATION, + choices: [ + { name: 'Delete (AM bulk delete)', value: 'delete' }, + { name: 'Move (AM bulk move)', value: 'move' }, + ], + }); + } + + if (!f['space-uid']) { + f['space-uid'] = await cliux.inquire({ + type: 'input', + name: 'spaceUid', + message: messages.AM_ENTER_SPACE_UID, + validate: (v: string) => (!v?.trim() ? messages.SPACE_UID_REQUIRED : true), + }); + } + + if (!f['org-uid']) { + f['org-uid'] = await cliux.inquire({ + type: 'input', + name: 'orgUid', + message: messages.AM_ENTER_ORG_UID, + validate: (v: string) => (!v?.trim() ? messages.ORG_UID_REQUIRED : true), + }); + } + + if (!f['asset-uids-file']) { + f['asset-uids-file'] = await cliux.inquire({ + type: 'input', + name: 'assetUidsFile', + message: messages.AM_ENTER_ASSET_UIDS_FILE, + validate: (v: string) => (!v?.trim() ? messages.AM_ASSET_UIDS_FILE_REQUIRED : true), + }); + } + + if (f.operation === 'delete' && !f.locale) { + f.locale = await cliux.inquire({ + type: 'input', + name: 'locale', + message: messages.AM_ENTER_LOCALE, + validate: (v: string) => (!v?.trim() ? messages.AM_LOCALE_REQUIRED : true), + }); + } + + if (f.operation === 'move' && !f['target-folder-uid']) { + f['target-folder-uid'] = await cliux.inquire({ + type: 'input', + name: 'targetFolderUid', + message: messages.AM_ENTER_TARGET_FOLDER, + validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true), + }); + } + + cliux.print(messages.INTERACTIVE_MODE_COMPLETE, { color: 'green' }); + + return f; +} From 2108c79f5a8ee18483360b8571c56d785a695f0a Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Sat, 30 May 2026 15:32:47 +0530 Subject: [PATCH 2/2] feat: implement BaseAmCommand and related functionality for asset management operations --- .talismanrc | 8 +- .../src/base-am-command.ts | 29 +++ .../src/base-bulk-command.ts | 31 +-- .../src/commands/cm/stacks/bulk-am-assets.ts | 31 +-- .../src/interfaces/index.ts | 12 + .../src/utils/interactive.ts | 148 +++++++----- .../test/unit/commands/bulk-am-assets.test.ts | 138 +++++++++++ .../test/unit/utils/interactive.test.ts | 222 ++++++++++++++++++ 8 files changed, 509 insertions(+), 110 deletions(-) create mode 100644 packages/contentstack-bulk-operations/src/base-am-command.ts create mode 100644 packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts diff --git a/.talismanrc b/.talismanrc index 3db7ed059..ebd1224b7 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,8 +1,4 @@ fileignoreconfig: -- filename: packages/contentstack-bulk-operations/src/services/am-asset-service.ts - checksum: 5f6c0ecba74e27399a7079ca15e65e77ef692697093c9fb1d57213728c4fe985 -- filename: packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts - checksum: 580932f192dd3fdd8bb2c55b7a7a78f1694f646ef5c5041f86c75668778f7ecb -- filename: packages/contentstack-bulk-operations/test/unit/utils/asset-uids-from-file.test.ts - checksum: 8123f7a675a0275795b59b15d0f2d5f8f1e57ccbecf3f97249a0dc5a037b9203 +- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts + checksum: a26defc1f356308c3f46607e68714bfa2de174e58237cb6b949bd43f2494a818 version: '1.0' diff --git a/packages/contentstack-bulk-operations/src/base-am-command.ts b/packages/contentstack-bulk-operations/src/base-am-command.ts new file mode 100644 index 000000000..67ce99cb9 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/base-am-command.ts @@ -0,0 +1,29 @@ +import { Command } from '@contentstack/cli-command'; +import { handleAndLogError } from '@contentstack/cli-utilities'; + +import { fillMissingAmFlags } from './utils'; +import type { AmAssetFlags } from './interfaces'; + +/** + * Thin base command for Asset Management operations. + * Handles flag prompting in init() and exposes typed parsedFlags / loggerContext. + * Deliberately does NOT inherit BaseBulkCommand — AM operations use a different API + * surface with no stack setup, queue managers, or rate limiters. + */ +export abstract class BaseAmCommand extends Command { + protected parsedFlags!: AmAssetFlags; + protected loggerContext!: { module: string }; + + protected async init(): Promise { + await super.init(); + const { flags } = await this.parse(this.constructor as typeof BaseAmCommand); + this.loggerContext = { module: this.id ?? 'cm:stacks:bulk-am-assets' }; + this.parsedFlags = (await fillMissingAmFlags(flags)) as AmAssetFlags; + } + + async catch(error: Error): Promise { + handleAndLogError(error); + } + + abstract run(): Promise; +} diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index db3e8debb..370b44a94 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -28,7 +28,6 @@ import { buildBulkModeResult, handleOperationError, fillMissingFlags, - fillMissingAmFlags, getLogPaths, clearLogs, generateBulkPublishStatusUrl, @@ -52,7 +51,7 @@ import { * Provides common functionality for bulk-entries and bulk-assets */ export abstract class BaseBulkCommand extends Command { - protected resourceType?: ResourceType; + protected abstract resourceType: ResourceType; // Common flags for all bulk operations static baseFlags: FlagInput = { @@ -146,24 +145,14 @@ export abstract class BaseBulkCommand extends Command { this.parsedFlags = flags; - // AM assets uses a different API surface — prompt for AM-specific flags and skip - // the publish/unpublish stack setup, queue init, and config build. - if (this.resourceType === ResourceType.AM_ASSET) { - this.logger = log; - this.loggerContext = { module: this.id }; - this.parsedFlags = await fillMissingAmFlags(flags); - return; - } - - const commandName = `cm:stacks:bulk-${this.resourceType === ResourceType.ENTRY ? 'entries' : 'assets'}`; createLogContext( - this.context?.info?.command || commandName, + this.context?.info?.command || this.id, flags['stack-api-key'] || '', flags.alias ? 'Management Token' : 'Basic Auth' ); this.logger = log; - this.loggerContext = { module: commandName }; + this.loggerContext = { module: this.id }; // Check for revert/retry EARLY - all config comes from log file const isRevertOrRetry = flags.revert || flags['retry-failed']; @@ -187,7 +176,7 @@ export abstract class BaseBulkCommand extends Command { await this.setupStack(); await this.initializeComponents(); - this.logger.debug($t(messages.INITIALIZING, { resourceType: this.resourceType! }), this.loggerContext); + this.logger.debug($t(messages.INITIALIZING, { resourceType: this.resourceType }), this.loggerContext); } /** @@ -199,7 +188,7 @@ export abstract class BaseBulkCommand extends Command { const isRetry = !!flags['retry-failed']; // Load config from log file - const logFileConfig = loadConfigFromLogFile(logPath, isRetry, this.resourceType!); + const logFileConfig = loadConfigFromLogFile(logPath, isRetry, this.resourceType); if (!logFileConfig) { throw new Error($t(messages.NO_CONFIG_IN_LOG)); @@ -344,7 +333,7 @@ export abstract class BaseBulkCommand extends Command { batchResults: this.batchResults, logger: this.logger, retryStrategy: this.retryStrategy, - resourceType: this.resourceType!, + resourceType: this.resourceType, logFolderPath: this.bulkOperationConfig.bulkOperationFolder, apiKey: this.bulkOperationConfig.apiKey || this.bulkOperationConfig.stackApiKey, branch: this.bulkOperationConfig.branch, @@ -362,7 +351,7 @@ export abstract class BaseBulkCommand extends Command { const flags = this.parsedFlags || (await this.parse(this.constructor as typeof BaseBulkCommand)).flags; const itemCount = items?.length || 0; - return await confirmOperationUtil(this.bulkOperationConfig, itemCount, this.resourceType!, flags.yes); + return await confirmOperationUtil(this.bulkOperationConfig, itemCount, this.resourceType, flags.yes); } /** @@ -503,7 +492,7 @@ export abstract class BaseBulkCommand extends Command { const result = await handleRevertOrRetry( logPath, isRetry, - this.resourceType!, + this.resourceType, this.bulkOperationConfig, flags.yes, this.executeBulkOperation.bind(this), @@ -528,14 +517,14 @@ export abstract class BaseBulkCommand extends Command { targetEnvs: flags.environments as string[], locales: flags.locales as string[], contentTypes: flags['content-types'] as string[] | undefined, - resourceType: this.resourceType!, + resourceType: this.resourceType, deliveryStack: this.deliveryStack!, // Required: initialized via source-alias delivery token }, this.logger ); if (itemsToPublish.length === 0) { - this.logger.warn($t(messages.NO_ITEMS_FOUND, { resourceType: this.resourceType! }), this.loggerContext); + this.logger.warn($t(messages.NO_ITEMS_FOUND, { resourceType: this.resourceType }), this.loggerContext); return; } diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts index bde0f65f8..7d1a3118c 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts @@ -2,14 +2,14 @@ import chalk from 'chalk'; import { flags, log, createLogContext, cliux, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; import messages, { $t } from '../../../messages'; -import { BaseBulkCommand } from '../../../base-bulk-command'; +import { BaseAmCommand } from '../../../base-am-command'; import { AmAssetService } from '../../../services'; import { loadAssetUidsFromFile, loadBulkDeleteItemsFromFile, LoadAssetUidsError, } from '../../../utils/asset-uids-from-file'; -import { AmBulkDeleteItem, ResourceType } from '../../../interfaces'; +import { AmBulkDeleteItem } from '../../../interfaces'; const COMMAND_ID = 'cm:stacks:bulk-am-assets'; @@ -18,7 +18,7 @@ type RegionWithOptionalAmUrl = { csAssetsUrl?: string }; /** * AM bulk delete (job) / bulk move — CS Assets API only; asset UIDs come from a JSON file `{ "uids": [...] }`. */ -export default class BulkAmAssets extends BaseBulkCommand { +export default class BulkAmAssets extends BaseAmCommand { static description = messages.BULK_AM_ASSETS_DESCRIPTION; static examples = [ @@ -58,8 +58,6 @@ export default class BulkAmAssets extends BaseBulkCommand { }), }; - protected resourceType = ResourceType.AM_ASSET; - private printAmSummary(op: 'delete' | 'move', opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string }): void { if (opts.error) { log.error($t(messages.AM_OPERATION_FAILED, { operation: op }), this.loggerContext); @@ -108,26 +106,9 @@ export default class BulkAmAssets extends BaseBulkCommand { return; } - const spaceUid = (f['space-uid'] ?? '').trim(); - if (!spaceUid) { - log.error($t(messages.SPACE_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - const orgUid = (f['org-uid'] ?? '').trim(); - if (!orgUid) { - log.error($t(messages.ORG_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - const assetUidsPath = (f['asset-uids-file'] ?? '').trim(); - if (!assetUidsPath) { - log.error($t(messages.AM_ASSET_UIDS_FILE_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } + const spaceUid = f['space-uid'].trim(); + const orgUid = f['org-uid'].trim(); + const assetUidsPath = f['asset-uids-file'].trim(); let deleteRows: AmBulkDeleteItem[]; diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index e311f8dcf..c13dbc3c4 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -271,6 +271,18 @@ export interface AmBulkOperationResult { error?: string; } +/** Typed flags for the bulk-am-assets command. */ +export interface AmAssetFlags { + operation: string; + 'space-uid': string; + 'org-uid': string; + workspace: string; + 'asset-uids-file': string; + locale?: string; + 'target-folder-uid'?: string; + yes: boolean; +} + export interface BulkJobResult { success: number; failed: number; diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index 61bb486c5..4bccb0a91 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -224,10 +224,21 @@ export async function fillMissingFlags(flags: any): Promise { return updatedFlags; } +/** + * Runs a sequence of prompt functions wrapped in the standard interactive mode header/footer. + * Each prompt is a no-op if its condition is already satisfied (value already in flags). + */ +async function runInteractivePrompts(prompts: Array<() => Promise>): Promise { + cliux.print(messages.INTERACTIVE_MODE_START, { color: 'cyan' }); + for (const prompt of prompts) await prompt(); + cliux.print(messages.INTERACTIVE_MODE_COMPLETE, { color: 'green' }); +} + /** * Fills in missing flags for the bulk-am-assets command by prompting the user. * Handles AM-specific required flags including operation-conditional ones * (locale for delete, target-folder-uid for move). + * Throws in non-TTY environments when required flags are missing. */ export async function fillMissingAmFlags(flags: any): Promise { const f = { ...flags }; @@ -239,66 +250,87 @@ export async function fillMissingAmFlags(flags: any): Promise { if (!needsPrompt) return f; - cliux.print(messages.INTERACTIVE_MODE_START, { color: 'cyan' }); - - if (!f.operation) { - f.operation = await cliux.inquire({ - type: 'list', - name: 'operation', - message: messages.AM_SELECT_OPERATION, - choices: [ - { name: 'Delete (AM bulk delete)', value: 'delete' }, - { name: 'Move (AM bulk move)', value: 'move' }, - ], - }); - } - - if (!f['space-uid']) { - f['space-uid'] = await cliux.inquire({ - type: 'input', - name: 'spaceUid', - message: messages.AM_ENTER_SPACE_UID, - validate: (v: string) => (!v?.trim() ? messages.SPACE_UID_REQUIRED : true), - }); - } - - if (!f['org-uid']) { - f['org-uid'] = await cliux.inquire({ - type: 'input', - name: 'orgUid', - message: messages.AM_ENTER_ORG_UID, - validate: (v: string) => (!v?.trim() ? messages.ORG_UID_REQUIRED : true), - }); - } - - if (!f['asset-uids-file']) { - f['asset-uids-file'] = await cliux.inquire({ - type: 'input', - name: 'assetUidsFile', - message: messages.AM_ENTER_ASSET_UIDS_FILE, - validate: (v: string) => (!v?.trim() ? messages.AM_ASSET_UIDS_FILE_REQUIRED : true), - }); + // Fail fast in non-interactive environments (CI/CD) rather than hanging on stdin + if (!process.stdin.isTTY) { + const missing = [ + !f.operation && '--operation', + !f['space-uid'] && '--space-uid', + !f['org-uid'] && '--org-uid', + !f['asset-uids-file'] && '--asset-uids-file', + (f.operation === 'delete' && !f.locale) && '--locale', + (f.operation === 'move' && !f['target-folder-uid']) && '--target-folder-uid', + ].filter(Boolean); + throw new Error( + `Missing required flag(s): ${missing.join(', ')}. Provide all required flags when running in a non-interactive environment.` + ); } - if (f.operation === 'delete' && !f.locale) { - f.locale = await cliux.inquire({ - type: 'input', - name: 'locale', - message: messages.AM_ENTER_LOCALE, - validate: (v: string) => (!v?.trim() ? messages.AM_LOCALE_REQUIRED : true), - }); - } - - if (f.operation === 'move' && !f['target-folder-uid']) { - f['target-folder-uid'] = await cliux.inquire({ - type: 'input', - name: 'targetFolderUid', - message: messages.AM_ENTER_TARGET_FOLDER, - validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true), - }); - } - - cliux.print(messages.INTERACTIVE_MODE_COMPLETE, { color: 'green' }); + await runInteractivePrompts([ + async () => { + if (!f.operation) { + f.operation = await cliux.inquire({ + type: 'list', + name: 'operation', + message: messages.AM_SELECT_OPERATION, + choices: [ + { name: 'Delete (AM bulk delete)', value: 'delete' }, + { name: 'Move (AM bulk move)', value: 'move' }, + ], + }); + } + }, + async () => { + if (!f['space-uid']) { + f['space-uid'] = await cliux.inquire({ + type: 'input', + name: 'spaceUid', + message: messages.AM_ENTER_SPACE_UID, + validate: (v: string) => (!v?.trim() ? messages.SPACE_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['org-uid']) { + f['org-uid'] = await cliux.inquire({ + type: 'input', + name: 'orgUid', + message: messages.AM_ENTER_ORG_UID, + validate: (v: string) => (!v?.trim() ? messages.ORG_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['asset-uids-file']) { + f['asset-uids-file'] = await cliux.inquire({ + type: 'input', + name: 'assetUidsFile', + message: messages.AM_ENTER_ASSET_UIDS_FILE, + validate: (v: string) => (!v?.trim() ? messages.AM_ASSET_UIDS_FILE_REQUIRED : true), + }); + } + }, + // Conditional prompts run after operation is resolved (captured by closure) + async () => { + if (f.operation === 'delete' && !f.locale) { + f.locale = await cliux.inquire({ + type: 'input', + name: 'locale', + message: messages.AM_ENTER_LOCALE, + validate: (v: string) => (!v?.trim() ? messages.AM_LOCALE_REQUIRED : true), + }); + } + }, + async () => { + if (f.operation === 'move' && !f['target-folder-uid']) { + f['target-folder-uid'] = await cliux.inquire({ + type: 'input', + name: 'targetFolderUid', + message: messages.AM_ENTER_TARGET_FOLDER, + validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true), + }); + } + }, + ]); return f; } diff --git a/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts new file mode 100644 index 000000000..0787bfeee --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import BulkAmAssets from '../../../src/commands/cm/stacks/bulk-am-assets'; + +describe('BulkAmAssets command', () => { + let sandbox: sinon.SinonSandbox; + let command: BulkAmAssets; + + const baseDeleteFlags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: true, + }; + + const baseMoveFlags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: true, + }; + + function setRegion(value: object): void { + Object.defineProperty(command, 'region', { value, configurable: true, writable: true }); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + command = new BulkAmAssets([], {} as any); + (command as any).parsedFlags = { ...baseDeleteFlags }; + (command as any).loggerContext = { module: 'cm:stacks:bulk-am-assets' }; + setRegion({}); + }); + + afterEach(() => { + sandbox.restore(); + process.exitCode = undefined; + }); + + describe('AM URL validation', () => { + it('should set exitCode=1 when AM URL is not configured in region', async () => { + setRegion({}); // no csAssetsUrl + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('locale not allowed for move', () => { + it('should set exitCode=1 when --locale is passed with --operation move', async () => { + (command as any).parsedFlags = { ...baseMoveFlags, locale: 'en-us' }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + // Stub the file loader to confirm it is NOT reached + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + const loadStub = sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile'); + + await command.run(); + + expect(process.exitCode).to.equal(1); + expect(loadStub.called).to.be.false; // Should have exited before loading files + }); + + it('should NOT set exitCode when --locale is absent for move and API succeeds', async () => { + (command as any).parsedFlags = { ...baseMoveFlags }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile').returns(['uid1', 'uid2']); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkMove').resolves({ + success: true, + notice: undefined, + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + }); + + describe('delete operation', () => { + beforeEach(() => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + }); + + it('should NOT set exitCode on successful delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkDelete').resolves({ + success: true, + jobId: 'job-abc-123', + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + + it('should set exitCode=1 on failed delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkDelete').resolves({ + success: false, + error: 'API rate limit exceeded', + }); + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('BaseAmCommand isolation — no publish/unpublish infrastructure', () => { + it('should not have bulkOperationConfig, queueManager, or managementStack on the instance', () => { + // BulkAmAssets extends BaseAmCommand, NOT BaseBulkCommand. + // None of these publish/unpublish properties should exist. + expect((command as any).bulkOperationConfig).to.be.undefined; + expect((command as any).queueManager).to.be.undefined; + expect((command as any).managementStack).to.be.undefined; + expect((command as any).rateLimiter).to.be.undefined; + }); + }); +}); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts index 36afee32f..20a5ea9fc 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts @@ -416,4 +416,226 @@ describe('Interactive Prompts', () => { expect(validValidation).to.be.true; }); }); + + describe('fillMissingAmFlags', () => { + // We need to import fillMissingAmFlags separately + let fillMissingAmFlags: typeof import('../../../src/utils/interactive').fillMissingAmFlags; + let originalIsTTY: boolean | undefined; + + before(async () => { + ({ fillMissingAmFlags } = await import('../../../src/utils/interactive')); + }); + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + + it('should return flags unchanged when all required flags are provided (delete)', async () => { + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingAmFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + expect(printStub.called).to.be.false; + }); + + it('should return flags unchanged when all required flags are provided (move)', async () => { + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingAmFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + }); + + it('should throw in non-TTY when required base flags are missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { workspace: 'main', yes: false }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--operation'); + expect(error.message).to.include('--space-uid'); + expect(error.message).to.include('--org-uid'); + expect(error.message).to.include('--asset-uids-file'); + expect(error.message).to.include('non-interactive'); + } + }); + + it('should throw in non-TTY and include --locale when operation=delete and locale missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--locale'); + } + }); + + it('should throw in non-TTY and include --target-folder-uid when operation=move and folder missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--target-folder-uid'); + } + }); + + it('should prompt for all missing base flags in TTY and show interactive header/footer', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = {}; + + inquireStub.onCall(0).resolves('delete'); // operation + inquireStub.onCall(1).resolves('sp123'); // space-uid + inquireStub.onCall(2).resolves('org456'); // org-uid + inquireStub.onCall(3).resolves('./assets.json'); // asset-uids-file + inquireStub.onCall(4).resolves('en-us'); // locale (delete-conditional) + + const result = await fillMissingAmFlags(flags); + + expect(result.operation).to.equal('delete'); + expect(result['space-uid']).to.equal('sp123'); + expect(result['org-uid']).to.equal('org456'); + expect(result['asset-uids-file']).to.equal('./assets.json'); + expect(result.locale).to.equal('en-us'); + expect(printStub.calledTwice).to.be.true; + }); + + it('should prompt for locale only when operation=delete and locale is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('en-us'); // locale + + const result = await fillMissingAmFlags(flags); + + expect(result.locale).to.equal('en-us'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('locale'); + }); + + it('should prompt for target-folder-uid only when operation=move and folder is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('folderABC'); // target-folder-uid + + const result = await fillMissingAmFlags(flags); + + expect(result['target-folder-uid']).to.equal('folderABC'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('targetFolderUid'); + }); + + it('should NOT prompt for locale when operation=move', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + const result = await fillMissingAmFlags(flags); + + expect(result.locale).to.be.undefined; + expect(inquireStub.called).to.be.false; + }); + + it('should present delete/move choices for the operation prompt', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + inquireStub.onCall(0).resolves('move'); // operation + + await fillMissingAmFlags(flags); + + const operationCall = inquireStub.firstCall.args[0]; + expect(operationCall.type).to.equal('list'); + const values = operationCall.choices.map((c: any) => c.value); + expect(values).to.include('delete'); + expect(values).to.include('move'); + }); + + it('should validate that space-uid is not blank', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + }; + + inquireStub.onCall(0).resolves('sp123'); + + await fillMissingAmFlags(flags); + + const spaceUidCall = inquireStub.firstCall.args[0]; + expect(spaceUidCall.validate('')).to.not.equal(true); + expect(spaceUidCall.validate('sp123')).to.equal(true); + }); + }); });