Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -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'
29 changes: 29 additions & 0 deletions packages/contentstack-bulk-operations/src/base-am-command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
handleAndLogError(error);
}

abstract run(): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,14 @@ export abstract class BaseBulkCommand extends Command {

this.parsedFlags = flags;

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'];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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 { BaseAmCommand } from '../../../base-am-command';
import { AmAssetService } from '../../../services';
import {
loadAssetUidsFromFile,
Expand All @@ -18,7 +18,7 @@
/**
* 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 BaseAmCommand {
static description = messages.BULK_AM_ASSETS_DESCRIPTION;

static examples = [
Expand All @@ -31,23 +31,19 @@
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',
description: messages.AM_WORKSPACE_FLAG,
}),
'asset-uids-file': flags.string({
description: messages.AM_ASSET_UIDS_FILE_FLAG,
required: true,
}),
locale: flags.string({
description: messages.AM_LOCALE_FLAG,
Expand All @@ -62,7 +58,22 @@
}),
};

private readonly loggerContext = { module: COMMAND_ID };
private printAmSummary(op: 'delete' | 'move', opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string }): void {

Check failure on line 61 in packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts

View workflow job for this annotation

GitHub Actions / run-tests

Replace `op:·'delete'·|·'move',·opts:·{·jobId?:·string;·count?:·number;·folderUid?:·string;·notice?:·string;·error?:·string·}` with `⏎····op:·'delete'·|·'move',⏎····opts:·{·jobId?:·string;·count?:·number;·folderUid?:·string;·notice?:·string;·error?:·string·}⏎··`
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);

Check failure on line 72 in packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts

View workflow job for this annotation

GitHub Actions / run-tests

Replace `$t(messages.AM_MOVE_ASSETS_COUNT,·{·count:·opts.count,·folderUid:·opts.folderUid·}),·this.loggerContext` with `⏎··········$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;
Expand All @@ -79,7 +90,7 @@

async run(): Promise<void> {
try {
const { flags: f } = await this.parse(BulkAmAssets);
const f = this.parsedFlags;

const amBaseUrl = (this.region as RegionWithOptionalAmUrl).csAssetsUrl?.trim();
if (!amBaseUrl) {
Expand All @@ -95,26 +106,9 @@
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[];

Expand Down Expand Up @@ -166,16 +160,17 @@
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;
}

Expand Down Expand Up @@ -231,14 +226,11 @@
);
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);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/contentstack-bulk-operations/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum ResourceType {
ENTRY = 'entry',
ASSET = 'asset',
TAXONOMY = 'taxonomy',
AM_ASSET = 'am-asset',
}

export enum FilterType {
Expand Down Expand Up @@ -270,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;
Expand Down
19 changes: 17 additions & 2 deletions packages/contentstack-bulk-operations/src/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,25 @@
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.',

Check failure on line 241 in packages/contentstack-bulk-operations/src/messages/index.ts

View workflow job for this annotation

GitHub Actions / run-tests

Insert `⏎···`
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.',

Check failure on line 242 in packages/contentstack-bulk-operations/src/messages/index.ts

View workflow job for this annotation

GitHub Actions / run-tests

Insert `⏎···`
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):',
};

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/contentstack-bulk-operations/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +98,7 @@ export {
buildBulkModeResult,
handleOperationError,
fillMissingFlags,
fillMissingAmFlags,
fetchTaxonomyList,
RATE_LIMITER_CONSTANTS,
RETRY_STRATEGY_CONSTANTS,
Expand Down
111 changes: 111 additions & 0 deletions packages/contentstack-bulk-operations/src/utils/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,114 @@

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<void>>): Promise<void> {
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<any> {
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;

// 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',

Check failure on line 260 in packages/contentstack-bulk-operations/src/utils/interactive.ts

View workflow job for this annotation

GitHub Actions / run-tests

Replace `(f.operation·===·'delete'·&&·!f.locale)` with `f.operation·===·'delete'·&&·!f.locale`
(f.operation === 'move' && !f['target-folder-uid']) && '--target-folder-uid',

Check failure on line 261 in packages/contentstack-bulk-operations/src/utils/interactive.ts

View workflow job for this annotation

GitHub Actions / run-tests

Replace `(f.operation·===·'move'·&&·!f['target-folder-uid'])` with `f.operation·===·'move'·&&·!f['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.`
);
}

await runInteractivePrompts([
async () => {
if (!f.operation) {
f.operation = await cliux.inquire<string>({
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<string>({
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<string>({
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<string>({
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<string>({
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<string>({
type: 'input',
name: 'targetFolderUid',
message: messages.AM_ENTER_TARGET_FOLDER,
validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true),
});
}
},
]);

return f;
}
Loading
Loading