Skip to content
10 changes: 10 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,14 @@ fileignoreconfig:
checksum: f612661a8b6784b20ea62794b35192b340a34d8424a54a1a2cffec1878ab7528
- filename: packages/contentstack-import/test/unit/utils/marketplace-app-helper.test.ts
checksum: de694e861560c4c242100eaf17a6d8e230247b99f94e4b428e55ef065084c10c
- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts
checksum: 8bca423db4c3815c651ad922d986a53b6271cce3ea27f5987b66ee594151165e
- filename: packages/contentstack-bulk-operations/src/messages/index.ts
checksum: 07c2bf3f3130ad5e8b6a2971d76139e9b643c70a9ff5f7450adfb5c9bf3d7164
- 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: ""
117 changes: 109 additions & 8 deletions packages/contentstack-bulk-operations/src/base-bulk-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import chalk from 'chalk';
import { Command } from '@contentstack/cli-command';
import { flags, log, createLogContext, getLogPath, handleAndLogError, FlagInput } from '@contentstack/cli-utilities';
import {
flags,
log,
createLogContext,
getLogPath,
handleAndLogError,
FlagInput,
getChalk,
loadChalk,
configHandler,
CLIProgressManager,
clearProgressModuleSetting,
} from '@contentstack/cli-utilities';

