From 2b67a6e08ba1097dc91afe0e0cae9213cea5a78d Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 15:24:24 +0530 Subject: [PATCH 1/4] implemented console policy --- .talismanrc | 6 + packages/contentstack-utilities/.mocharc.json | 1 + .../src/constants/logging.ts | 2 - .../contentstack-utilities/src/helpers.ts | 14 +- packages/contentstack-utilities/src/index.ts | 4 + .../src/interfaces/index.ts | 5 + .../src/logger/console-policy.ts | 37 ++++ .../src/logger/logger.ts | 67 +++---- .../progress-summary/cli-progress-manager.ts | 40 ++-- .../test/helpers/mock-ora.js | 37 ++++ .../test/unit/cliProgressManager.test.ts | 181 ++++++++++++++++-- .../test/unit/logger.test.ts | 153 ++++++++++----- packages/contentstack/package.json | 1 + .../src/hooks/init/console-policy.ts | 75 ++++++++ .../test/unit/console-policy-hook.test.ts | 154 +++++++++++++++ 15 files changed, 648 insertions(+), 129 deletions(-) create mode 100644 packages/contentstack-utilities/src/logger/console-policy.ts create mode 100644 packages/contentstack-utilities/test/helpers/mock-ora.js create mode 100644 packages/contentstack/src/hooks/init/console-policy.ts create mode 100644 packages/contentstack/test/unit/console-policy-hook.test.ts diff --git a/.talismanrc b/.talismanrc index 9ddfed208b..77da462a29 100644 --- a/.talismanrc +++ b/.talismanrc @@ -5,3 +5,9 @@ fileignoreconfig: version: '1.0' - filename: .github/workflows/release-production-pipeline.yml checksum: dd858a2c2a3297c5651c2843ddae73ad0776a0386329200d01b051ddc489871e +- filename: packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts + checksum: be7e833dc0abbbb211dc71f7970d8a1c680860f978dc2b871bcc9a30a659f436 +- filename: packages/contentstack-utilities/test/unit/logger.test.ts + checksum: ca0bbc2838a0b6a069d8fc4874e465dd30dd35377a6202eb005eb43e5ff5d871 +- filename: packages/contentstack-utilities/test/unit/cliProgressManager.test.ts + checksum: 6df6c21e1188b077a83a0464aecc4c628941a3fbb3bdea99db18d30b5bd3cf71 diff --git a/packages/contentstack-utilities/.mocharc.json b/packages/contentstack-utilities/.mocharc.json index 3f2da8ca68..416c1daedb 100644 --- a/packages/contentstack-utilities/.mocharc.json +++ b/packages/contentstack-utilities/.mocharc.json @@ -1,6 +1,7 @@ { "require": [ "test/helpers/init.js", + "test/helpers/mock-ora.js", "ts-node/register", "source-map-support/register", "test/helpers/mocha-root-hooks.js" diff --git a/packages/contentstack-utilities/src/constants/logging.ts b/packages/contentstack-utilities/src/constants/logging.ts index 985f739fd4..5fe4e39696 100644 --- a/packages/contentstack-utilities/src/constants/logging.ts +++ b/packages/contentstack-utilities/src/constants/logging.ts @@ -15,5 +15,3 @@ export const levelColors = { info: 'white', debug: 'blue', }; - -export const PROGRESS_SUPPORTED_MODULES = ['export', 'import', 'audit', 'import-setup', 'clone', 'bulk-operations'] as const; diff --git a/packages/contentstack-utilities/src/helpers.ts b/packages/contentstack-utilities/src/helpers.ts index 3e94d77e93..ced5822e67 100644 --- a/packages/contentstack-utilities/src/helpers.ts +++ b/packages/contentstack-utilities/src/helpers.ts @@ -255,12 +255,16 @@ const sensitiveKeys = [ /delivery[-._]?token/i, ]; +/** + * @deprecated No-op, kept only so the plugins that still call it keep compiling. + * + * Console visibility is no longer derived from `log.progressSupportedModule` — it is a + * process-wide policy resolved once by the core CLI's `console-policy` init hook (see + * `logger/console-policy.ts`), so there is nothing left to clear. Remove this export a + * release after the plugin call sites are gone. + */ export function clearProgressModuleSetting(): void { - const logConfig = configHandler.get('log') || {}; - if (logConfig?.progressSupportedModule) { - delete logConfig.progressSupportedModule; - configHandler.set('log', logConfig); - } + // Intentionally empty. } /** diff --git a/packages/contentstack-utilities/src/index.ts b/packages/contentstack-utilities/src/index.ts index e41db792e0..a21e4b62c9 100644 --- a/packages/contentstack-utilities/src/index.ts +++ b/packages/contentstack-utilities/src/index.ts @@ -82,6 +82,10 @@ export type { ChalkInstance } from './chalk'; export { Logger }; export { default as authenticationHandler } from './authentication-handler'; export { v2Logger as log, cliErrorHandler, handleAndLogError, getLogPath, getSessionLogPath } from './logger/log'; +// NOTE Only the reader is exported. `setConsoleLogPolicy` stays off the index so that +// nothing downstream of the core CLI's `console-policy` init hook can override the +// decision — the hook imports the setter by deep path. +export { isConsoleLogEnabled } from './logger/console-policy'; export { CLIProgressManager, SummaryManager, diff --git a/packages/contentstack-utilities/src/interfaces/index.ts b/packages/contentstack-utilities/src/interfaces/index.ts index 30e684a9af..7a21973d34 100644 --- a/packages/contentstack-utilities/src/interfaces/index.ts +++ b/packages/contentstack-utilities/src/interfaces/index.ts @@ -140,6 +140,11 @@ export interface ProcessProgress { } export interface ProgressManagerOptions { + /** + * Defaults to the process-wide console-log policy (`isConsoleLogEnabled()`), which is + * what production code should rely on. Pass it explicitly only to drive the two modes + * directly, e.g. from tests. + */ showConsoleLogs?: boolean; total?: number; moduleName?: string; diff --git a/packages/contentstack-utilities/src/logger/console-policy.ts b/packages/contentstack-utilities/src/logger/console-policy.ts new file mode 100644 index 0000000000..4bea044f79 --- /dev/null +++ b/packages/contentstack-utilities/src/logger/console-policy.ts @@ -0,0 +1,37 @@ +/** + * Console-log policy. + * + * Whether the CLI writes log lines to the console is a single process-wide + * decision, resolved once from static inputs (env var → user config → the + * plugin's `csdxConfig.showConsoleLogs` declaration → `false`) by the + * `console-policy` init hook in the core CLI, before any command code runs. + * + * The default is `false`: the logger writes diagnostics to files and knows + * nothing about the screen. Console output is an opt-in verbosity feature, and + * because the progress UI and the log stream are two consumers of one terminal, + * enabling it also turns the progress UI off (see `CLIProgressManager`). + * + * This module holds no disk state, so it is free to consult per message. The + * decision is deliberately *not* frozen on first read — freezing a value that + * arrives late is the bug this policy replaces. + * + * `setConsoleLogPolicy` is intentionally absent from the package index. The core + * CLI imports it by deep path (`@contentstack/cli-utilities/lib/logger/console-policy`); + * a plugin importing from the index has no setter to call, which makes "nothing + * downstream may override the policy" structural rather than a runtime lock. + */ + +let enabled = false; + +export function setConsoleLogPolicy(value: boolean): void { + enabled = value; +} + +export function isConsoleLogEnabled(): boolean { + return enabled; +} + +/** Test-only: restore the default (files-only) policy between cases. */ +export function resetConsoleLogPolicy(): void { + enabled = false; +} diff --git a/packages/contentstack-utilities/src/logger/logger.ts b/packages/contentstack-utilities/src/logger/logger.ts index b8be997e07..f1a2542133 100644 --- a/packages/contentstack-utilities/src/logger/logger.ts +++ b/packages/contentstack-utilities/src/logger/logger.ts @@ -2,10 +2,10 @@ import traverse from 'traverse'; import { klona } from 'klona/full'; import { normalize } from 'path'; import * as winston from 'winston'; -import { levelColors, logLevels, PROGRESS_SUPPORTED_MODULES } from '../constants/logging'; +import { levelColors, logLevels } from '../constants/logging'; import { LoggerConfig, LogLevel, LogType } from '../interfaces/index'; -import { configHandler } from '..'; import { getSessionLogPath } from './session-path'; +import { isConsoleLogEnabled } from './console-policy'; export default class Logger { private loggers: Record; @@ -69,44 +69,31 @@ export default class Logger { }), ]; - // Determine console logging based on configuration - let showConsoleLogs = true; - if (configHandler && typeof configHandler.get === 'function') { - const logConfig = configHandler.get('log') || {}; - const currentModule = logConfig.progressSupportedModule; - const hasProgressSupport = currentModule && PROGRESS_SUPPORTED_MODULES.includes(currentModule); - - if (hasProgressSupport) { - // Plugin has progress bars - respect user's explicit setting, or default to false (show progress bars) - showConsoleLogs = logConfig.showConsoleLogs ?? false; - } else { - // Plugin doesn't have progress support - always show console logs - showConsoleLogs = true; - } - } - - // Errors and warnings must always reach the console, even when progress bars - // suppress info/success/debug output — otherwise failures (e.g. an invalid - // stack API key or a taxonomy error) are silently swallowed in progress mode. - const isErrorOrWarn = level === 'error' || level === 'warn'; - - if (showConsoleLogs || isErrorOrWarn) { - transports.push( - new winston.transports.Console({ - format: winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.printf((info) => { - // Apply full redaction for console (user-facing) - const redactedInfo = this.redact(info, true); - const colorizer = winston.format.colorize(); - const levelText = redactedInfo.level.toUpperCase(); - const { timestamp, message } = redactedInfo; - return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`); - }), - ), - }), - ); - } + // The Console transport is always attached; whether it emits is decided per + // message by the console-log policy, never by the transport list. The filter + // below is the FIRST format in the chain so that a `false` return short-circuits + // the write before timestamp/printf run. + // + // It must return `info` (not `{}`) on the enabled path: logform's `combine` feeds + // each format's return value into the next, so returning a fresh object would + // *replace* `info` and the printf below would emit blank lines instead of the + // message. Returning `false` is winston-transport's documented skip signal. + transports.push( + new winston.transports.Console({ + format: winston.format.combine( + winston.format((info) => (isConsoleLogEnabled() ? info : false))(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf((info) => { + // Apply full redaction for console (user-facing) + const redactedInfo = this.redact(info, true); + const colorizer = winston.format.colorize(); + const levelText = redactedInfo.level.toUpperCase(); + const { timestamp, message } = redactedInfo; + return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`); + }), + ), + }), + ); return winston.createLogger({ levels: logLevels, diff --git a/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts index 07a9dd25b3..cd56cc7623 100644 --- a/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts +++ b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts @@ -3,8 +3,8 @@ import ora, { Ora } from 'ora'; import ProgressBar from 'cli-progress'; import SummaryManager from './summary-manager'; import { ProcessProgress, ProgressManagerOptions, Failure } from '../interfaces'; -import { configHandler } from '..'; import { ProgressStrategyRegistry } from './progress-strategy'; +import { isConsoleLogEnabled } from '../logger/console-policy'; interface ProgressCallback { onModuleStart?: (moduleName: string) => void; @@ -37,7 +37,7 @@ export default class CLIProgressManager { private branchName: string; constructor({ - showConsoleLogs = false, + showConsoleLogs = isConsoleLogEnabled(), total = 0, moduleName = 'Module', enableNestedProgress = false, @@ -70,7 +70,7 @@ export default class CLIProgressManager { CLIProgressManager.globalSummary = new SummaryManager({ operationName, context: { branchName } }); // Only show header if console logs are disabled (progress UI mode) - if (!configHandler.get('log')?.showConsoleLogs) { + if (!isConsoleLogEnabled()) { CLIProgressManager.displayOperationHeader(branchName, headerTitle); } @@ -167,32 +167,44 @@ export default class CLIProgressManager { /** * Create a simple progress manager (no nested processes) + * + * @param _showConsoleLogs Deprecated and ignored. Console visibility is a process-wide + * policy (`isConsoleLogEnabled()`), which the constructor reads itself — a caller must + * not be able to override it. The parameter is retained only so the plugins still + * passing it keep compiling, and is removed once those call sites are gone. */ - static createSimple(moduleName: string, total?: number, showConsoleLogs = false): CLIProgressManager { + static createSimple(moduleName: string, total?: number, _showConsoleLogs?: boolean): CLIProgressManager { return new CLIProgressManager({ moduleName: moduleName.toUpperCase(), total: total || 0, - showConsoleLogs, enableNestedProgress: false, }); } /** * Create a nested progress manager (with sub-processes) + * + * @param _showConsoleLogs Deprecated and ignored — see `createSimple`. */ - static createNested(moduleName: string, showConsoleLogs = false): CLIProgressManager { + static createNested(moduleName: string, _showConsoleLogs?: boolean): CLIProgressManager { return new CLIProgressManager({ moduleName: moduleName.toUpperCase(), total: 0, - showConsoleLogs, enableNestedProgress: true, }); } /** - * Show a loading spinner before initializing progress + * Show a loading spinner while an async operation runs. + * + * A spinner is part of the progress UI, so it is skipped entirely when console logs own + * the terminal — callers do not need to branch on the policy themselves. */ static async withLoadingSpinner(message: string, asyncOperation: () => Promise): Promise { + if (isConsoleLogEnabled()) { + return asyncOperation(); + } + const spinner = ora(message).start(); try { const result = await asyncOperation(); @@ -288,6 +300,10 @@ export default class CLIProgressManager { } private initializeProgress(): void { + // Console logs and the progress UI are mutually exclusive: exactly one of them owns + // the terminal. When console logging is on, no renderer is created at all — not + // suppressed, not buffered, never constructed. Everything below is therefore reached + // only in progress-UI mode. if (this.showConsoleLogs) { return; } @@ -305,13 +321,9 @@ export default class CLIProgressManager { ProgressBar.Presets.shades_classic, ); - if (!this.showConsoleLogs) { - console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); - } + console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); } else if (this.total > 0) { - if (!this.showConsoleLogs) { - console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); - } + console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); this.progressBar = new ProgressBar.SingleBar({ format: ' {label} |' + getChalk().cyan('{bar}') + '| {percentage}% | {value}/{total} | {status}', diff --git a/packages/contentstack-utilities/test/helpers/mock-ora.js b/packages/contentstack-utilities/test/helpers/mock-ora.js new file mode 100644 index 0000000000..c7dd35d1b7 --- /dev/null +++ b/packages/contentstack-utilities/test/helpers/mock-ora.js @@ -0,0 +1,37 @@ +/** + * Install the `ora` mock before any test file is loaded. + * + * Mocha loads every test file up front, so a `Module.prototype.require` interception set up + * inside one test file is already too late: an alphabetically earlier file will have pulled + * `cli-progress-manager` — and with it the real `ora` — into the require cache, and the + * module-scope binding it captured can no longer be replaced. Intercepting here, from a + * `--require` entry, is the only point that runs before all of them, which is what makes + * spinner assertions deterministic whether a file runs alone or as part of the suite. + */ +const sinon = require('sinon'); +const Module = require('module'); + +const mockOraInstance = { + start: sinon.stub().returnsThis(), + stop: sinon.stub().returnsThis(), + succeed: sinon.stub().returnsThis(), + fail: sinon.stub().returnsThis(), + warn: sinon.stub().returnsThis(), + info: sinon.stub().returnsThis(), + text: '', + color: 'cyan', + isSpinning: false, +}; + +const mockOra = sinon.stub().returns(mockOraInstance); +mockOra.promise = sinon.stub().returns(mockOraInstance); + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id) { + if (id === 'ora') { + return mockOra; + } + return originalRequire.apply(this, arguments); +}; + +module.exports = { mockOra, mockOraInstance }; diff --git a/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts index ab6f20ab35..40bc97efa1 100644 --- a/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts +++ b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts @@ -2,21 +2,11 @@ import { expect } from 'chai'; import { fancy } from 'fancy-test'; import sinon from 'sinon'; -//NOTE:- Mock ora BEFORE any imports to prevent real spinners -const mockOraInstance = { - start: sinon.stub().returnsThis(), - stop: sinon.stub().returnsThis(), - succeed: sinon.stub().returnsThis(), - fail: sinon.stub().returnsThis(), - warn: sinon.stub().returnsThis(), - info: sinon.stub().returnsThis(), - text: '', - color: 'cyan', - isSpinning: false, -}; - -const mockOra = sinon.stub().returns(mockOraInstance); -(mockOra as any).promise = sinon.stub().returns(mockOraInstance); +//NOTE:- The ora mock is installed from `test/helpers/mock-ora.js`, a mocharc `--require` +// entry, because mocha loads every test file before running any of them: an interception set +// up here would already be too late for the real ora pulled in by an earlier file. Reuse that +// module's stubs so assertions see the spinner the code under test actually got. +const { mockOra, mockOraInstance } = require('../helpers/mock-ora'); // Mock require.cache to intercept ora module loading const Module = require('module'); @@ -56,9 +46,15 @@ Module.prototype.require = function (id: string) { return originalRequire.apply(this, arguments); }; +// NOTE `configHandler` is imported through the package index on purpose. `config-handler.ts` +// imports `cliux` back from the barrel, so the module graph has to be entered at the index — +// the same way every consumer enters it — or `auth-handler`'s module-scope initialisation +// runs against a half-built `configHandler`. This file used to get that ordering by accident, +// via the `import { configHandler } from '..'` that `cli-progress-manager` no longer needs. +import { configHandler } from '../../src'; import CLIProgressManager from '../../src/progress-summary/cli-progress-manager'; import SummaryManager from '../../src/progress-summary/summary-manager'; -import configHandler from '../../src/config-handler'; +import { setConsoleLogPolicy, resetConsoleLogPolicy } from '../../src/logger/console-policy'; // Optimized cleanup function for fast tests function forceCleanupSpinners() { @@ -85,7 +81,11 @@ describe('CLIProgressManager', () => { beforeEach(() => { forceCleanupSpinners(); - + + // The console-log policy is a module-level singleton, so reset it or cases leak into + // each other. + resetConsoleLogPolicy(); + // Mock require.cache to intercept ora and cli-progress module loading Module.prototype.require = function (id: string) { if (id === 'ora') { @@ -107,6 +107,7 @@ describe('CLIProgressManager', () => { Module.prototype.require = originalRequire; forceCleanupSpinners(); CLIProgressManager.clearGlobalSummary(); + resetConsoleLogPolicy(); }); beforeEach(() => { @@ -223,7 +224,7 @@ describe('CLIProgressManager', () => { }); fancy.it('should create simple progress manager', () => { - const simple = CLIProgressManager.createSimple('testModule', 50, true); + const simple = CLIProgressManager.createSimple('testModule', 50); try { expect(simple).to.be.instanceOf(CLIProgressManager); } finally { @@ -236,7 +237,7 @@ describe('CLIProgressManager', () => { }); fancy.it('should create nested progress manager', () => { - const nested = CLIProgressManager.createNested('testModule', false); + const nested = CLIProgressManager.createNested('testModule'); try { expect(nested).to.be.instanceOf(CLIProgressManager); } finally { @@ -516,6 +517,148 @@ describe('CLIProgressManager', () => { }); }); + // Console logs and the progress UI are two consumers of one terminal, so exactly one of + // them may own it. These cases assert the manager reads that decision from the policy + // itself, which is what makes interleaved output unreachable by configuration. + describe('Console log policy (mutual exclusion with the progress UI)', () => { + fancy.it('constructs no renderer for a simple manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createSimple('MUTEX_SIMPLE', 25); + + expect(progressManager['progressBar'], 'progress bar').to.be.null; + expect(progressManager['multiBar'], 'multi bar').to.be.null; + expect(progressManager['spinner'], 'spinner').to.be.null; + }); + + fancy.it('constructs no renderer for a nested manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createNested('MUTEX_NESTED'); + + expect(progressManager['progressBar'], 'progress bar').to.be.null; + expect(progressManager['multiBar'], 'multi bar').to.be.null; + expect(progressManager['spinner'], 'spinner').to.be.null; + }); + + fancy.it('constructs no spinner for a total-less manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createSimple('MUTEX_SPINNER'); + + expect(progressManager['spinner'], 'spinner').to.be.null; + expect(mockOra.called, 'ora must not be constructed at all').to.be.false; + }); + + fancy.it('builds the expected renderer for each shape when console logs are off', () => { + const simple = CLIProgressManager.createSimple('UI_SIMPLE', 25); + const nested = CLIProgressManager.createNested('UI_NESTED'); + const spinning = CLIProgressManager.createSimple('UI_SPINNER'); + + try { + expect(simple['progressBar'], 'a bounded module gets a single bar').to.not.be.null; + expect(nested['multiBar'], 'a nested module gets a multi bar').to.not.be.null; + expect(spinning['spinner'], 'an unbounded module gets a spinner').to.not.be.null; + } finally { + [simple, nested, spinning].forEach((m) => { + try { + m.stop(); + } catch (e) { + // ignore + } + }); + } + }); + + // The factories keep a third parameter only so the plugins still passing it compile; + // it must not be able to reintroduce the caller-threaded flag this policy replaced. + fancy.it('ignores a showConsoleLogs argument passed to the factories', () => { + setConsoleLogPolicy(true); + const forcedOff = CLIProgressManager.createSimple('OVERRIDE_ATTEMPT', 25, false); + + try { + expect(forcedOff['progressBar'], 'a caller must not be able to force the UI on').to.be.null; + expect(forcedOff['showConsoleLogs']).to.equal(true); + } finally { + try { + forcedOff.stop(); + } catch (e) { + // ignore + } + } + + resetConsoleLogPolicy(); + const forcedOn = CLIProgressManager.createNested('OVERRIDE_ATTEMPT_2', true); + + try { + expect(forcedOn['multiBar'], 'a caller must not be able to force the UI off').to.not.be.null; + expect(forcedOn['showConsoleLogs']).to.equal(false); + } finally { + try { + forcedOn.stop(); + } catch (e) { + // ignore + } + } + }); + + fancy.it('withLoadingSpinner runs the action without a spinner when console logs are on', async () => { + setConsoleLogPolicy(true); + let ran = false; + + const result = await CLIProgressManager.withLoadingSpinner('loading', async () => { + ran = true; + return 'done'; + }); + + expect(ran, 'the action must still run').to.be.true; + expect(result).to.equal('done'); + expect(mockOra.called, 'no spinner may be started in console-log mode').to.be.false; + }); + + fancy.it('withLoadingSpinner starts and stops a spinner when console logs are off', async () => { + mockOraInstance.start.resetHistory(); + mockOraInstance.stop.resetHistory(); + + const result = await CLIProgressManager.withLoadingSpinner('loading', async () => 'done'); + + expect(result).to.equal('done'); + expect(mockOra.calledWith('loading'), 'spinner must be created').to.be.true; + expect(mockOraInstance.start.called, 'spinner must be started').to.be.true; + expect(mockOraInstance.stop.called, 'spinner must be stopped').to.be.true; + }); + + fancy.it('withLoadingSpinner stops the spinner and rethrows when the action fails', async () => { + mockOraInstance.stop.resetHistory(); + + try { + await CLIProgressManager.withLoadingSpinner('loading', async () => { + throw new Error('action failed'); + }); + expect.fail('the error should have propagated'); + } catch (error: any) { + expect(error.message).to.equal('action failed'); + } + + expect(mockOraInstance.stop.called, 'spinner must be stopped on failure too').to.be.true; + }); + + fancy.it('skips the operation header when console logs are on', () => { + setConsoleLogPolicy(true); + consoleLogStub.resetHistory(); + + CLIProgressManager.initializeGlobalSummary('TEST_OPERATION', 'main', 'MAIN CONTENT'); + + const printedHeader = consoleLogStub.getCalls().some((call) => call.args[0]?.includes?.('MAIN CONTENT')); + expect(printedHeader, 'the header is progress UI and must not print').to.be.false; + }); + + fancy.it('prints the operation header when console logs are off', () => { + consoleLogStub.resetHistory(); + + CLIProgressManager.initializeGlobalSummary('TEST_OPERATION', 'main', 'MAIN CONTENT'); + + const printedHeader = consoleLogStub.getCalls().some((call) => call.args[0]?.includes?.('MAIN CONTENT')); + expect(printedHeader, 'the header belongs to progress UI mode').to.be.true; + }); + }); describe('Logging and Console Output', () => { beforeEach(() => { progressManager = new CLIProgressManager({ diff --git a/packages/contentstack-utilities/test/unit/logger.test.ts b/packages/contentstack-utilities/test/unit/logger.test.ts index 0bd8f3992b..ccdff9c1eb 100644 --- a/packages/contentstack-utilities/test/unit/logger.test.ts +++ b/packages/contentstack-utilities/test/unit/logger.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as os from 'os'; import Logger from '../../src/logger/logger'; import { getSessionLogPath, clearSessionLogPathCache } from '../../src/logger/session-path'; +import { setConsoleLogPolicy, resetConsoleLogPolicy } from '../../src/logger/console-policy'; import configHandler from '../../src/config-handler'; describe('Logger', () => { @@ -233,67 +234,121 @@ describe('Logger', () => { }); }); -describe('Console output in progress-manager mode', () => { - const tempDir = path.join(os.tmpdir(), `csdx-progress-log-${Date.now()}`); +describe('Console log policy', () => { + const tempDir = path.join(os.tmpdir(), `csdx-console-policy-log-${Date.now()}`); + + // A progress-supporting module is stubbed on purpose: it is the configuration under + // which the *old* transport-list logic suppressed console output for info/success. + // Keeping it here means these cases exercise the policy and nothing else. + const stubLogConfig = (...args: any[]) => { + const key = args[0]; + if (key === 'log') return { progressSupportedModule: 'export' }; + if (key === 'log.path') return tempDir; + if (key === 'currentCommandId') return 'export'; + if (key === 'sessionId') return 'test-session'; + return undefined; + }; + + function transportOf(winLogger: any, name: 'Console' | 'File'): any { + return winLogger.transports.find((t: any) => t.constructor && t.constructor.name === name); + } - function hasConsoleTransport(winLogger: any): boolean { - return winLogger.transports.some((t: any) => t.constructor && t.constructor.name === 'Console'); + /** Spy a transport's `log`, so a format returning `false` shows up as zero calls. */ + function spyOnTransport(winLogger: any, name: 'Console' | 'File'): sinon.SinonSpy { + const transport = transportOf(winLogger, name); + expect(transport, `${name} transport should be attached to every logger`).to.exist; + return sinon.stub(transport, 'log').callsFake(((_info: any, next?: () => void) => { + if (typeof next === 'function') next(); + }) as any); } - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for errors when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].error)).to.equal(true); + beforeEach(() => { + resetConsoleLogPolicy(); + }); + + afterEach(() => { + resetConsoleLogPolicy(); + sinon.restore(); + }); + + const levels: Array<{ level: string; call: (l: Logger) => void }> = [ + { level: 'info', call: (l) => l.info('an info line') }, + { level: 'success', call: (l) => l.success('a success line') }, + { level: 'error', call: (l) => l.error('an error line') }, + { level: 'warn', call: (l) => l.warn('a warn line') }, + ]; + + levels.forEach(({ level, call }) => { + fancy.stub(configHandler, 'get', stubLogConfig).it(`suppresses ${level} on the console when the policy is off`, () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'][level], 'Console'); + + call(logger); + + expect(consoleSpy.callCount, `${level} should not reach the console by default`).to.equal(0); + }); + + fancy.stub(configHandler, 'get', stubLogConfig).it(`emits ${level} on the console when the policy is on`, () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'][level], 'Console'); + setConsoleLogPolicy(true); + + call(logger); + + expect(consoleSpy.callCount, `${level} should reach the console when opted in`).to.equal(1); }); + }); fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for warnings when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].warn)).to.equal(true); + .stub(configHandler, 'get', stubLogConfig) + .it('still writes to the file transport while the console is silent', () => { + // The File transports were never gated by the removed `showConsoleLogs || isErrorOrWarn` + // logic, so inspecting log files by hand would pass regardless. Asserting both + // transports from one call is what actually proves the file path survived. + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].error, 'Console'); + const fileSpy = spyOnTransport(logger['loggers'].error, 'File'); + + logger.error('a file-only error'); + + expect(fileSpy.callCount, 'file transport must still receive the message').to.equal(1); + expect(consoleSpy.callCount, 'console transport must stay silent').to.equal(0); }); fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('suppresses console output for info logs so progress bars stay clean', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(false); + .stub(configHandler, 'get', stubLogConfig) + .it('emits console output for a message logged with `logError`', () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].error, 'Console'); + setConsoleLogPolicy(true); + + logger.logError({ type: 'TestError', message: 'structured failure', error: new Error('boom') }); + + expect(consoleSpy.callCount).to.equal(1); }); + // Regression test for the original bug: console visibility used to be baked into the + // winston transport list at construction time, so any `log.*` access from an init or + // prerun hook froze the decision before the value arrived. It must now follow the + // policy no matter when the Logger was built. fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: true }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('shows console output for info logs when console logging is enabled', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(true); + .stub(configHandler, 'get', stubLogConfig) + .it('is not frozen at construction — a policy set after the Logger is built still applies', () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].info, 'Console'); + + // Constructed while the policy was off: the old code would have omitted the + // Console transport for `info` outright, and no later change could revive it. + logger.info('before opting in'); + expect(consoleSpy.callCount, 'policy was still off').to.equal(0); + + setConsoleLogPolicy(true); + logger.info('after opting in'); + expect(consoleSpy.callCount, 'the decision must follow the policy, not construction time').to.equal(1); + + setConsoleLogPolicy(false); + logger.info('after opting back out'); + expect(consoleSpy.callCount, 'turning the policy off again must take effect too').to.equal(1); }); }); diff --git a/packages/contentstack/package.json b/packages/contentstack/package.json index 7a06a25865..fefa70a80d 100755 --- a/packages/contentstack/package.json +++ b/packages/contentstack/package.json @@ -157,6 +157,7 @@ "./lib/hooks/prerun/plan-guard" ], "init": [ + "./lib/hooks/init/console-policy", "./lib/hooks/init/region-refresh", "./lib/hooks/init/context-init", "./lib/hooks/init/utils-init", diff --git a/packages/contentstack/src/hooks/init/console-policy.ts b/packages/contentstack/src/hooks/init/console-policy.ts new file mode 100644 index 0000000000..883269d7fe --- /dev/null +++ b/packages/contentstack/src/hooks/init/console-policy.ts @@ -0,0 +1,75 @@ +import { configHandler } from '@contentstack/cli-utilities'; +import { setConsoleLogPolicy } from '@contentstack/cli-utilities/lib/logger/console-policy'; + +/** + * Resolve whether the CLI writes log lines to the console, once, before any command code + * runs. Registered first among the `init` hooks so the pre-policy window is `Config.load()` + * alone, during which no first-party code runs. + * + * Console output is opt-in; the default is files only. Three static sources may enable it + * and nothing downstream may override the result — the setter is deliberately reachable + * only by deep path, not from the package index. Precedence, highest first: + * + * 1. `CS_CLI_CONSOLE_LOGS` (`1`/`true` → on, `0`/`false` → off). Read at process start, so + * it cannot lose a race with hook ordering. Also the way to force console logs *off*. + * 2. User config `log.showConsoleLogs`, honoured **only when `true`** — see below. + * 3. The command's plugin declaring `csdxConfig.showConsoleLogs` in its `package.json`. + * 4. Default `false`. + * + * A persisted `log.showConsoleLogs: false` is treated as "no opinion" rather than an + * override. Every user who has run `cm:stacks:audit` carries that value on disk, written as + * a side effect; since `false` *is* the default it conveys no intent the default doesn't + * already provide, and letting it win would silently mute every plugin that declares + * console logs, with no visible cause. Ignore the value; never rewrite the user's file. + * + * Because the progress UI and the log stream are two consumers of one terminal, enabling + * console logs also turns the progress UI off — see `CLIProgressManager.initializeProgress`. + */ +export default function (opts: { id?: string }): void { + setConsoleLogPolicy(resolveConsoleLogPolicy(opts, this?.config)); +} + +/** + * The precedence chain, split out from the hook so it can be exercised directly with a + * fake oclif `config`. + */ + +export function resolveConsoleLogPolicy(opts: { id?: string }, config: any): boolean { + // 1. Environment variable — the only source that can force console logs off. + const fromEnv = parseBoolean(process.env.CS_CLI_CONSOLE_LOGS); + if (fromEnv !== undefined) return fromEnv; + + // 2. User config, on `true` only. + try { + if (configHandler.get('log')?.showConsoleLogs === true) return true; + } catch { + // a broken config file must not stop the CLI from starting + } + + // 3. The plugin owning this command. Resolved straight from `pjson.csdxConfig` with plain + // oclif API rather than via `config.context`, so this hook neither depends on how the + // context is built nor has to be re-run wherever the context is rebuilt. + // + // `findCommand` returns nothing for an id oclif has yet to correct (a mistyped command), + // in which case the declaration simply cannot be read and the default applies. That is + // accepted: the invocation is about to be re-dispatched, and adding a second resolution + // point for one decision is the failure mode this policy exists to remove. + try { + const command = config?.findCommand?.(opts?.id) || {}; + const plugin = (config?.plugins || new Map()).get(command.pluginName) || {}; + if (plugin.pjson?.csdxConfig?.showConsoleLogs === true) return true; + } catch { + // never block CLI startup on plugin resolution + } + + // 4. Default: files only. + return false; +} + +function parseBoolean(value?: string): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return true; + if (normalized === '0' || normalized === 'false') return false; + return undefined; +} diff --git a/packages/contentstack/test/unit/console-policy-hook.test.ts b/packages/contentstack/test/unit/console-policy-hook.test.ts new file mode 100644 index 0000000000..b03ab611c7 --- /dev/null +++ b/packages/contentstack/test/unit/console-policy-hook.test.ts @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { configHandler } from '@contentstack/cli-utilities'; +import { + isConsoleLogEnabled, + resetConsoleLogPolicy, +} from '@contentstack/cli-utilities/lib/logger/console-policy'; +import consolePolicyHook, { resolveConsoleLogPolicy } from '../../src/hooks/init/console-policy'; + +describe('console-policy init hook', () => { + let configGetStub: sinon.SinonStub; + const originalEnv = process.env.CS_CLI_CONSOLE_LOGS; + + /** A fake oclif config shaped like the one the hook receives at init time. */ + function fakeConfig(declared?: boolean, pluginName = '@contentstack/cli-cm-export') { + return { + findCommand: (id: string) => (id === 'cm:stacks:export' ? { pluginName } : undefined), + plugins: new Map([[pluginName, { pjson: { csdxConfig: { showConsoleLogs: declared } } }]]), + }; + } + + const exportCommand = { id: 'cm:stacks:export' }; + + /** `log.showConsoleLogs` as it sits in the user's config file. */ + function userConfig(showConsoleLogs?: boolean) { + configGetStub.withArgs('log').returns(showConsoleLogs === undefined ? {} : { showConsoleLogs }); + } + + beforeEach(() => { + delete process.env.CS_CLI_CONSOLE_LOGS; + configGetStub = sinon.stub(configHandler, 'get'); + userConfig(undefined); + resetConsoleLogPolicy(); + }); + + afterEach(() => { + sinon.restore(); + resetConsoleLogPolicy(); + if (originalEnv === undefined) delete process.env.CS_CLI_CONSOLE_LOGS; + else process.env.CS_CLI_CONSOLE_LOGS = originalEnv; + }); + + describe('precedence', () => { + it('defaults to false — files only', () => { + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + + it('honours a plugin declaring csdxConfig.showConsoleLogs', () => { + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('honours user config set to true, over a plugin that declares nothing', () => { + userConfig(true); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + + it('lets the env var beat user config', () => { + userConfig(true); + process.env.CS_CLI_CONSOLE_LOGS = '0'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + + it('lets the env var beat a plugin declaration', () => { + process.env.CS_CLI_CONSOLE_LOGS = '1'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + + ['1', 'true', 'TRUE', ' true '].forEach((value) => { + it(`reads CS_CLI_CONSOLE_LOGS=${JSON.stringify(value)} as on`, () => { + process.env.CS_CLI_CONSOLE_LOGS = value; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + }); + + ['0', 'false', 'FALSE'].forEach((value) => { + it(`reads CS_CLI_CONSOLE_LOGS=${JSON.stringify(value)} as off`, () => { + process.env.CS_CLI_CONSOLE_LOGS = value; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(false); + }); + }); + + it('ignores an unparseable env var and falls through', () => { + process.env.CS_CLI_CONSOLE_LOGS = 'maybe'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + }); + + // The half of the fix that protects users who already ran `cm:stacks:audit`, which used to + // persist `log.showConsoleLogs: false` into their config file as a side effect. That value + // must read as "no opinion", never as an override of a plugin's declaration. + describe('legacy `false` in user config falls through', () => { + it('does not override a plugin declaring true', () => { + userConfig(false); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('treats undefined the same way', () => { + userConfig(undefined); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('still leaves force-off available through the env var', () => { + userConfig(false); + process.env.CS_CLI_CONSOLE_LOGS = '0'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(false); + }); + + it('resolves to the default when nothing declares anything', () => { + userConfig(false); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + }); + + describe('degrades quietly', () => { + // oclif corrects a mistyped id *after* init, so `findCommand` cannot resolve the plugin + // and its declaration is lost for that one run. Accepted: the invocation is about to be + // re-dispatched, and a second resolution point is the very thing this hook removes. + it('falls back without throwing when the command id does not resolve', () => { + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:exportt' }, fakeConfig(true))).to.equal(false); + }); + + it('still honours the env var for an unresolvable command id', () => { + process.env.CS_CLI_CONSOLE_LOGS = '1'; + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:exportt' }, fakeConfig(true))).to.equal(true); + }); + + it('survives a missing config, a missing id and a missing plugins map', () => { + expect(resolveConsoleLogPolicy({}, undefined)).to.equal(false); + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:export' }, {})).to.equal(false); + expect(resolveConsoleLogPolicy(exportCommand, { findCommand: () => ({ pluginName: 'nope' }) })).to.equal(false); + }); + + it('survives a configHandler that throws', () => { + configGetStub.withArgs('log').throws(new Error('unreadable config file')); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + }); + + describe('applying the policy', () => { + it('sets the process-wide policy from the resolved value', () => { + expect(isConsoleLogEnabled()).to.equal(false); + + consolePolicyHook.call({ config: fakeConfig(true) }, exportCommand); + + expect(isConsoleLogEnabled()).to.equal(true); + }); + + it('leaves the policy off when nothing enables it', () => { + consolePolicyHook.call({ config: fakeConfig(undefined) }, exportCommand); + + expect(isConsoleLogEnabled()).to.equal(false); + }); + }); +}); From 30311649bfa1309b43d226ccc47f359afaac7a64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 19 Aug 2026 12:16:09 +0530 Subject: [PATCH 2/4] removed repeated plan guard hook --- packages/contentstack/package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/contentstack/package.json b/packages/contentstack/package.json index fefa70a80d..cbd444bad2 100755 --- a/packages/contentstack/package.json +++ b/packages/contentstack/package.json @@ -153,8 +153,7 @@ "./lib/hooks/prerun/init-context-for-command", "./lib/hooks/prerun/plan-guard", "./lib/hooks/prerun/default-rate-limit-check", - "./lib/hooks/prerun/latest-version-warning", - "./lib/hooks/prerun/plan-guard" + "./lib/hooks/prerun/latest-version-warning" ], "init": [ "./lib/hooks/init/console-policy", @@ -170,4 +169,4 @@ "url": "git+https://github.com/contentstack/cli.git", "directory": "packages/contentstack" } -} +} \ No newline at end of file From 12a3be578bc7871cc4d57710f53ffd7d03c35238 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 21 Aug 2026 14:36:54 +0530 Subject: [PATCH 3/4] fixed logger policy feature --- .../src/commands/auth/login.ts | 9 +++++++++ .../src/commands/auth/logout.ts | 12 +++++++++++ .../src/commands/auth/whoami.ts | 9 ++++++++- .../src/commands/config/remove/proxy.ts | 10 ++++++++-- .../src/commands/config/set/proxy.ts | 20 +++++++++++++++++-- .../src/hooks/prerun/plan-guard.ts | 20 +++++++++++++++++-- 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/contentstack-auth/src/commands/auth/login.ts b/packages/contentstack-auth/src/commands/auth/login.ts index 56dfd3ae14..fe1e6411db 100644 --- a/packages/contentstack-auth/src/commands/auth/login.ts +++ b/packages/contentstack-auth/src/commands/auth/login.ts @@ -1,11 +1,13 @@ import { CLIError, authHandler as oauthHandler, + cliux, flags, managementSDKClient, FlagInput, log, handleAndLogError, + isConsoleLogEnabled, messageHandler, } from '@contentstack/cli-utilities'; import { User } from '../../interfaces'; @@ -132,6 +134,13 @@ export default class LoginCommand extends BaseCommand { log.debug('Configuration data set successfully.', this.contextDetails); log.success(messageHandler.parse('CLI_AUTH_LOGIN_SUCCESS'), this.contextDetails); + + // log.success maps to the info level, and the Console transport is suppressed for every + // level when the console-log policy is off — so this is the command's only success + // output and it reaches nobody. auth has no progress UI to interleave with. + if (!isConsoleLogEnabled()) { + cliux.success('CLI_AUTH_LOGIN_SUCCESS'); + } log.debug('Login completed successfully.', this.contextDetails); } catch (error) { log.debug('Login failed.', { ...this.contextDetails, error }); diff --git a/packages/contentstack-auth/src/commands/auth/logout.ts b/packages/contentstack-auth/src/commands/auth/logout.ts index 2b66abcf61..668d2388df 100644 --- a/packages/contentstack-auth/src/commands/auth/logout.ts +++ b/packages/contentstack-auth/src/commands/auth/logout.ts @@ -7,6 +7,7 @@ import { FlagInput, log, handleAndLogError, + isConsoleLogEnabled, messageHandler, } from '@contentstack/cli-utilities'; @@ -77,6 +78,12 @@ export default class LogoutCommand extends BaseCommand { cliux.loader(''); log.success(messageHandler.parse('CLI_AUTH_LOGOUT_SUCCESS'), this.contextDetails); + + // Same as auth:login — the only success output, and dark when the console-log policy + // is off. The loader is stopped on the line above, so nothing is rendering here. + if (!isConsoleLogEnabled()) { + cliux.success('CLI_AUTH_LOGOUT_SUCCESS'); + } log.debug('Logout completed successfully.', this.contextDetails); } else { log.debug('User not confirmed or not authenticated, skipping logout', { @@ -88,6 +95,11 @@ export default class LogoutCommand extends BaseCommand { ? 'CLI_AUTH_LOGOUT_CANCELLED' : 'CLI_AUTH_LOGOUT_ALREADY'; log.success(messageHandler.parse(messageKey), this.contextDetails); + + // Cancelled / already-logged-out: without this the user answers "no" and sees nothing. + if (!isConsoleLogEnabled()) { + cliux.success(messageKey); + } } } catch (error) { log.debug('Logout failed.', { ...this.contextDetails, error: error.message }); diff --git a/packages/contentstack-auth/src/commands/auth/whoami.ts b/packages/contentstack-auth/src/commands/auth/whoami.ts index c8edec410d..52949a4ff3 100644 --- a/packages/contentstack-auth/src/commands/auth/whoami.ts +++ b/packages/contentstack-auth/src/commands/auth/whoami.ts @@ -1,4 +1,4 @@ -import { cliux, log, handleAndLogError, messageHandler } from '@contentstack/cli-utilities'; +import { cliux, log, handleAndLogError, isConsoleLogEnabled, messageHandler } from '@contentstack/cli-utilities'; import { BaseCommand } from '../../base-command'; export default class WhoamiCommand extends BaseCommand { @@ -22,6 +22,13 @@ export default class WhoamiCommand extends BaseCommand { } else { log.debug('No user email found in context.', this.contextDetails); log.error(messageHandler.parse('CLI_AUTH_WHOAMI_FAILED'), this.contextDetails); + + // Not-logged-in is the whole answer this command exists to give, and log.error is + // suppressed with every other level when the console-log policy is off. Same channel + // and colour as the catch block below. + if (!isConsoleLogEnabled()) { + cliux.print('CLI_AUTH_WHOAMI_FAILED', { color: 'yellow' }); + } } } catch (error) { log.debug('whoami command failed.', { ...this.contextDetails, error }); diff --git a/packages/contentstack-config/src/commands/config/remove/proxy.ts b/packages/contentstack-config/src/commands/config/remove/proxy.ts index 88c9350d89..10dc42cfb4 100644 --- a/packages/contentstack-config/src/commands/config/remove/proxy.ts +++ b/packages/contentstack-config/src/commands/config/remove/proxy.ts @@ -1,4 +1,4 @@ -import { configHandler, log, handleAndLogError } from '@contentstack/cli-utilities'; +import { configHandler, log, handleAndLogError, cliux, isConsoleLogEnabled } from '@contentstack/cli-utilities'; import { BaseCommand } from '../../../base-command'; export default class ProxyRemoveCommand extends BaseCommand { @@ -17,7 +17,13 @@ export default class ProxyRemoveCommand extends BaseCommand log.debug('Saving proxy configuration to global config', this.contextDetails); configHandler.set('proxy', proxyConfig); - log.success('Proxy configuration set successfully', this.contextDetails); + const successMessage = 'Proxy configuration set successfully'; + log.success(successMessage, this.contextDetails); + + // log.success maps to the info level, which only reaches the console when the + // console-log policy is on. This is the command's only output, so print it directly + // when the policy is off. config has no progress UI — nothing to interleave with. + if (!isConsoleLogEnabled()) { + cliux.print(successMessage); + } } catch (error) { handleAndLogError(error, { ...this.contextDetails, module: 'config-set-proxy' }); } diff --git a/packages/contentstack/src/hooks/prerun/plan-guard.ts b/packages/contentstack/src/hooks/prerun/plan-guard.ts index e7c7742c09..0e1fe9967c 100644 --- a/packages/contentstack/src/hooks/prerun/plan-guard.ts +++ b/packages/contentstack/src/hooks/prerun/plan-guard.ts @@ -1,4 +1,12 @@ -import { configHandler, isFeatureEnabled, FeatureCtx, FeatureStatus, log } from '@contentstack/cli-utilities'; +import { + configHandler, + isFeatureEnabled, + FeatureCtx, + FeatureStatus, + log, + cliux, + isConsoleLogEnabled, +} from '@contentstack/cli-utilities'; function getArgvFlag(argv: string[], ...names: string[]): string | undefined { for (let i = 0; i < argv.length; i++) { @@ -72,11 +80,19 @@ export default async function (opts: { Command?: { id?: string }; argv?: string[ try { planStatus[featureUid] = await isFeatureEnabled(featureUid, ctx); } catch (error) { - log.warn(`[plan-guard] Could not fetch status for "${featureUid}": ${(error as Error).message}`, { + const warning = `[plan-guard] Could not fetch status for "${featureUid}": ${(error as Error).message}`; + log.warn(warning, { module: 'plan-guard', commandId, featureUid, }); + + // The Console transport is suppressed for every level when the console-log policy is + // off, so without this the user is never told the entitlement check could not run. + // This is a prerun hook — no command has started, so no progress bar or spinner is live. + if (!isConsoleLogEnabled()) { + cliux.print(warning, { color: 'yellow' }); + } failedFeatures.push(featureUid); } } From f72f0c5b72c93c60e737a0b94d2e93156315bf18 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 27 Aug 2026 16:53:01 +0530 Subject: [PATCH 4/4] fix: surface errors and exit non-zero on failed auth and config commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the error-visibility plan. These are pre-existing defects that the console-log policy change exposed rather than caused: a bare process.exit() exits 0 and terminates before oclif can print, so the error text only ever reached the screen via the winston console transport that the policy change correctly removed. - auth:login — a failed login (unknown flag, empty username, locked account) printed nothing and exited 0, so callers saw success. Print the reason and exit 1. Verified: `auth:login --bogusflag` now reports "Nonexistent flag" and exits 1 where it previously emitted zero bytes. - auth-guard — all three exits defaulted to 0, including this.exit(), so a command blocked by the guard reported success. Now non-zero; the messages were already printed via cliux.error. - config:set:log — the show-console-logs flag declared `default: false`, which oclif always populates, leaving the `!== undefined` guards dead. Every invocation persisted log.showConsoleLogs and claimed to have set it, even when the user only changed the level. Dropping the default restores the distinction between "not mentioned" and "explicitly false". Console messages use cliux.print with red, guarded on the console-log policy so they do not double up with the logger when console output is enabled. Co-Authored-By: Claude Opus 5 --- .../contentstack-auth/src/commands/auth/login.ts | 12 +++++++++++- .../src/commands/config/set/log.ts | 6 +++++- packages/contentstack/src/hooks/prerun/auth-guard.ts | 12 +++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/contentstack-auth/src/commands/auth/login.ts b/packages/contentstack-auth/src/commands/auth/login.ts index fe1e6411db..2479d9d930 100644 --- a/packages/contentstack-auth/src/commands/auth/login.ts +++ b/packages/contentstack-auth/src/commands/auth/login.ts @@ -95,7 +95,17 @@ export default class LoginCommand extends BaseCommand { error.message = `${error.message}\nFor more information about MFA, visit: https://www.contentstack.com/docs/developers/security/multi-factor-authentication`; } handleAndLogError(error, { ...this.contextDetails }); - process.exit(); + + // handleAndLogError writes to the log files; the Console transport is suppressed when the + // console-log policy is off, so without this the command fails in complete silence — no + // message for an unknown flag, an empty username, or a locked account. + if (!isConsoleLogEnabled()) { + cliux.print(`Error: ${error?.message || 'Login failed'}`, { color: 'red' }); + } + + // Exit non-zero. A bare `process.exit()` exits 0, so a failed login reported success and + // scripts chained on it carried on as though the user were authenticated. + process.exit(1); } } diff --git a/packages/contentstack-config/src/commands/config/set/log.ts b/packages/contentstack-config/src/commands/config/set/log.ts index a2edc52254..6008054930 100644 --- a/packages/contentstack-config/src/commands/config/set/log.ts +++ b/packages/contentstack-config/src/commands/config/set/log.ts @@ -18,7 +18,11 @@ export default class LogSetCommand extends Command { 'show-console-logs': flags.boolean({ description: 'Enable console logging.', allowNo: true, // no-show-console-logs - default: false, + // NOTE No `default`. oclif always populates a flag that declares one, which made the + // `!== undefined` guards below dead: every `config:set:log` invocation then persisted + // `showConsoleLogs` and reported it as set, even when the user only changed the level. + // Leaving it undefined is what lets those guards distinguish "not mentioned" from + // "explicitly set to false". }), }; diff --git a/packages/contentstack/src/hooks/prerun/auth-guard.ts b/packages/contentstack/src/hooks/prerun/auth-guard.ts index e42bc35bef..cf48945a48 100644 --- a/packages/contentstack/src/hooks/prerun/auth-guard.ts +++ b/packages/contentstack/src/hooks/prerun/auth-guard.ts @@ -19,10 +19,12 @@ export default async function (opts): Promise { } // Auth guard if (protectedCommands[opts.Command.id]) { + // NOTE Every exit below is non-zero. `this.exit()` and `process.exit()` both default to 0, + // so a command blocked by the auth guard used to report success to the caller. if (!isAuthenticated()) { handleAndLogError(new Error('Authentication required for this command'), { module: 'auth-guard', commandId: opts.Command.id }); cliux.error('Please log in to execute the command'); - this.exit(); + this.exit(1); } const client = await managementSDKClient({host: region.cma}) try { @@ -30,13 +32,13 @@ export default async function (opts): Promise { if (!result) { handleAndLogError(new Error('Error in auth validation'), { module: 'auth-guard' }); cliux.error('Please log in to execute the command'); - this.exit(); + this.exit(1); } - log.debug('Logged-in user', { module: 'auth-guard', userData: result.data }); + log.debug('Logged-in user', { module: 'auth-guard', userData: result.data }); } catch (error) { handleAndLogError(error, { module: 'auth-guard' }, 'Error in auth validation'); cliux.error('Please log in to execute the command'); - process.exit(); - } + process.exit(1); + } } }