Skip to content

Commit 8c5fbf7

Browse files
Merge remote-tracking branch 'origin/fix/console-log-policy' into fix/DX-10224-bulk-error-visibility
2 parents d7e7cc3 + 3031164 commit 8c5fbf7

15 files changed

Lines changed: 665 additions & 78 deletions

File tree

‎.talismanrc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,9 @@ fileignoreconfig:
55
version: '1.0'
66
- filename: .github/workflows/release-production-pipeline.yml
77
checksum: dd858a2c2a3297c5651c2843ddae73ad0776a0386329200d01b051ddc489871e
8+
- filename: packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts
9+
checksum: be7e833dc0abbbb211dc71f7970d8a1c680860f978dc2b871bcc9a30a659f436
10+
- filename: packages/contentstack-utilities/test/unit/logger.test.ts
11+
checksum: ca0bbc2838a0b6a069d8fc4874e465dd30dd35377a6202eb005eb43e5ff5d871
12+
- filename: packages/contentstack-utilities/test/unit/cliProgressManager.test.ts
13+
checksum: 6df6c21e1188b077a83a0464aecc4c628941a3fbb3bdea99db18d30b5bd3cf71

‎packages/contentstack-utilities/.mocharc.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"require": [
33
"test/helpers/init.js",
4+
"test/helpers/mock-ora.js",
45
"ts-node/register",
56
"source-map-support/register",
67
"test/helpers/mocha-root-hooks.js"

‎packages/contentstack-utilities/src/constants/logging.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,3 @@ export const levelColors = {
1515
info: 'white',
1616
debug: 'blue',
1717
};
18-
19-
export const PROGRESS_SUPPORTED_MODULES = ['export', 'import', 'audit', 'import-setup', 'clone', 'bulk-operations'] as const;

‎packages/contentstack-utilities/src/helpers.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,16 @@ const sensitiveKeys = [
255255
/delivery[-._]?token/i,
256256
];
257257

258+
/**
259+
* @deprecated No-op, kept only so the plugins that still call it keep compiling.
260+
*
261+
* Console visibility is no longer derived from `log.progressSupportedModule` — it is a
262+
* process-wide policy resolved once by the core CLI's `console-policy` init hook (see
263+
* `logger/console-policy.ts`), so there is nothing left to clear. Remove this export a
264+
* release after the plugin call sites are gone.
265+
*/
258266
export function clearProgressModuleSetting(): void {
259-
const logConfig = configHandler.get('log') || {};
260-
if (logConfig?.progressSupportedModule) {
261-
delete logConfig.progressSupportedModule;
262-
configHandler.set('log', logConfig);
263-
}
267+
// Intentionally empty.
264268
}
265269

266270
/**

‎packages/contentstack-utilities/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export type { ChalkInstance } from './chalk';
8282
export { Logger };
8383
export { default as authenticationHandler } from './authentication-handler';
8484
export { v2Logger as log, cliErrorHandler, handleAndLogError, getLogPath, getSessionLogPath } from './logger/log';
85+
// NOTE Only the reader is exported. `setConsoleLogPolicy` stays off the index so that
86+
// nothing downstream of the core CLI's `console-policy` init hook can override the
87+
// decision — the hook imports the setter by deep path.
88+
export { isConsoleLogEnabled } from './logger/console-policy';
8589
export {
8690
CLIProgressManager,
8791
SummaryManager,

‎packages/contentstack-utilities/src/interfaces/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export interface ProcessProgress {
140140
}
141141

142142
export interface ProgressManagerOptions {
143+
/**
144+
* Defaults to the process-wide console-log policy (`isConsoleLogEnabled()`), which is
145+
* what production code should rely on. Pass it explicitly only to drive the two modes
146+
* directly, e.g. from tests.
147+
*/
143148
showConsoleLogs?: boolean;
144149
total?: number;
145150
moduleName?: string;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Console-log policy.
3+
*
4+
* Whether the CLI writes log lines to the console is a single process-wide
5+
* decision, resolved once from static inputs (env var → user config → the
6+
* plugin's `csdxConfig.showConsoleLogs` declaration → `false`) by the
7+
* `console-policy` init hook in the core CLI, before any command code runs.
8+
*
9+
* The default is `false`: the logger writes diagnostics to files and knows
10+
* nothing about the screen. Console output is an opt-in verbosity feature, and
11+
* because the progress UI and the log stream are two consumers of one terminal,
12+
* enabling it also turns the progress UI off (see `CLIProgressManager`).
13+
*
14+
* This module holds no disk state, so it is free to consult per message. The
15+
* decision is deliberately *not* frozen on first read — freezing a value that
16+
* arrives late is the bug this policy replaces.
17+
*
18+
* `setConsoleLogPolicy` is intentionally absent from the package index. The core
19+
* CLI imports it by deep path (`@contentstack/cli-utilities/lib/logger/console-policy`);
20+
* a plugin importing from the index has no setter to call, which makes "nothing
21+
* downstream may override the policy" structural rather than a runtime lock.
22+
*/
23+
24+
let enabled = false;
25+
26+
export function setConsoleLogPolicy(value: boolean): void {
27+
enabled = value;
28+
}
29+
30+
export function isConsoleLogEnabled(): boolean {
31+
return enabled;
32+
}
33+
34+
/** Test-only: restore the default (files-only) policy between cases. */
35+
export function resetConsoleLogPolicy(): void {
36+
enabled = false;
37+
}

‎packages/contentstack-utilities/src/logger/logger.ts‎

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import traverse from 'traverse';
22
import { klona } from 'klona/full';
33
import { normalize } from 'path';
44
import * as winston from 'winston';
5-
import { levelColors, logLevels, PROGRESS_SUPPORTED_MODULES } from '../constants/logging';
5+
import { levelColors, logLevels } from '../constants/logging';
66
import { LoggerConfig, LogLevel, LogType } from '../interfaces/index';
7-
import { configHandler } from '..';
87
import { getSessionLogPath } from './session-path';
8+
import { isConsoleLogEnabled } from './console-policy';
99

1010
export default class Logger {
1111
private loggers: Record<string, winston.Logger>;
@@ -69,39 +69,31 @@ export default class Logger {
6969
}),
7070
];
7171

72-
// Determine console logging based on configuration
73-
let showConsoleLogs = true;
74-
if (configHandler && typeof configHandler.get === 'function') {
75-
const logConfig = configHandler.get('log') || {};
76-
const currentModule = logConfig.progressSupportedModule;
77-
const hasProgressSupport = currentModule && PROGRESS_SUPPORTED_MODULES.includes(currentModule);
78-
79-
if (hasProgressSupport) {
80-
// Plugin has progress bars - respect user's explicit setting, or default to false (show progress bars)
81-
showConsoleLogs = logConfig.showConsoleLogs ?? false;
82-
} else {
83-
// Plugin doesn't have progress support - always show console logs
84-
showConsoleLogs = true;
85-
}
86-
}
87-
88-
if (showConsoleLogs) {
89-
transports.push(
90-
new winston.transports.Console({
91-
format: winston.format.combine(
92-
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
93-
winston.format.printf((info) => {
94-
// Apply full redaction for console (user-facing)
95-
const redactedInfo = this.redact(info, true);
96-
const colorizer = winston.format.colorize();
97-
const levelText = redactedInfo.level.toUpperCase();
98-
const { timestamp, message } = redactedInfo;
99-
return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`);
100-
}),
101-
),
102-
}),
103-
);
104-
}
72+
// The Console transport is always attached; whether it emits is decided per
73+
// message by the console-log policy, never by the transport list. The filter
74+
// below is the FIRST format in the chain so that a `false` return short-circuits
75+
// the write before timestamp/printf run.
76+
//
77+
// It must return `info` (not `{}`) on the enabled path: logform's `combine` feeds
78+
// each format's return value into the next, so returning a fresh object would
79+
// *replace* `info` and the printf below would emit blank lines instead of the
80+
// message. Returning `false` is winston-transport's documented skip signal.
81+
transports.push(
82+
new winston.transports.Console({
83+
format: winston.format.combine(
84+
winston.format((info) => (isConsoleLogEnabled() ? info : false))(),
85+
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
86+
winston.format.printf((info) => {
87+
// Apply full redaction for console (user-facing)
88+
const redactedInfo = this.redact(info, true);
89+
const colorizer = winston.format.colorize();
90+
const levelText = redactedInfo.level.toUpperCase();
91+
const { timestamp, message } = redactedInfo;
92+
return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`);
93+
}),
94+
),
95+
}),
96+
);
10597

10698
return winston.createLogger({
10799
levels: logLevels,

‎packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import ora, { Ora } from 'ora';
33
import ProgressBar from 'cli-progress';
44
import SummaryManager from './summary-manager';
55
import { ProcessProgress, ProgressManagerOptions, Failure } from '../interfaces';
6-
import { configHandler } from '..';
76
import { ProgressStrategyRegistry } from './progress-strategy';
7+
import { isConsoleLogEnabled } from '../logger/console-policy';
88

99
interface ProgressCallback {
1010
onModuleStart?: (moduleName: string) => void;
@@ -37,7 +37,7 @@ export default class CLIProgressManager {
3737
private branchName: string;
3838

3939
constructor({
40-
showConsoleLogs = false,
40+
showConsoleLogs = isConsoleLogEnabled(),
4141
total = 0,
4242
moduleName = 'Module',
4343
enableNestedProgress = false,
@@ -70,7 +70,7 @@ export default class CLIProgressManager {
7070
CLIProgressManager.globalSummary = new SummaryManager({ operationName, context: { branchName } });
7171

7272
// Only show header if console logs are disabled (progress UI mode)
73-
if (!configHandler.get('log')?.showConsoleLogs) {
73+
if (!isConsoleLogEnabled()) {
7474
CLIProgressManager.displayOperationHeader(branchName, headerTitle);
7575
}
7676

@@ -167,32 +167,44 @@ export default class CLIProgressManager {
167167

168168
/**
169169
* Create a simple progress manager (no nested processes)
170+
*
171+
* @param _showConsoleLogs Deprecated and ignored. Console visibility is a process-wide
172+
* policy (`isConsoleLogEnabled()`), which the constructor reads itself — a caller must
173+
* not be able to override it. The parameter is retained only so the plugins still
174+
* passing it keep compiling, and is removed once those call sites are gone.
170175
*/
171-
static createSimple(moduleName: string, total?: number, showConsoleLogs = false): CLIProgressManager {
176+
static createSimple(moduleName: string, total?: number, _showConsoleLogs?: boolean): CLIProgressManager {
172177
return new CLIProgressManager({
173178
moduleName: moduleName.toUpperCase(),
174179
total: total || 0,
175-
showConsoleLogs,
176180
enableNestedProgress: false,
177181
});
178182
}
179183

180184
/**
181185
* Create a nested progress manager (with sub-processes)
186+
*
187+
* @param _showConsoleLogs Deprecated and ignored — see `createSimple`.
182188
*/
183-
static createNested(moduleName: string, showConsoleLogs = false): CLIProgressManager {
189+
static createNested(moduleName: string, _showConsoleLogs?: boolean): CLIProgressManager {
184190
return new CLIProgressManager({
185191
moduleName: moduleName.toUpperCase(),
186192
total: 0,
187-
showConsoleLogs,
188193
enableNestedProgress: true,
189194
});
190195
}
191196

192197
/**
193-
* Show a loading spinner before initializing progress
198+
* Show a loading spinner while an async operation runs.
199+
*
200+
* A spinner is part of the progress UI, so it is skipped entirely when console logs own
201+
* the terminal — callers do not need to branch on the policy themselves.
194202
*/
195203
static async withLoadingSpinner<T>(message: string, asyncOperation: () => Promise<T>): Promise<T> {
204+
if (isConsoleLogEnabled()) {
205+
return asyncOperation();
206+
}
207+
196208
const spinner = ora(message).start();
197209
try {
198210
const result = await asyncOperation();
@@ -288,6 +300,10 @@ export default class CLIProgressManager {
288300
}
289301

290302
private initializeProgress(): void {
303+
// Console logs and the progress UI are mutually exclusive: exactly one of them owns
304+
// the terminal. When console logging is on, no renderer is created at all — not
305+
// suppressed, not buffered, never constructed. Everything below is therefore reached
306+
// only in progress-UI mode.
291307
if (this.showConsoleLogs) {
292308
return;
293309
}
@@ -305,13 +321,9 @@ export default class CLIProgressManager {
305321
ProgressBar.Presets.shades_classic,
306322
);
307323

308-
if (!this.showConsoleLogs) {
309-
console.log(getChalk().bold.cyan(`\n${this.moduleName}:`));
310-
}
324+
console.log(getChalk().bold.cyan(`\n${this.moduleName}:`));
311325
} else if (this.total > 0) {
312-
if (!this.showConsoleLogs) {
313-
console.log(getChalk().bold.cyan(`\n${this.moduleName}:`));
314-
}
326+
console.log(getChalk().bold.cyan(`\n${this.moduleName}:`));
315327

316328
this.progressBar = new ProgressBar.SingleBar({
317329
format: ' {label} |' + getChalk().cyan('{bar}') + '| {percentage}% | {value}/{total} | {status}',
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Install the `ora` mock before any test file is loaded.
3+
*
4+
* Mocha loads every test file up front, so a `Module.prototype.require` interception set up
5+
* inside one test file is already too late: an alphabetically earlier file will have pulled
6+
* `cli-progress-manager` — and with it the real `ora` — into the require cache, and the
7+
* module-scope binding it captured can no longer be replaced. Intercepting here, from a
8+
* `--require` entry, is the only point that runs before all of them, which is what makes
9+
* spinner assertions deterministic whether a file runs alone or as part of the suite.
10+
*/
11+
const sinon = require('sinon');
12+
const Module = require('module');
13+
14+
const mockOraInstance = {
15+
start: sinon.stub().returnsThis(),
16+
stop: sinon.stub().returnsThis(),
17+
succeed: sinon.stub().returnsThis(),
18+
fail: sinon.stub().returnsThis(),
19+
warn: sinon.stub().returnsThis(),
20+
info: sinon.stub().returnsThis(),
21+
text: '',
22+
color: 'cyan',
23+
isSpinning: false,
24+
};
25+
26+
const mockOra = sinon.stub().returns(mockOraInstance);
27+
mockOra.promise = sinon.stub().returns(mockOraInstance);
28+
29+
const originalRequire = Module.prototype.require;
30+
Module.prototype.require = function (id) {
31+
if (id === 'ora') {
32+
return mockOra;
33+
}
34+
return originalRequire.apply(this, arguments);
35+
};
36+
37+
module.exports = { mockOra, mockOraInstance };

0 commit comments

Comments
 (0)