import config from './config';
import messages, { $t } from './messages';
Expand Down Expand Up @@ -33,6 +44,7 @@ import {
generateBulkPublishStatusUrl,
validateBranch,
validateEnvironments,
aggregateBatchResults,
} from './utils';
import {
OperationType,
Expand Down Expand Up @@ -150,6 +162,9 @@ export abstract class BaseBulkCommand extends Command {
protected async init(): Promise<void> {
await super.init();

// Load chalk (ESM) up-front. The progress-module flag is set in buildConfig()
// (config layer, mirrors export-config-handler) before the first log call.
await loadChalk();
if (this.shouldSkipBulkPipeline()) {
return;
}
Expand Down Expand Up @@ -233,6 +248,9 @@ export abstract class BaseBulkCommand extends Command {
await this.initializeComponents();

await this.handleRevertOrRetry(mergedFlags);
// This early exit bypasses finally(); print the run summary and clear the progress-module
// flag here so it doesn't leak into the persisted config / later commands.
this.finalizeProgressSummary();
process.exit(0);
}

Expand Down Expand Up @@ -281,6 +299,13 @@ export abstract class BaseBulkCommand extends Command {
* Build operation configuration
*/
protected async buildConfiguration(flags: any): Promise<void> {
// Enable the progress-bar UI + suppress the timestamped console logs for the whole command.
// Set here (command lifecycle, runs before the first log call) rather than in the pure
// buildConfig() util so it isn't triggered by direct/unit-test callers of buildConfig.
// Cleared on every exit path: finally() on normal runs, and finalizeProgressSummary() before
// the validation exit(1) below and the revert/retry exit(0).
configHandler.set('log.progressSupportedModule', 'bulk-operations');

this.bulkOperationConfig = buildConfig(flags);

// buildConfig splits comma-separated oclif `multiple` values; mirror onto flags so
Expand All @@ -293,6 +318,9 @@ export abstract class BaseBulkCommand extends Command {

const validation = validateFlags(this.bulkOperationConfig);
if (!validation.valid) {
// The progress-module flag was set above; this early exit bypasses finally(), so clear it
// here to avoid leaking the setting into the persisted config / later commands.
this.finalizeProgressSummary();
process.exit(1);
}

Expand Down Expand Up @@ -378,20 +406,91 @@ export abstract class BaseBulkCommand extends Command {
this.logger.debug($t(messages.EXECUTING_OPERATION, { count: items.length }), this.loggerContext);
const startTime = Date.now();

// Initialize the run-level summary + header once (progress-manager UX, same as import).
this.beginOperationSummary(items.length);

let result: BulkOperationResult;
try {
logOperationInfo(items, this.logger);

const publishMode = this.bulkOperationConfig.publishMode || PublishMode.BULK;
this.logger.debug(`Using ${publishMode.toUpperCase()} mode for operation`, this.loggerContext);

if (publishMode === PublishMode.SINGLE) {
return await this.executeSingleMode(items, startTime);
}

return await this.executeBulkMode(items, startTime);
result =
publishMode === PublishMode.SINGLE
? await this.executeSingleMode(items, startTime)
: await this.executeBulkMode(items, startTime);
} catch (error: any) {
return handleOperationError(error, items, startTime);
result = handleOperationError(error, items, startTime);
}

this.recordModuleSummary(result, items.length);
return result;
}

/**
* Initialize the run-level summary + header once. The label includes the branch, which
* defaults to 'main' from the --branch flag, so it normally reads e.g. "BULK PUBLISH-main" —
* the same "<OP>-<branch>" title shape export/import produce (SummaryManager uses the passed
* operationName verbatim). The branch is dropped from the label only in the edge case where
* it is unset. Shared with bulk-taxonomies via inheritance.
*/
protected beginOperationSummary(itemCount: number): void {
const operationLabel = (this.bulkOperationConfig?.operation || 'operation').toString().toUpperCase();
const branchName = this.bulkOperationConfig?.branch || '';
CLIProgressManager.initializeGlobalSummary(
branchName ? `BULK ${operationLabel}-${branchName}` : `BULK ${operationLabel}`,
branchName,
$t(messages.EXECUTING_OPERATION, { count: itemCount })
);
}

/**
* Record a per-module row in the summary so the final summary shows Module Details for this
* command (entry/asset/taxonomy).
*
* SINGLE mode processes items individually and returns real success/failed counts, so those
* are used directly. BULK mode submits async jobs — buildBulkModeResult reports success/failed
* as 0 because the publish runs server-side — so we count submission-level failures from
* batchResults (recorded as status:'failed') and treat the remaining submitted items as
* success. The printed status URL remains the source of truth for the real publish outcome.
*/
protected recordModuleSummary(result: BulkOperationResult, submittedCount: number): void {
const showConsoleLogs = Boolean(configHandler.get('log')?.showConsoleLogs);
const publishMode = this.bulkOperationConfig?.publishMode || PublishMode.BULK;
const total = result?.total || submittedCount || 0;

let success: number;
let failed: number;
if (publishMode === PublishMode.SINGLE) {
failed = result?.failed || 0;
success = typeof result?.success === 'number' ? result.success : Math.max(total - failed, 0);
} else {
// BULK: derive failures from batch submissions (result.failed is always 0 here).
failed = aggregateBatchResults(this.batchResults).totalFailed;
success = Math.max(total - failed, 0);
}

// Clamp so counts never over/under-report relative to total.
failed = Math.min(Math.max(failed, 0), total);
success = Math.min(Math.max(success, 0), total - failed);

const progress = CLIProgressManager.createSimple(this.resourceType, total, showConsoleLogs);
Comment thread
harshitha-cstk marked this conversation as resolved.
for (let i = 0; i < success; i++) progress.tick(true);
for (let i = 0; i < failed; i++) progress.tick(false);
progress.complete(failed === 0);
}

/**
* Print the run-level summary once and clear progress state. Idempotent: subclasses call
* finally() explicitly AND oclif calls it again, so clearing the summary after printing makes
* the second invocation a no-op. Also clears the progress-module flag so it never leaks into
* a later command in the same process (mirrors export/import/clone).
*/
protected finalizeProgressSummary(): void {
CLIProgressManager.printGlobalSummary();
CLIProgressManager.clearGlobalSummary();
clearProgressModuleSetting();
}

/**
Expand Down Expand Up @@ -457,6 +556,7 @@ export abstract class BaseBulkCommand extends Command {
* Called at the end of run() method in subclasses
*/
protected printOperationSummary(result: BulkOperationResult): void {
const chalk = getChalk();
const publishMode = this.bulkOperationConfig.publishMode || PublishMode.BULK;

console.log('');
Expand Down Expand Up @@ -568,6 +668,7 @@ export abstract class BaseBulkCommand extends Command {
abstract run(): Promise<void>;

protected async finally(_error: Error | undefined): Promise<void> {
this.finalizeProgressSummary();
await this.cleanup();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from '@contentstack/cli-command';
import { flags, log, createLogContext, handleAndLogError } from '@contentstack/cli-utilities';
import { flags, log, createLogContext, handleAndLogError, loadChalk } from '@contentstack/cli-utilities';

import messages, { $t } from '../../../messages';
import { BaseBulkCommand } from '../../../base-bulk-command';
Expand Down Expand Up @@ -58,6 +58,10 @@ export default class BulkTaxonomies extends BaseBulkCommand {
// Call oclif Command init without running BaseBulkCommand.init (taxonomy uses its own prompts).
await (Command.prototype as unknown as { init(this: Command): Promise<void> }).init.call(this);

// Load chalk (ESM) up-front. The progress-module flag is set in buildConfig() (invoked by
// buildConfiguration below), before the first log call.
await loadChalk();

let { flags: parsed } = await this.parse(BulkTaxonomies);

if (parsed.revert || parsed['retry-failed']) {
Expand Down Expand Up @@ -161,6 +165,9 @@ export default class BulkTaxonomies extends BaseBulkCommand {
this.logger.debug($t(messages.EXECUTING_OPERATION, { count: items.length }), this.loggerContext);
const startTime = Date.now();

// Initialize the run-level summary + header once (inherited from BaseBulkCommand).
this.beginOperationSummary(items.length);

const operation = this.bulkOperationConfig.operation;
if (operation !== OperationType.PUBLISH && operation !== OperationType.UNPUBLISH) {
throw new Error($t(messages.UNSUPPORTED_OPERATION, { operation: operation ?? 'unknown' }));
Expand Down Expand Up @@ -190,12 +197,17 @@ export default class BulkTaxonomies extends BaseBulkCommand {
this.logger.info(String(response.notice));
}

return {
const result: BulkOperationResult = {
success: 0,
failed: 0,
total: items.length,
duration,
jobIds: jobId ? [jobId] : [],
};

// Record the taxonomy module row in the summary (inherited from BaseBulkCommand).
this.recordModuleSummary(result, items.length);

return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import sinon from 'sinon';
import { Command } from '@contentstack/cli-command';
import messages, { $t } from '../../src/messages';
import { BaseBulkCommand } from '../../src/base-bulk-command';
import { ResourceType, BulkOperationResult } from '../../src/interfaces';
import { ResourceType, BulkOperationResult, PublishMode } from '../../src/interfaces';
import * as utils from '../../src/utils';

class TestBulkCommand extends BaseBulkCommand {
Expand Down Expand Up @@ -521,6 +521,135 @@ describe('BaseBulkCommand', () => {
});
});

describe('progress manager (summary + cleanup)', () => {
let cliUtils: any;
let progressStub: { tick: sinon.SinonStub; complete: sinon.SinonStub };

beforeEach(() => {
cliUtils = require('@contentstack/cli-utilities');
progressStub = { tick: sandbox.stub(), complete: sandbox.stub() };
sandbox.stub(cliUtils.CLIProgressManager, 'createSimple').returns(progressStub as any);
sandbox.stub(cliUtils.CLIProgressManager, 'initializeGlobalSummary').returns({} as any);
sandbox.stub(cliUtils.CLIProgressManager, 'printGlobalSummary').callsFake(() => {});
sandbox.stub(cliUtils.CLIProgressManager, 'clearGlobalSummary').callsFake(() => {});
// clearProgressModuleSetting is a frozen re-export (not stubbable); let the real one run
// and drive/assert it through configHandler instead.
sandbox.stub(cliUtils.configHandler, 'get').returns({ showConsoleLogs: false });
sandbox.stub(cliUtils.configHandler, 'set').callsFake(() => {});
});

describe('beginOperationSummary', () => {
it('builds a "BULK <OP>-<branch>" label and passes the branch', () => {
(command as any).bulkOperationConfig = { operation: 'publish', branch: 'main' };

(command as any).beginOperationSummary(5);

expect(cliUtils.CLIProgressManager.initializeGlobalSummary.calledOnce).to.be.true;
const args = cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args;
expect(args[0]).to.equal('BULK PUBLISH-main');
expect(args[1]).to.equal('main');
});

it('omits the branch suffix when the branch is unset', () => {
(command as any).bulkOperationConfig = { operation: 'unpublish', branch: '' };

(command as any).beginOperationSummary(0);

expect(cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args[0]).to.equal('BULK UNPUBLISH');
});

it('does not throw when bulkOperationConfig is undefined', () => {
(command as any).bulkOperationConfig = undefined;

expect(() => (command as any).beginOperationSummary(3)).to.not.throw();
expect(cliUtils.CLIProgressManager.initializeGlobalSummary.firstCall.args[0]).to.equal('BULK OPERATION');
});
});

describe('recordModuleSummary', () => {
it('SINGLE mode: uses the real success/failed counts from the result', () => {
(command as any).bulkOperationConfig = { publishMode: PublishMode.SINGLE };

(command as any).recordModuleSummary({ success: 8, failed: 2, total: 10 } as BulkOperationResult, 10);

expect(cliUtils.CLIProgressManager.createSimple.calledWith(ResourceType.ENTRY, 10)).to.be.true;
expect(progressStub.tick.withArgs(true).callCount).to.equal(8);
expect(progressStub.tick.withArgs(false).callCount).to.equal(2);
expect(progressStub.complete.calledWith(false)).to.be.true;
});

it('BULK mode: derives failures from batchResults and counts the rest as submitted', () => {
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
// buildBulkModeResult reports success:0/failed:0; failures live in batchResults.
(command as any).batchResults = new Map<string, any>([
['b1', { status: 'failed', success: 0, failed: 50, jobId: '' }],
['b2', { status: 'success', success: 0, failed: 0, jobId: 'job-2' }],
]);

(command as any).recordModuleSummary({ success: 0, failed: 0, total: 126 } as BulkOperationResult, 126);

// failed = 50 (from batchResults); success = 126 - 50 = 76
expect(progressStub.tick.withArgs(true).callCount).to.equal(76);
expect(progressStub.tick.withArgs(false).callCount).to.equal(50);
expect(progressStub.complete.calledWith(false)).to.be.true;
});

it('BULK mode: with no submission failures, all submitted items count as success', () => {
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
(command as any).batchResults = new Map<string, any>([
['b1', { status: 'success', success: 0, failed: 0, jobId: 'job-1' }],
]);

(command as any).recordModuleSummary({ success: 0, failed: 0, total: 50 } as BulkOperationResult, 50);

expect(progressStub.tick.withArgs(true).callCount).to.equal(50);
expect(progressStub.tick.withArgs(false).callCount).to.equal(0);
expect(progressStub.complete.calledWith(true)).to.be.true;
});

it('clamps counts so failures never exceed the total', () => {
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
(command as any).batchResults = new Map<string, any>([
['b1', { status: 'failed', success: 0, failed: 999, jobId: '' }],
]);

(command as any).recordModuleSummary({ success: 0, failed: 0, total: 10 } as BulkOperationResult, 10);

expect(progressStub.tick.withArgs(false).callCount).to.equal(10); // clamped to total
expect(progressStub.tick.withArgs(true).callCount).to.equal(0);
});

it('falls back to submittedCount when the result has no total', () => {
(command as any).bulkOperationConfig = { publishMode: PublishMode.BULK };
(command as any).batchResults = new Map<string, any>();

(command as any).recordModuleSummary({ success: 0, failed: 0 } as BulkOperationResult, 7);

expect(cliUtils.CLIProgressManager.createSimple.calledWith(ResourceType.ENTRY, 7)).to.be.true;
expect(progressStub.tick.withArgs(true).callCount).to.equal(7);
});
});

describe('finalizeProgressSummary', () => {
it('prints the summary and clears it', () => {
(command as any).finalizeProgressSummary();

expect(cliUtils.CLIProgressManager.printGlobalSummary.calledOnce).to.be.true;
expect(cliUtils.CLIProgressManager.clearGlobalSummary.calledOnce).to.be.true;
});

it('clears the persisted progress-module flag', () => {
// Simulate the flag being set, then verify clearProgressModuleSetting removes it.
cliUtils.configHandler.get.returns({ progressSupportedModule: 'bulk-operations', showConsoleLogs: false });

(command as any).finalizeProgressSummary();

expect(cliUtils.configHandler.set.calledWith('log', sinon.match((v: any) => !('progressSupportedModule' in v)))).to
.be.true;
});
});
});

describe('printOperationSummary', () => {
beforeEach(() => {
// Initialize bulkOperationConfig required for printOperationSummary
Expand Down
Loading