diff --git a/.talismanrc b/.talismanrc index 0067b670e..1835171e5 100644 --- a/.talismanrc +++ b/.talismanrc @@ -7,4 +7,14 @@ fileignoreconfig: checksum: c13ca4996b3767fae9d2716fe9ae7d90d98b26012e064f42ea9fdd054f684761 - filename: packages/contentstack-export/src/utils/export-config-handler.ts checksum: 68351337b78f0962475498946fe34245a681182db756528c9ff6fab7153392e3 +- filename: packages/contentstack-bulk-operations/src/messages/index.ts + checksum: 07c2bf3f3130ad5e8b6a2971d76139e9b643c70a9ff5f7450adfb5c9bf3d7164 +- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts + checksum: 8bca423db4c3815c651ad922d986a53b6271cce3ea27f5987b66ee594151165e +- filename: packages/contentstack-bulk-operations/test/unit/utils/operation-flag-matrix.test.ts + checksum: 35451ca4c03359b06531a8461091f4a3a45c4dea1f8853081fb53e4ce28c1cc3 +- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-init.test.ts + checksum: 590b1cfe42d46d0917ac90f363b1ccd05200b180bd9c58c770ffd1f12eb18327 +- filename: packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts + checksum: 99a3c3eb422a17f73f4c8f15088004fa4c95df3545bdf510310c1f3a20e4c2c2 version: "" diff --git a/packages/contentstack-bulk-operations/package.json b/packages/contentstack-bulk-operations/package.json index 86704d330..6e04a3d02 100644 --- a/packages/contentstack-bulk-operations/package.json +++ b/packages/contentstack-bulk-operations/package.json @@ -112,7 +112,6 @@ }, "csdxConfig": { "shortCommandName": { - "cm:stacks:bulk-am-assets": "BOAM", "cm:stacks:bulk-assets": "BOA", "cm:stacks:bulk-entries": "BOE", "cm:stacks:bulk-taxonomies": "BOT" diff --git a/packages/contentstack-bulk-operations/src/base-am-command.ts b/packages/contentstack-bulk-operations/src/base-am-command.ts deleted file mode 100644 index 0d23b9c93..000000000 --- a/packages/contentstack-bulk-operations/src/base-am-command.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Command } from '@contentstack/cli-command'; -import { handleAndLogError } from '@contentstack/cli-utilities'; - -import { fillMissingCsAssetsFlags } from './utils'; -import type { CsAssetsFlags } from './interfaces'; - -/** - * Thin base command for CS Assets operations. - * Handles flag prompting in init() and exposes typed parsedFlags / loggerContext. - * Deliberately does NOT inherit BaseBulkCommand — CS Assets operations use a different API - * surface with no stack setup, queue managers, or rate limiters. - */ -export abstract class BaseCsAssetsCommand extends Command { - protected parsedFlags!: CsAssetsFlags; - protected loggerContext!: { module: string }; - - protected async init(): Promise { - await super.init(); - const { flags } = await this.parse(this.constructor as typeof BaseCsAssetsCommand); - this.loggerContext = { module: this.id ?? 'cm:stacks:bulk-am-assets' }; - this.parsedFlags = (await fillMissingCsAssetsFlags(flags)) as CsAssetsFlags; - } - - 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 55436fc33..3db56adb3 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -135,12 +135,25 @@ export abstract class BaseBulkCommand extends Command { private batchResults: Map = new Map(); protected parsedFlags: any; + /** + * Hook for subclasses to bypass the bulk-publish pipeline (stack setup, queue, + * rate limiter) when an operation uses a different execution path entirely. + * The subclass is then responsible for its own initialization after super.init(). + */ + protected shouldSkipBulkPipeline(): boolean { + return false; + } + /** * Initialize common components */ protected async init(): Promise { await super.init(); + if (this.shouldSkipBulkPipeline()) { + return; + } + let { flags } = await this.parse(this.constructor as typeof BaseBulkCommand); this.parsedFlags = flags; 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 deleted file mode 100644 index 850832eca..000000000 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts +++ /dev/null @@ -1,257 +0,0 @@ -import chalk from 'chalk'; -import { flags, log, createLogContext, cliux, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; - -import messages, { $t } from '../../../messages'; -import { BaseCsAssetsCommand } from '../../../base-am-command'; -import { CsAssetsService } from '../../../services'; -import { - loadAssetUidsFromFile, - loadBulkDeleteItemsFromFile, - LoadAssetUidsError, -} from '../../../utils/asset-uids-from-file'; -import { generateCsAssetsJobStatusUrl } from '../../../utils/bulk-publish-url-generator'; -import { CsAssetsBulkDeleteItem } from '../../../interfaces'; - -const COMMAND_ID = 'cm:stacks:bulk-am-assets'; - -type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string }; - -/** - * CS Assets bulk delete (job) / bulk move; asset UIDs come from a JSON file `{ "uids": [...] }`. - */ -export default class BulkCsAssets extends BaseCsAssetsCommand { - static description = messages.BULK_CS_ASSETS_DESCRIPTION; - - static examples = [ - '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltcOrg --locale en-us --asset-uids-file ./assets.json', - '<%= config.bin %> <%= command.id %> --operation move --space-uid am123 --org-uid bltcOrg --target-folder-uid amFolder --asset-uids-file ./assets.json', - '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltcOrg --workspace main --locale en-us --asset-uids-file ./uids.json -y', - ]; - - static flags: FlagInput = { - operation: flags.string({ - description: messages.CS_ASSETS_OPERATION_FLAG, - options: ['delete', 'move'], - }), - 'space-uid': flags.string({ - description: messages.CS_ASSETS_SPACE_UID_FLAG, - }), - 'org-uid': flags.string({ - description: messages.CS_ASSETS_ORG_UID_FLAG, - }), - workspace: flags.string({ - default: 'main', - description: messages.CS_ASSETS_WORKSPACE_FLAG, - }), - 'asset-uids-file': flags.string({ - description: messages.CS_ASSETS_ASSET_UIDS_FILE_FLAG, - }), - locale: flags.string({ - description: messages.CS_ASSETS_LOCALE_FLAG, - }), - 'target-folder-uid': flags.string({ - description: messages.CS_ASSETS_TARGET_FOLDER_FLAG, - }), - yes: flags.boolean({ - char: 'y', - description: messages.YES, - default: false, - }), - }; - - private printCsAssetsSummary( - op: 'delete' | 'move', - opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string; spaceUid?: string } - ): void { - if (opts.error) { - log.error($t(messages.CS_ASSETS_OPERATION_FAILED, { operation: op }), this.loggerContext); - log.error(opts.error, this.loggerContext); - } else if (op === 'delete') { - log.success($t(messages.CS_ASSETS_DELETE_SUCCESS), this.loggerContext); - if (opts.jobId) log.info($t(messages.CS_ASSETS_DELETE_JOB_ID, { jobId: opts.jobId }), this.loggerContext); - log.info($t(messages.CS_ASSETS_DELETE_ASYNC_NOTE), this.loggerContext); - const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); - if (statusUrl) log.info(statusUrl, this.loggerContext); - } else { - log.success($t(messages.CS_ASSETS_MOVE_SUCCESS), this.loggerContext); - if (opts.count !== undefined && opts.folderUid) { - log.info( - $t(messages.CS_ASSETS_MOVE_ASSETS_COUNT, { count: opts.count, folderUid: opts.folderUid }), - this.loggerContext - ); - } - const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); - if (statusUrl) log.info(statusUrl, this.loggerContext); - } - if (opts.notice) log.info(opts.notice, this.loggerContext); - } - - private handleAssetUidsFileError(e: LoadAssetUidsError): void { - const pathShown = e.filePath; - if (e.kind === 'READ') { - log.error( - $t(messages.CS_ASSETS_ASSET_UIDS_FILE_READ_FAILED, { path: pathShown, detail: e.message }), - this.loggerContext - ); - } else { - log.error( - $t(messages.CS_ASSETS_ASSET_UIDS_FILE_INVALID, { path: pathShown, detail: e.message }), - this.loggerContext - ); - } - process.exitCode = 1; - } - - async run(): Promise { - try { - const f = this.parsedFlags; - - const csAssetsBaseUrl = (this.region as RegionWithOptionalCsAssetsUrl).csAssetsUrl?.trim(); - if (!csAssetsBaseUrl) { - log.error($t(messages.CS_ASSETS_URL_NOT_CONFIGURED), this.loggerContext); - process.exitCode = 1; - return; - } - - const op = f.operation; - if (op !== 'delete' && op !== 'move') { - log.error($t(messages.CS_ASSETS_INVALID_OPERATION, { operation: String(op ?? '') }), 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: CsAssetsBulkDeleteItem[]; - - if (op === 'delete') { - const locale = (f.locale ?? '').trim(); - if (!locale) { - log.error($t(messages.CS_ASSETS_LOCALE_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - try { - deleteRows = loadBulkDeleteItemsFromFile(assetUidsPath, locale); - } catch (e: unknown) { - if (e instanceof LoadAssetUidsError) { - this.handleAssetUidsFileError(e); - } else { - handleAndLogError(e); - process.exitCode = 1; - } - return; - } - - createLogContext(this.context?.info?.command || COMMAND_ID, spaceUid, 'OAuth/Token'); - const csAssetsService = new CsAssetsService(csAssetsBaseUrl, spaceUid, orgUid); - const workspace = f.workspace ?? 'main'; - - if (!f.yes) { - console.log(chalk.yellow(`\n${$t(messages.OPERATION_CONFIG_HEADER)}\n`)); - console.log(' Operation: CS Assets bulk delete'); - console.log(` Space UID: ${spaceUid}`); - console.log(` Organization UID: ${orgUid}`); - console.log(` Workspace: ${workspace}`); - console.log(` Locale: ${locale}`); - console.log(` Asset UIDs file: ${assetUidsPath}`); - console.log(` Total CS Assets delete entries: ${deleteRows.length}\n`); - - const confirmed: boolean = await cliux.inquire({ - type: 'confirm', - name: 'proceed', - message: chalk.grey($t(messages.CONTINUE_WITH_CONFIG)), - default: false, - }); - if (!confirmed) { - log.warn($t(messages.OPERATION_CANCELLED), this.loggerContext); - return; - } - } - - log.info($t(messages.CS_ASSETS_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), this.loggerContext); - const result = await csAssetsService.bulkDelete(spaceUid, workspace, deleteRows); - if (!result.success) { - this.printCsAssetsSummary('delete', { error: result.error ?? 'CS Assets bulk delete failed', spaceUid }); - process.exitCode = 1; - return; - } - this.printCsAssetsSummary('delete', { jobId: result.jobId, notice: result.notice, spaceUid }); - return; - } - - if (f.locale) { - log.error($t(messages.CS_ASSETS_LOCALE_NOT_ALLOWED_FOR_MOVE), this.loggerContext); - process.exitCode = 1; - return; - } - - const moveFolderUid = (f['target-folder-uid'] ?? '').trim(); - if (!moveFolderUid) { - log.error($t(messages.TARGET_FOLDER_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - let uids: string[]; - try { - uids = loadAssetUidsFromFile(assetUidsPath); - } catch (e: unknown) { - if (e instanceof LoadAssetUidsError) { - this.handleAssetUidsFileError(e); - } else { - handleAndLogError(e); - process.exitCode = 1; - } - return; - } - - createLogContext(this.context?.info?.command || COMMAND_ID, spaceUid, 'OAuth/Token'); - const csAssetsService = new CsAssetsService(csAssetsBaseUrl, spaceUid, orgUid); - const workspace = f.workspace ?? 'main'; - - if (!f.yes) { - console.log(chalk.yellow(`\n${$t(messages.OPERATION_CONFIG_HEADER)}\n`)); - console.log(' Operation: CS Assets bulk move'); - console.log(` Space UID: ${spaceUid}`); - console.log(` Organization UID: ${orgUid}`); - console.log(` Workspace: ${workspace}`); - console.log(` Target folder UID: ${moveFolderUid}`); - console.log(` Asset UIDs file: ${assetUidsPath}`); - console.log(` Assets: ${uids.length}\n`); - - const confirmed: boolean = await cliux.inquire({ - type: 'confirm', - name: 'proceed', - message: chalk.grey($t(messages.CONTINUE_WITH_CONFIG)), - default: false, - }); - if (!confirmed) { - log.warn($t(messages.OPERATION_CANCELLED), this.loggerContext); - return; - } - } - - log.info( - $t(messages.CS_ASSETS_MOVING_ASSETS, { count: uids.length, targetFolderUid: moveFolderUid }), - this.loggerContext - ); - const result = await csAssetsService.bulkMove(spaceUid, workspace, uids, moveFolderUid); - if (!result.success) { - this.printCsAssetsSummary('move', { error: result.error ?? 'CS Assets bulk move failed', spaceUid }); - process.exitCode = 1; - return; - } - this.printCsAssetsSummary('move', { - count: uids.length, - folderUid: moveFolderUid, - notice: result.notice, - spaceUid, - }); - } catch (error) { - handleAndLogError(error); - } - } -} diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts index 71122a91d..1706fefba 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts @@ -1,12 +1,39 @@ import { flags, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; -import { ResourceType } from '../../../interfaces'; +import { ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces'; import { BaseBulkCommand } from '../../../base-bulk-command'; -import { $t, messages, fetchAssets } from '../../../utils'; +import { + $t, + messages, + fetchAssets, + fillMissingCsAssetsFlags, + promptForOperation, + runCsAssetsOperation, + enforceOperationFlagMatrix, + getOperationFromArgv, + OperationFlagMatrixError, + RETRY_REVERT_CONTEXT, +} from '../../../utils'; + +type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string }; + +const ALL_OPERATION_CHOICES = [ + { name: 'Publish', value: OperationType.PUBLISH }, + { name: 'Unpublish', value: OperationType.UNPUBLISH }, + { name: 'Delete (CS Assets bulk delete)', value: OperationType.DELETE }, + { name: 'Move (CS Assets bulk move)', value: OperationType.MOVE }, +]; /** * Bulk operations command for assets - * Supports publish, unpublish, and cross publish operations + * Supports publish, unpublish, and cross publish operations (CMS), plus + * delete and move operations (CS Assets). + * + * The two families use fully separate execution paths: + * - publish/unpublish run through the BaseBulkCommand pipeline (stack setup, + * queue, rate limiter, retry) authenticated via stack API key or alias. + * - delete/move run through the CS Assets runner (OAuth/Token against the + * region's csAssetsUrl) with no bulk-publish infrastructure at all. */ export default class BulkAssets extends BaseBulkCommand { static description = messages.BULK_ASSETS_DESCRIPTION; @@ -32,18 +59,118 @@ export default class BulkAssets extends BaseBulkCommand { // Revert (unpublish) previously published assets using success log '<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias', + + // CS Assets bulk delete (asset UIDs from a JSON file `{ "uids": [...] }`) + '<%= config.bin %> <%= command.id %> --operation delete --space-uid am123 --org-uid bltOrg --locale en-us --asset-uids-file ./assets.json', + + // CS Assets bulk move to a target folder + '<%= config.bin %> <%= command.id %> --operation move --space-uid am123 --org-uid bltOrg --target-folder-uid amFolder --asset-uids-file ./assets.json', ]; static flags: FlagInput = { ...BaseBulkCommand.baseFlags, + operation: flags.string({ + description: messages.BULK_ASSETS_OPERATION, + options: [OperationType.PUBLISH, OperationType.UNPUBLISH, OperationType.DELETE, OperationType.MOVE], + required: false, // Not required if retry-failed or revert is used + }), 'folder-uid': flags.string({ description: messages.FOLDER_UID, }), + + // CS Assets delete/move flags + 'space-uid': flags.string({ + description: messages.CS_ASSETS_SPACE_UID_FLAG, + }), + 'org-uid': flags.string({ + description: messages.CS_ASSETS_ORG_UID_FLAG, + }), + workspace: flags.string({ + default: 'main', + description: messages.CS_ASSETS_WORKSPACE_FLAG, + }), + 'asset-uids-file': flags.string({ + description: messages.CS_ASSETS_ASSET_UIDS_FILE_FLAG, + }), + locale: flags.string({ + description: messages.CS_ASSETS_LOCALE_FLAG, + }), + 'target-folder-uid': flags.string({ + description: messages.CS_ASSETS_TARGET_FOLDER_FLAG, + }), }; protected resourceType: ResourceType = ResourceType.ASSET; + /** True when the resolved operation is a CS Assets one (delete/move). */ + private csAssetsMode = false; + private csAssetsFlags!: CsAssetsFlags; + + protected shouldSkipBulkPipeline(): boolean { + return this.csAssetsMode; + } + + protected async init(): Promise { + // Resolve the operation from raw argv BEFORE any pipeline work: the two + // operation families use disjoint flags and auth, so the pipeline choice + // (and flag validation) depends on it. Raw argv is also what lets us reject + // explicitly-passed CMS flags that carry defaults (--branch, --publish-mode). + let operation = getOperationFromArgv(this.argv); + const isRevertOrRetry = this.argv.some( + (token) => + token === '--retry-failed' || + token.startsWith('--retry-failed=') || + token === '--revert' || + token.startsWith('--revert=') + ); + + if (!operation && !isRevertOrRetry) { + if (!process.stdin.isTTY) { + throw new Error( + 'Missing required flag: --operation. Provide it when running in a non-interactive environment.' + ); + } + operation = await promptForOperation(ALL_OPERATION_CHOICES); + // Feed the answer back into argv so both pipelines parse it like any other flag + this.argv.push('--operation', operation); + } + + enforceOperationFlagMatrix(operation ?? RETRY_REVERT_CONTEXT, this.argv, { module: this.id }); + + this.csAssetsMode = operation === OperationType.DELETE || operation === OperationType.MOVE; + + // For delete/move, shouldSkipBulkPipeline() makes super.init() stop right after + // the framework-level Command init — no stack setup, queue, or rate limiter. + await super.init(); + + if (this.csAssetsMode) { + const { flags: parsed } = await this.parse(this.constructor as typeof BulkAssets); + this.loggerContext = { module: this.id ?? 'cm:stacks:bulk-assets' }; + this.csAssetsFlags = (await fillMissingCsAssetsFlags(parsed)) as CsAssetsFlags; + this.parsedFlags = this.csAssetsFlags; + } + } + + async catch(error: Error): Promise { + // Matrix violations are already logged (with exitCode set) by + // enforceOperationFlagMatrix — the throw only aborts init(). + if (error instanceof OperationFlagMatrixError) { + return; + } + return super.catch(error); + } + async run(): Promise { + if (this.csAssetsMode) { + await runCsAssetsOperation({ + flags: this.csAssetsFlags, + csAssetsBaseUrl: (this.region as RegionWithOptionalCsAssetsUrl).csAssetsUrl, + commandId: this.context?.info?.command || this.id || 'cm:stacks:bulk-assets', + loggerContext: this.loggerContext, + }); + return; + } + try { // Handle cross-publish separately if source-env is specified if (this.bulkOperationConfig.sourceEnv) { diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index 04b3ed901..f7a9c7596 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -271,7 +271,7 @@ export interface CsAssetsBulkOperationResult { error?: string; } -/** Typed flags for the bulk-am-assets command. */ +/** Typed flags for CS Assets delete/move operations (cm:stacks:bulk-assets). */ export interface CsAssetsFlags { operation: string; 'space-uid': string; diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index ccc90ebb5..9f82677a8 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -200,6 +200,8 @@ const assetServiceMsg = { * Bulk assets command messages */ const bulkAssetsMsg = { + BULK_ASSETS_OPERATION: + 'Operation to perform: `publish`/`unpublish` (CMS, requires stack API key or alias) or `delete`/`move` (CS Assets, requires OAuth/Token and --space-uid/--org-uid)', FETCHING: 'Fetching assets...', FOUND_ASSETS: 'Found {count} assets ({locale})', FETCH_FOR_LOCALES: 'Fetch assets for {count} locales', @@ -216,8 +218,6 @@ const bulkAssetsMsg = { * CS Assets bulk delete/move messages */ const csAssetsBulkMsg = { - BULK_CS_ASSETS_DESCRIPTION: - 'Bulk delete or move assets via CS Assets API. Loads asset UIDs from a JSON file `{ "uids": [...] }`; pass organization via `--org-uid`.', CS_ASSETS_URL_NOT_CONFIGURED: 'CS Assets operations require csAssetsUrl in your region settings. Ensure your region is configured correctly.', SPACE_UID_REQUIRED: '--space-uid is required for CS Assets operations', @@ -253,8 +253,13 @@ const csAssetsBulkMsg = { CS_ASSETS_MOVE_ASSETS_COUNT: '{count} asset(s) moved to folder: {folderUid}', CS_ASSETS_OPERATION_FAILED: 'CS Assets {operation} failed.', + // Merged-command flag matrix validation + FLAG_NOT_ALLOWED_FOR_OPERATION: '{flag} is not valid for operation "{operation}".{hint}', + FLAG_NOT_ALLOWED_WITH_RETRY_REVERT: '{flag} cannot be combined with --retry-failed/--revert.', + CS_ASSETS_AUTH_REQUIRED: + 'The {operation} operation requires OAuth login or an auth token. Run "csdx login" and try again.', + // Interactive prompts - CS_ASSETS_SELECT_OPERATION: 'Select CS Assets operation:', CS_ASSETS_ENTER_SPACE_UID: 'Enter CS Assets space UID:', CS_ASSETS_ENTER_ORG_UID: 'Enter organization UID:', CS_ASSETS_ENTER_ASSET_UIDS_FILE: 'Enter path to asset UIDs JSON file (e.g. ./assets.json):', @@ -419,10 +424,10 @@ const flagDescriptions = { */ const commandInfo = { BULK_ENTRIES_DESCRIPTION: 'Bulk operations for entries (publish/unpublish/cross-publish)', - BULK_ASSETS_DESCRIPTION: 'Bulk operations for assets (publish/unpublish/cross-publish)', + BULK_ASSETS_DESCRIPTION: + 'Bulk operations for assets: publish/unpublish/cross-publish (CMS) and delete/move (CS Assets). Delete/move load asset UIDs from a JSON file `{ "uids": [...] }`; pass organization via `--org-uid`.', BULK_TAXONOMIES_DESCRIPTION: 'Publish taxonomies to environments and locales (CMA POST /v3/taxonomies/publish; initiates a publish job)', - BULK_CS_ASSETS_DESCRIPTION: csAssetsBulkMsg.BULK_CS_ASSETS_DESCRIPTION, }; /** diff --git a/packages/contentstack-bulk-operations/src/utils/cs-assets-runner.ts b/packages/contentstack-bulk-operations/src/utils/cs-assets-runner.ts new file mode 100644 index 000000000..fdf8c0b5f --- /dev/null +++ b/packages/contentstack-bulk-operations/src/utils/cs-assets-runner.ts @@ -0,0 +1,258 @@ +import chalk from 'chalk'; +import { log, createLogContext, cliux, handleAndLogError, authenticationHandler } from '@contentstack/cli-utilities'; + +import messages, { $t } from '../messages'; +import { CsAssetsService } from '../services'; +import { loadAssetUidsFromFile, loadBulkDeleteItemsFromFile, LoadAssetUidsError } from './asset-uids-from-file'; +import { generateCsAssetsJobStatusUrl } from './bulk-publish-url-generator'; +import { CsAssetsFlags, CsAssetsBulkOperationResult, OperationType } from '../interfaces'; + +/** + * Execution path for CS Assets bulk delete/move — deliberately independent of the + * BaseBulkCommand pipeline. CS Assets operations use a different API surface with + * no stack setup, queue managers, or rate limiters, and authenticate via OAuth/Token + * against the region's csAssetsUrl. + */ + +interface CsAssetsRunnerOptions { + flags: CsAssetsFlags; + csAssetsBaseUrl: string | undefined; + commandId: string; + loggerContext: { module: string }; +} + +/** Per-operation execution plan; the shared flow below drives it. */ +interface CsAssetsOperationPlan { + summaryLines: string[]; + logStart: () => void; + execute: (service: CsAssetsService) => Promise; + failureFallback: string; + successOpts: (result: CsAssetsBulkOperationResult) => Parameters[1]; +} + +function printCsAssetsSummary( + op: 'delete' | 'move', + opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string; spaceUid?: string }, + loggerContext: { module: string } +): void { + if (opts.error) { + log.error($t(messages.CS_ASSETS_OPERATION_FAILED, { operation: op }), loggerContext); + log.error(opts.error, loggerContext); + } else if (op === 'delete') { + log.success($t(messages.CS_ASSETS_DELETE_SUCCESS), loggerContext); + if (opts.jobId) log.info($t(messages.CS_ASSETS_DELETE_JOB_ID, { jobId: opts.jobId }), loggerContext); + log.info($t(messages.CS_ASSETS_DELETE_ASYNC_NOTE), loggerContext); + const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); + if (statusUrl) log.info(statusUrl, loggerContext); + } else { + log.success($t(messages.CS_ASSETS_MOVE_SUCCESS), loggerContext); + if (opts.count !== undefined && opts.folderUid) { + log.info( + $t(messages.CS_ASSETS_MOVE_ASSETS_COUNT, { count: opts.count, folderUid: opts.folderUid }), + loggerContext + ); + } + const statusUrl = generateCsAssetsJobStatusUrl(opts.spaceUid); + if (statusUrl) log.info(statusUrl, loggerContext); + } + if (opts.notice) log.info(opts.notice, loggerContext); +} + +function handleAssetUidsFileError(e: LoadAssetUidsError, loggerContext: { module: string }): void { + const pathShown = e.filePath; + if (e.kind === 'READ') { + log.error( + $t(messages.CS_ASSETS_ASSET_UIDS_FILE_READ_FAILED, { path: pathShown, detail: e.message }), + loggerContext + ); + } else { + log.error($t(messages.CS_ASSETS_ASSET_UIDS_FILE_INVALID, { path: pathShown, detail: e.message }), loggerContext); + } + process.exitCode = 1; +} + +/** + * Loads rows from the asset UIDs file, reporting errors and setting the exit code. + * Returns undefined when loading failed. + */ +function tryLoadFromFile(loader: () => T, loggerContext: { module: string }): T | undefined { + try { + return loader(); + } catch (e: unknown) { + if (e instanceof LoadAssetUidsError) { + handleAssetUidsFileError(e, loggerContext); + } else { + handleAndLogError(e as Error); + process.exitCode = 1; + } + return undefined; + } +} + +/** + * Pre-flight auth check: CS Assets operations require an OAuth session or auth token. + * Fails before any confirmation prompt so users don't confirm a destructive operation + * that would only fail at the API call. + */ +async function ensureCsAssetsAuth(operation: string, loggerContext: { module: string }): Promise { + try { + await authenticationHandler.getAuthDetails(); + if (!authenticationHandler.accessToken) { + log.error($t(messages.CS_ASSETS_AUTH_REQUIRED, { operation }), loggerContext); + process.exitCode = 1; + return false; + } + return true; + } catch (error) { + log.error($t(messages.CS_ASSETS_AUTH_REQUIRED, { operation }), loggerContext); + handleAndLogError(error as Error); + process.exitCode = 1; + return false; + } +} + +/** Prints the operation summary and asks for confirmation (skipped with --yes). */ +async function confirmProceed( + summaryLines: string[], + yes: boolean, + loggerContext: { module: string } +): Promise { + if (yes) return true; + + console.log(chalk.yellow(`\n${$t(messages.OPERATION_CONFIG_HEADER)}\n`)); + for (const line of summaryLines) { + console.log(` ${line}`); + } + console.log(''); + + const confirmed: boolean = await cliux.inquire({ + type: 'confirm', + name: 'proceed', + message: chalk.grey($t(messages.CONTINUE_WITH_CONFIG)), + default: false, + }); + if (!confirmed) { + log.warn($t(messages.OPERATION_CANCELLED), loggerContext); + } + return confirmed; +} + +/** + * Runs a CS Assets bulk delete or move. Flags are expected to be pre-validated + * (operation flag matrix) and pre-filled (fillMissingCsAssetsFlags). + */ +export async function runCsAssetsOperation(options: CsAssetsRunnerOptions): Promise { + const { flags: f, csAssetsBaseUrl: rawBaseUrl, commandId, loggerContext } = options; + + try { + const csAssetsBaseUrl = rawBaseUrl?.trim(); + if (!csAssetsBaseUrl) { + log.error($t(messages.CS_ASSETS_URL_NOT_CONFIGURED), loggerContext); + process.exitCode = 1; + return; + } + + const op = f.operation; + if (op !== OperationType.DELETE && op !== OperationType.MOVE) { + log.error($t(messages.CS_ASSETS_INVALID_OPERATION, { operation: String(op ?? '') }), loggerContext); + process.exitCode = 1; + return; + } + + if (!(await ensureCsAssetsAuth(op, loggerContext))) { + return; + } + + const spaceUid = f['space-uid'].trim(); + const orgUid = f['org-uid'].trim(); + const assetUidsPath = f['asset-uids-file'].trim(); + const workspace = f.workspace ?? 'main'; + + const commonSummaryLines = [ + `Space UID: ${spaceUid}`, + `Organization UID: ${orgUid}`, + `Workspace: ${workspace}`, + ]; + + let plan: CsAssetsOperationPlan; + + if (op === OperationType.DELETE) { + const locale = (f.locale ?? '').trim(); + if (!locale) { + log.error($t(messages.CS_ASSETS_LOCALE_REQUIRED), loggerContext); + process.exitCode = 1; + return; + } + + const deleteRows = tryLoadFromFile(() => loadBulkDeleteItemsFromFile(assetUidsPath, locale), loggerContext); + if (!deleteRows) return; + + plan = { + summaryLines: [ + 'Operation: CS Assets bulk delete', + ...commonSummaryLines, + `Locale: ${locale}`, + `Asset UIDs file: ${assetUidsPath}`, + `Total CS Assets delete entries: ${deleteRows.length}`, + ], + logStart: () => + log.info($t(messages.CS_ASSETS_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), loggerContext), + execute: (service) => service.bulkDelete(spaceUid, workspace, deleteRows), + failureFallback: 'CS Assets bulk delete failed', + successOpts: (result) => ({ jobId: result.jobId, notice: result.notice, spaceUid }), + }; + } else { + if (f.locale) { + log.error($t(messages.CS_ASSETS_LOCALE_NOT_ALLOWED_FOR_MOVE), loggerContext); + process.exitCode = 1; + return; + } + + const moveFolderUid = (f['target-folder-uid'] ?? '').trim(); + if (!moveFolderUid) { + log.error($t(messages.TARGET_FOLDER_REQUIRED), loggerContext); + process.exitCode = 1; + return; + } + + const uids = tryLoadFromFile(() => loadAssetUidsFromFile(assetUidsPath), loggerContext); + if (!uids) return; + + plan = { + summaryLines: [ + 'Operation: CS Assets bulk move', + ...commonSummaryLines, + `Target folder UID: ${moveFolderUid}`, + `Asset UIDs file: ${assetUidsPath}`, + `Assets: ${uids.length}`, + ], + logStart: () => + log.info( + $t(messages.CS_ASSETS_MOVING_ASSETS, { count: uids.length, targetFolderUid: moveFolderUid }), + loggerContext + ), + execute: (service) => service.bulkMove(spaceUid, workspace, uids, moveFolderUid), + failureFallback: 'CS Assets bulk move failed', + successOpts: (result) => ({ count: uids.length, folderUid: moveFolderUid, notice: result.notice, spaceUid }), + }; + } + + createLogContext(commandId, spaceUid, 'OAuth/Token'); + const csAssetsService = new CsAssetsService(csAssetsBaseUrl, spaceUid, orgUid); + + if (!(await confirmProceed(plan.summaryLines, f.yes, loggerContext))) { + return; + } + + plan.logStart(); + const result = await plan.execute(csAssetsService); + if (!result.success) { + printCsAssetsSummary(op, { error: result.error ?? plan.failureFallback, spaceUid }, loggerContext); + process.exitCode = 1; + return; + } + printCsAssetsSummary(op, plan.successOpts(result), loggerContext); + } catch (error) { + handleAndLogError(error as Error); + } +} diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index aa1d8801f..651b8c755 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -35,7 +35,15 @@ import { buildBulkModeResult, handleOperationError, } from './command-helpers'; -import { fillMissingFlags, fillMissingCsAssetsFlags } from './interactive'; +import { fillMissingFlags, fillMissingCsAssetsFlags, promptForOperation } from './interactive'; +import { runCsAssetsOperation } from './cs-assets-runner'; +import { + validateOperationFlagMatrix, + enforceOperationFlagMatrix, + getOperationFromArgv, + OperationFlagMatrixError, + RETRY_REVERT_CONTEXT, +} from './operation-flag-matrix'; import { RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, @@ -99,6 +107,13 @@ export { handleOperationError, fillMissingFlags, fillMissingCsAssetsFlags, + promptForOperation, + runCsAssetsOperation, + validateOperationFlagMatrix, + enforceOperationFlagMatrix, + getOperationFromArgv, + OperationFlagMatrixError, + RETRY_REVERT_CONTEXT, 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 448d1d5ca..2066c7809 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -2,15 +2,19 @@ import { cliux, configHandler, isAuthenticated } from '@contentstack/cli-utiliti import { OperationType, FilterType } from '../interfaces'; import { messages } from './index'; -async function promptForOperation(): Promise { +const DEFAULT_OPERATION_CHOICES = [ + { name: 'Publish', value: OperationType.PUBLISH }, + { name: 'Unpublish', value: OperationType.UNPUBLISH }, +]; + +export async function promptForOperation( + choices: Array<{ name: string; value: string }> = DEFAULT_OPERATION_CHOICES +): Promise { return cliux.inquire({ type: 'list', name: 'operation', message: messages.SELECT_OPERATION, - choices: [ - { name: 'Publish', value: OperationType.PUBLISH }, - { name: 'Unpublish', value: OperationType.UNPUBLISH }, - ], + choices, }); } @@ -235,9 +239,10 @@ async function runInteractivePrompts(prompts: Array<() => Promise>): Promi } /** - * Fills in missing flags for the bulk-am-assets command by prompting the user. + * Fills in missing flags for CS Assets delete/move operations by prompting the user. * Handles CS Assets-specific required flags including operation-conditional ones * (locale for delete, target-folder-uid for move). + * The operation itself is always resolved by the command's init() before this runs. * Throws in non-TTY environments when required flags are missing. */ export async function fillMissingCsAssetsFlags(flags: any): Promise { @@ -245,15 +250,13 @@ export async function fillMissingCsAssetsFlags(flags: any): Promise { 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; + const needsPrompt = !f['space-uid'] || !f['org-uid'] || !f['asset-uids-file'] || needsLocale || needsFolderUid; if (!needsPrompt) return f; // 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', @@ -266,19 +269,6 @@ export async function fillMissingCsAssetsFlags(flags: any): Promise { } await runInteractivePrompts([ - async () => { - if (!f.operation) { - f.operation = await cliux.inquire({ - type: 'list', - name: 'operation', - message: messages.CS_ASSETS_SELECT_OPERATION, - choices: [ - { name: 'Delete (CS Assets bulk delete)', value: 'delete' }, - { name: 'Move (CS Assets bulk move)', value: 'move' }, - ], - }); - } - }, async () => { if (!f['space-uid']) { f['space-uid'] = await cliux.inquire({ diff --git a/packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts b/packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts new file mode 100644 index 000000000..5d3ad02f1 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/utils/operation-flag-matrix.ts @@ -0,0 +1,157 @@ +import { log } from '@contentstack/cli-utilities'; + +import messages, { $t } from '../messages'; +import { OperationType } from '../interfaces'; + +/** + * Per-operation flag matrix for the merged `cm:stacks:bulk-assets` command. + * + * publish/unpublish (CMS) and delete/move (CS Assets) share a single command but + * use disjoint flag sets and auth models. oclif cannot express operation-conditional + * flags statically, so this validates the RAW argv: several CMS flags carry defaults + * (--branch, --publish-mode, --bulk-operation-file), which makes parsed flags unable + * to distinguish "user passed it" from "default filled it". + */ + +interface FlagSpec { + /** Long flag name without leading dashes */ + name: string; + /** Optional short char without leading dash */ + char?: string; +} + +/** Flags only meaningful for CMS publish/unpublish operations. */ +const CMS_ONLY_FLAGS: FlagSpec[] = [ + { name: 'stack-api-key', char: 'k' }, + { name: 'alias', char: 'a' }, + { name: 'environments' }, + { name: 'locales' }, + { name: 'source-env' }, + { name: 'source-alias' }, + { name: 'publish-mode' }, + { name: 'branch' }, + { name: 'config', char: 'c' }, + { name: 'retry-failed' }, + { name: 'revert' }, + { name: 'bulk-operation-file' }, + { name: 'folder-uid' }, +]; + +/** Flags only meaningful for CS Assets delete/move operations. */ +const CS_ASSETS_ONLY_FLAGS: FlagSpec[] = [ + { name: 'space-uid' }, + { name: 'org-uid' }, + { name: 'asset-uids-file' }, + { name: 'locale' }, + { name: 'target-folder-uid' }, + { name: 'workspace' }, +]; + +/** Did-you-mean hints for the twin locale flags. */ +const FLAG_HINTS: Record> = { + locales: { + [OperationType.DELETE]: ' Did you mean --locale?', + }, + locale: { + [OperationType.PUBLISH]: ' Did you mean --locales?', + [OperationType.UNPUBLISH]: ' Did you mean --locales?', + }, +}; + +/** + * Returns true when the given flag was explicitly passed on the command line. + * Handles `--flag`, `--flag=value`, `--no-flag`, and short forms `-k` / `-k=value`. + */ +function isFlagInArgv(argv: string[], spec: FlagSpec): boolean { + return argv.some((token) => { + if (!token.startsWith('-')) return false; + // Strip `=value` if present + const bare = token.split('=')[0]; + if (bare === `--${spec.name}` || bare === `--no-${spec.name}`) return true; + if (spec.char && bare === `-${spec.char}`) return true; + return false; + }); +} + +function isCsAssetsOperation(operation: string): boolean { + return operation === OperationType.DELETE || operation === OperationType.MOVE; +} + +/** + * Sentinel operation context for the --retry-failed/--revert path, where no + * --operation flag is given but CS Assets flags must still be rejected. + */ +export const RETRY_REVERT_CONTEXT = 'retry/revert'; + +/** Thrown by enforceOperationFlagMatrix so init() aborts without process.exit(). */ +export class OperationFlagMatrixError extends Error { + readonly violations: string[]; + + constructor(violations: string[]) { + super(violations.join('\n')); + this.name = 'OperationFlagMatrixError'; + this.violations = violations; + } +} + +/** + * Validates that no cross-operation flags were explicitly passed for the resolved + * operation. Returns the list of violation messages (empty when valid). + */ +export function validateOperationFlagMatrix(operation: string, argv: string[]): string[] { + const violations: string[] = []; + + const rejected = isCsAssetsOperation(operation) ? CMS_ONLY_FLAGS : CS_ASSETS_ONLY_FLAGS; + + for (const spec of rejected) { + if (isFlagInArgv(argv, spec)) { + if (operation === RETRY_REVERT_CONTEXT) { + violations.push($t(messages.FLAG_NOT_ALLOWED_WITH_RETRY_REVERT, { flag: `--${spec.name}` })); + } else { + const hint = FLAG_HINTS[spec.name]?.[operation] ?? ''; + violations.push($t(messages.FLAG_NOT_ALLOWED_FOR_OPERATION, { flag: `--${spec.name}`, operation, hint })); + } + } + } + + // move additionally rejects --locale (delete-only within the CS Assets pair) + if (operation === OperationType.MOVE && isFlagInArgv(argv, { name: 'locale' })) { + violations.push(messages.CS_ASSETS_LOCALE_NOT_ALLOWED_FOR_MOVE); + } + + return violations; +} + +/** + * Runs the matrix validation. On violations: logs each one, sets a non-zero exit + * code, and throws OperationFlagMatrixError to abort init(). Throwing (instead of + * process.exit) lets oclif run its catch/finally hooks and keeps this testable. + */ +export function enforceOperationFlagMatrix(operation: string, argv: string[], loggerContext?: unknown): void { + const violations = validateOperationFlagMatrix(operation, argv); + if (violations.length > 0) { + for (const violation of violations) { + log.error(violation, loggerContext); + } + process.exitCode = 1; + throw new OperationFlagMatrixError(violations); + } +} + +/** + * Extracts the value of `--operation` from raw argv without a full oclif parse + * (parse cannot run before init, and init needs the operation to pick a pipeline). + * Returns undefined when the flag is absent. + */ +export function getOperationFromArgv(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token === '--operation') { + return argv[i + 1]; + } + if (token.startsWith('--operation=')) { + return token.slice('--operation='.length); + } + } + return undefined; +} diff --git a/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts b/packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-cs-assets.test.ts similarity index 53% rename from packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts rename to packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-cs-assets.test.ts index 4f31c7761..b004ba5de 100644 --- a/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-cs-assets.test.ts @@ -2,11 +2,16 @@ import sinon from 'sinon'; import { expect } from 'chai'; import { describe, it, beforeEach, afterEach } from 'mocha'; -import BulkCsAssets from '../../../src/commands/cm/stacks/bulk-am-assets'; +import BulkAssets from '../../../src/commands/cm/stacks/bulk-assets'; -describe('BulkCsAssets command', () => { +/** + * CS Assets delete/move path of the merged cm:stacks:bulk-assets command + * (formerly cm:stacks:bulk-am-assets). + */ +describe('BulkAssets command — CS Assets delete/move path', () => { let sandbox: sinon.SinonSandbox; - let command: BulkCsAssets; + let command: BulkAssets; + let authHandler: any; const baseDeleteFlags = { operation: 'delete', @@ -32,12 +37,24 @@ describe('BulkCsAssets command', () => { Object.defineProperty(command, 'region', { value, configurable: true, writable: true }); } + function setCsAssetsFlags(flags: object): void { + (command as any).csAssetsMode = true; + (command as any).csAssetsFlags = { ...flags }; + (command as any).loggerContext = { module: 'cm:stacks:bulk-assets' }; + } + + function stubAuthSuccess(): void { + sandbox.stub(authHandler, 'getAuthDetails').resolves(); + sandbox.stub(Object.getPrototypeOf(authHandler), 'accessToken').get(() => 'test-token'); + } + beforeEach(() => { sandbox = sinon.createSandbox(); - command = new BulkCsAssets([], {} as any); - (command as any).parsedFlags = { ...baseDeleteFlags }; - (command as any).loggerContext = { module: 'cm:stacks:bulk-am-assets' }; + command = new BulkAssets([], {} as any); + setCsAssetsFlags(baseDeleteFlags); setRegion({}); + + authHandler = require('@contentstack/cli-utilities').authenticationHandler; }); afterEach(() => { @@ -47,7 +64,35 @@ describe('BulkCsAssets command', () => { describe('AM URL validation', () => { it('should set exitCode=1 when AM URL is not configured in region', async () => { - setRegion({}); // no csAssetsUrl + setRegion({}); // no csAssetsUrl + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('auth pre-flight', () => { + it('should set exitCode=1 and skip the API when no auth token is available', async () => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + sandbox.stub(authHandler, 'getAuthDetails').resolves(); + sandbox.stub(Object.getPrototypeOf(authHandler), 'accessToken').get(() => ''); + + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + const loadStub = sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile'); + const amServiceModule = require('../../../src/services/am-asset-service'); + const deleteStub = sandbox.stub(amServiceModule.CsAssetsService.prototype, 'bulkDelete'); + + await command.run(); + + expect(process.exitCode).to.equal(1); + expect(loadStub.called).to.be.false; // fails before reading any files + expect(deleteStub.called).to.be.false; + }); + + it('should set exitCode=1 when fetching auth details throws', async () => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + sandbox.stub(authHandler, 'getAuthDetails').rejects(new Error('not logged in')); await command.run(); @@ -57,8 +102,9 @@ describe('BulkCsAssets command', () => { 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' }; + setCsAssetsFlags({ ...baseMoveFlags, locale: 'en-us' }); setRegion({ csAssetsUrl: 'https://assets.example.com' }); + stubAuthSuccess(); // Stub the file loader to confirm it is NOT reached const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); @@ -67,12 +113,13 @@ describe('BulkCsAssets command', () => { await command.run(); expect(process.exitCode).to.equal(1); - expect(loadStub.called).to.be.false; // Should have exited before loading files + 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 }; + setCsAssetsFlags({ ...baseMoveFlags }); setRegion({ csAssetsUrl: 'https://assets.example.com' }); + stubAuthSuccess(); const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile').returns(['uid1', 'uid2']); @@ -92,6 +139,7 @@ describe('BulkCsAssets command', () => { describe('delete operation', () => { beforeEach(() => { setRegion({ csAssetsUrl: 'https://assets.example.com' }); + stubAuthSuccess(); }); it('should NOT set exitCode on successful delete', async () => { @@ -125,14 +173,30 @@ describe('BulkCsAssets command', () => { }); }); - describe('BaseCsAssetsCommand isolation — no publish/unpublish infrastructure', () => { - it('should not have bulkOperationConfig, queueManager, or managementStack on the instance', () => { - // BulkCsAssets extends BaseCsAssetsCommand, NOT BaseBulkCommand. - // None of these publish/unpublish properties should exist. + describe('CS Assets path isolation — no publish/unpublish infrastructure', () => { + it('should not touch bulkOperationConfig, queueManager, or managementStack when running delete/move', async () => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + stubAuthSuccess(); + + 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.CsAssetsService.prototype, 'bulkDelete').resolves({ success: true, jobId: 'j1' }); + + await command.run(); + + // The CS Assets path must never initialize the bulk-publish pipeline. 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; }); + + it('shouldSkipBulkPipeline() should reflect csAssetsMode', () => { + (command as any).csAssetsMode = true; + expect((command as any).shouldSkipBulkPipeline()).to.be.true; + (command as any).csAssetsMode = false; + expect((command as any).shouldSkipBulkPipeline()).to.be.false; + }); }); }); diff --git a/packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-init.test.ts b/packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-init.test.ts new file mode 100644 index 000000000..381347841 --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/commands/bulk-assets-init.test.ts @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { Command } from '@contentstack/cli-command'; +import BulkAssets from '../../../src/commands/cm/stacks/bulk-assets'; +import { BaseBulkCommand } from '../../../src/base-bulk-command'; +import { OperationFlagMatrixError } from '../../../src/utils/operation-flag-matrix'; + +/** + * Integration tests for the merged command's init() dispatch: operation + * resolution from argv, flag-matrix enforcement, pipeline selection, and the + * interactive prompt fallback. + */ +describe('BulkAssets command — init() dispatch', () => { + let sandbox: sinon.SinonSandbox; + let command: BulkAssets; + // The utils barrel re-exports via getters, so stubs must target the defining modules + let interactiveModule: any; + let logHandlerModule: any; + let cliUtilitiesModule: any; + let logStub: any; + let originalIsTTY: PropertyDescriptor | undefined; + + function setStdinTTY(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + } + + const csDeleteArgv = [ + '--operation', + 'delete', + '--space-uid', + 'sp1', + '--org-uid', + 'org1', + '--asset-uids-file', + './assets.json', + '--locale', + 'en-us', + '-y', + ]; + + function makeCommand(argv: string[]): BulkAssets { + const cmd = new BulkAssets(argv, {} as any); + (cmd as any).config = { + runHook: sandbox.stub().resolves(), + bin: 'test-bin', + version: '1.0.0', + }; + return cmd; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + cliUtilitiesModule = require('@contentstack/cli-utilities'); + logStub = { + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + debug: sandbox.stub(), + success: sandbox.stub(), + }; + sandbox.stub(cliUtilitiesModule, 'log').value(logStub); + sandbox.stub(cliUtilitiesModule, 'handleAndLogError').callsFake(() => {}); + sandbox.stub(cliUtilitiesModule, 'createLogContext').callsFake(() => {}); + + interactiveModule = require('../../../src/utils/interactive'); + logHandlerModule = require('../../../src/utils/bulk-operation-log-handler'); + originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + // Stub the framework-level init so no real oclif/CLI setup runs + sandbox.stub(Command.prototype, 'init' as any).resolves(); + }); + + afterEach(() => { + sandbox.restore(); + process.exitCode = undefined; + if (originalIsTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); + } else { + delete (process.stdin as any).isTTY; + } + }); + + describe('delete/move → CS Assets pipeline', () => { + it('skips the bulk pipeline entirely and fills CS Assets flags', async () => { + command = makeCommand([...csDeleteArgv]); + + const parsedFlags = { + operation: 'delete', + 'space-uid': 'sp1', + 'org-uid': 'org1', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: true, + }; + sandbox.stub(command as any, 'parse').resolves({ flags: parsedFlags }); + const fillStub = sandbox.stub(interactiveModule, 'fillMissingCsAssetsFlags').resolvesArg(0); + + const buildConfigSpy = sandbox.spy(command as any, 'buildConfiguration'); + const setupStackSpy = sandbox.spy(command as any, 'setupStack'); + + await (command as any).init(); + + expect((command as any).csAssetsMode).to.be.true; + expect((command as any).shouldSkipBulkPipeline()).to.be.true; + expect(fillStub.calledOnce).to.be.true; + expect((command as any).csAssetsFlags).to.deep.equal(parsedFlags); + // Bulk-publish pipeline must never run for CS Assets operations + expect(buildConfigSpy.called).to.be.false; + expect(setupStackSpy.called).to.be.false; + expect((command as any).queueManager).to.be.undefined; + expect((command as any).rateLimiter).to.be.undefined; + }); + }); + + describe('publish/unpublish → CMS pipeline', () => { + it('runs the full BaseBulkCommand pipeline', async () => { + command = makeCommand(['--operation', 'publish', '--environments', 'dev', '--locales', 'en-us', '-k', 'blt1']); + + sandbox.stub(command as any, 'parse').resolves({ + flags: { + operation: 'publish', + environments: ['dev'], + locales: ['en-us'], + 'stack-api-key': 'blt1', + }, + }); + sandbox.stub(interactiveModule, 'fillMissingFlags').resolvesArg(0); + sandbox.stub(logHandlerModule, 'clearLogs').returns(undefined); + const fillCsStub = sandbox.stub(interactiveModule, 'fillMissingCsAssetsFlags'); + + const buildConfigStub = sandbox.stub(command as any, 'buildConfiguration').callsFake(() => { + (command as any).bulkOperationConfig = { + operation: 'publish', + environments: ['dev'], + locales: ['en-us'], + bulkOperationFolder: '/mock/bulk-operation', + }; + return Promise.resolve(); + }); + const setupStackStub = sandbox.stub(command as any, 'setupStack').resolves(); + const initComponentsStub = sandbox.stub(command as any, 'initializeComponents').resolves(); + + await (command as any).init(); + + expect((command as any).csAssetsMode).to.be.false; + expect((command as any).shouldSkipBulkPipeline()).to.be.false; + expect(buildConfigStub.calledOnce).to.be.true; + expect(setupStackStub.calledOnce).to.be.true; + expect(initComponentsStub.calledOnce).to.be.true; + expect(fillCsStub.called).to.be.false; + }); + }); + + describe('flag-matrix enforcement', () => { + it('aborts init with OperationFlagMatrixError when a CMS flag is passed with delete', async () => { + command = makeCommand(['--operation', 'delete', '--environments', 'dev']); + const parseSpy = sandbox.spy(command as any, 'parse'); + + let thrown: unknown; + try { + await (command as any).init(); + } catch (e) { + thrown = e; + } + + expect(thrown).to.be.instanceOf(OperationFlagMatrixError); + expect(process.exitCode).to.equal(1); + expect(logStub.error.called).to.be.true; + expect(parseSpy.called).to.be.false; // aborted before any pipeline work + }); + + it('catch() swallows OperationFlagMatrixError without double-logging', async () => { + command = makeCommand([]); + const baseCatchStub = sandbox.stub(BaseBulkCommand.prototype, 'catch').resolves(); + + await (command as any).catch(new OperationFlagMatrixError(['--environments is not valid'])); + + expect(baseCatchStub.called).to.be.false; // violations already logged by enforce + }); + + it('catch() delegates other errors to the base handler', async () => { + command = makeCommand([]); + const baseCatchStub = sandbox.stub(BaseBulkCommand.prototype, 'catch').resolves(); + + await (command as any).catch(new Error('boom')); + + expect(baseCatchStub.calledOnce).to.be.true; + }); + }); + + describe('operation resolution', () => { + it('throws in non-TTY environments when --operation is missing', async () => { + command = makeCommand([]); + setStdinTTY(false); + + let thrown: unknown; + try { + await (command as any).init(); + } catch (e) { + thrown = e; + } + + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.include('--operation'); + }); + + it('prompts with all four operations and feeds the answer back into argv', async () => { + command = makeCommand([]); + setStdinTTY(true); + + const promptStub = sandbox.stub(interactiveModule, 'promptForOperation').resolves('delete'); + sandbox.stub(command as any, 'parse').resolves({ flags: { operation: 'delete' } }); + sandbox.stub(interactiveModule, 'fillMissingCsAssetsFlags').resolvesArg(0); + + await (command as any).init(); + + expect(promptStub.calledOnce).to.be.true; + const choices = promptStub.firstCall.args[0]; + expect(choices.map((c: any) => c.value)).to.deep.equal(['publish', 'unpublish', 'delete', 'move']); + expect((command as any).argv).to.include.members(['--operation', 'delete']); + expect((command as any).csAssetsMode).to.be.true; + }); + + it('does not prompt on the retry/revert path but still rejects CS Assets flags', async () => { + command = makeCommand(['--retry-failed', './bulk-operation', '--space-uid', 'sp1']); + const promptStub = sandbox.stub(interactiveModule, 'promptForOperation'); + + let thrown: unknown; + try { + await (command as any).init(); + } catch (e) { + thrown = e; + } + + expect(promptStub.called).to.be.false; + expect(thrown).to.be.instanceOf(OperationFlagMatrixError); + expect(logStub.error.firstCall.args[0]).to.include('--retry-failed/--revert'); + }); + }); +}); 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 371a23839..c75cfb1fa 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts @@ -472,13 +472,13 @@ describe('Interactive Prompts', () => { 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 }; + const flags = { operation: 'delete', workspace: 'main', yes: false }; try { await fillMissingCsAssetsFlags(flags); expect.fail('Should have thrown'); } catch (error: any) { - expect(error.message).to.include('--operation'); + expect(error.message).to.not.include('--operation,'); expect(error.message).to.include('--space-uid'); expect(error.message).to.include('--org-uid'); expect(error.message).to.include('--asset-uids-file'); @@ -525,13 +525,13 @@ describe('Interactive Prompts', () => { 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 = {}; + // operation is always resolved by the command's init() before this runs + const flags = { operation: 'delete' }; - 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) + inquireStub.onCall(0).resolves('sp123'); // space-uid + inquireStub.onCall(1).resolves('org456'); // org-uid + inquireStub.onCall(2).resolves('./assets.json'); // asset-uids-file + inquireStub.onCall(3).resolves('en-us'); // locale (delete-conditional) const result = await fillMissingCsAssetsFlags(flags); @@ -598,25 +598,22 @@ describe('Interactive Prompts', () => { expect(inquireStub.called).to.be.false; }); - it('should present delete/move choices for the operation prompt', async () => { + it('should never prompt for the operation (resolved by init() before this runs)', 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', - 'target-folder-uid': 'folderABC', }; - inquireStub.onCall(0).resolves('move'); // operation + inquireStub.onCall(0).resolves('en-us'); // locale await fillMissingCsAssetsFlags(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'); + const promptNames = inquireStub.getCalls().map((c: any) => c.args[0].name); + expect(promptNames).to.not.include('operation'); }); it('should validate that space-uid is not blank', async () => { diff --git a/packages/contentstack-bulk-operations/test/unit/utils/operation-flag-matrix.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/operation-flag-matrix.test.ts new file mode 100644 index 000000000..84a01cf8b --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/utils/operation-flag-matrix.test.ts @@ -0,0 +1,176 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { + validateOperationFlagMatrix, + enforceOperationFlagMatrix, + getOperationFromArgv, + OperationFlagMatrixError, + RETRY_REVERT_CONTEXT, +} from '../../../src/utils/operation-flag-matrix'; + +describe('operation-flag-matrix', () => { + describe('validateOperationFlagMatrix', () => { + describe('delete/move reject CMS-only flags', () => { + it('rejects --environments with delete', () => { + const violations = validateOperationFlagMatrix('delete', ['--operation', 'delete', '--environments', 'dev']); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--environments'); + expect(violations[0]).to.include('delete'); + }); + + it('rejects explicitly passed defaulted flags (--branch, --publish-mode) with delete', () => { + const violations = validateOperationFlagMatrix('delete', [ + '--operation', + 'delete', + '--branch', + 'dev', + '--publish-mode', + 'bulk', + ]); + expect(violations).to.have.lengthOf(2); + expect(violations.join(' ')).to.include('--branch'); + expect(violations.join(' ')).to.include('--publish-mode'); + }); + + it('does NOT trip on flag defaults when they are not in argv', () => { + // --branch defaults to "main" after parse, but was never passed + const violations = validateOperationFlagMatrix('delete', [ + '--operation', + 'delete', + '--space-uid', + 'sp1', + '--org-uid', + 'org1', + '--asset-uids-file', + './assets.json', + '--locale', + 'en-us', + '-y', + ]); + expect(violations).to.be.empty; + }); + + it('rejects short flags -k and -a with move', () => { + const violations = validateOperationFlagMatrix('move', ['-k', 'blt123', '-a', 'myAlias']); + expect(violations).to.have.lengthOf(2); + }); + + it('rejects --flag=value form', () => { + const violations = validateOperationFlagMatrix('delete', ['--environments=dev']); + expect(violations).to.have.lengthOf(1); + }); + + it('rejects --retry-failed and --revert with delete', () => { + const violations = validateOperationFlagMatrix('delete', ['--retry-failed', './log', '--revert', './log']); + expect(violations).to.have.lengthOf(2); + }); + + it('adds a did-you-mean hint for --locales with delete', () => { + const violations = validateOperationFlagMatrix('delete', ['--locales', 'en-us']); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--locale?'); + }); + + it('rejects --locale with move (delete-only flag within CS Assets pair)', () => { + const violations = validateOperationFlagMatrix('move', ['--locale', 'en-us']); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--locale'); + }); + }); + + describe('publish/unpublish reject CS Assets flags', () => { + it('rejects --space-uid with publish', () => { + const violations = validateOperationFlagMatrix('publish', ['--space-uid', 'sp1']); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--space-uid'); + }); + + it('adds a did-you-mean hint for --locale with publish', () => { + const violations = validateOperationFlagMatrix('publish', ['--locale', 'en-us']); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--locales?'); + }); + + it('accepts a normal publish invocation', () => { + const violations = validateOperationFlagMatrix('publish', [ + '--operation', + 'publish', + '--environments', + 'dev', + '--locales', + 'en-us', + '-k', + 'blt123', + ]); + expect(violations).to.be.empty; + }); + + it('rejects CS Assets flags on the retry/revert path (no operation given)', () => { + const violations = validateOperationFlagMatrix(RETRY_REVERT_CONTEXT, [ + '--retry-failed', + './log', + '--space-uid', + 'sp1', + ]); + expect(violations).to.have.lengthOf(1); + expect(violations[0]).to.include('--space-uid'); + // retry/revert is not an operation the user typed — message must not present it as one + expect(violations[0]).to.include('--retry-failed/--revert'); + expect(violations[0]).to.not.include('operation "retry/revert"'); + }); + }); + }); + + describe('enforceOperationFlagMatrix', () => { + let sandbox: sinon.SinonSandbox; + let logErrorStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + const cliUtilities = require('@contentstack/cli-utilities'); + logErrorStub = sandbox.stub(); + sandbox.stub(cliUtilities, 'log').value({ error: logErrorStub }); + }); + + afterEach(() => { + sandbox.restore(); + process.exitCode = undefined; + }); + + it('throws OperationFlagMatrixError and sets exitCode=1 on violations (no process.exit)', () => { + let thrown: unknown; + try { + enforceOperationFlagMatrix('delete', ['--environments', 'dev', '--branch', 'main']); + } catch (e) { + thrown = e; + } + + expect(thrown).to.be.instanceOf(OperationFlagMatrixError); + expect((thrown as OperationFlagMatrixError).violations).to.have.lengthOf(2); + expect(process.exitCode).to.equal(1); + expect(logErrorStub.callCount).to.equal(2); // each violation logged individually + }); + + it('does nothing on a valid flag set', () => { + enforceOperationFlagMatrix('delete', ['--operation', 'delete', '--space-uid', 'sp1']); + + expect(process.exitCode).to.not.equal(1); + expect(logErrorStub.called).to.be.false; + }); + }); + + describe('getOperationFromArgv', () => { + it('reads --operation value form', () => { + expect(getOperationFromArgv(['--operation', 'delete'])).to.equal('delete'); + }); + + it('reads --operation=value form', () => { + expect(getOperationFromArgv(['--operation=move'])).to.equal('move'); + }); + + it('returns undefined when absent', () => { + expect(getOperationFromArgv(['--retry-failed', './log'])).to.be.undefined; + }); + }); +});