From 9f5829d2d6e47dc369fdd6e30bd2f6ca3be7a030 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 10:51:56 -0700 Subject: [PATCH] feat(cli): honor NO_COLOR Disables chalk colors and prettyjson colors (deploy, sites:create, status, status:hooks, watch) when NO_COLOR is set, per no-color.org. --- src/commands/deploy/deploy.ts | 16 ++++++++++------ src/commands/sites/sites-create.ts | 23 +++++++++++++++++------ src/commands/status/status-hooks.ts | 4 ++-- src/commands/status/status.ts | 20 ++++++++++++-------- src/commands/watch/watch.ts | 13 ++++++++----- src/utils/command-helpers.ts | 10 +++++++++- tests/unit/utils/no-color.test.ts | 28 ++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 tests/unit/utils/no-color.test.ts diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 2fbcb3211b0..d4a1da84789 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -35,6 +35,7 @@ import { getToken, log, logJson, + prettyJsonRenderOptions, warn, type APIError, } from '../../utils/command-helpers.js' @@ -901,7 +902,7 @@ const printResults = ({ }), ) - log(prettyjson.render(msgData)) + log(prettyjson.render(msgData, prettyJsonRenderOptions())) if (!deployToProduction) { log() @@ -960,11 +961,14 @@ const prepAndRunDeploy = async ({ log('') log( - prettyjson.render({ - 'Deploy path': deployFolder, - 'Functions path': functionsFolder, - 'Configuration path': configPath, - }), + prettyjson.render( + { + 'Deploy path': deployFolder, + 'Functions path': functionsFolder, + 'Configuration path': configPath, + }, + prettyJsonRenderOptions(), + ), ) log() diff --git a/src/commands/sites/sites-create.ts b/src/commands/sites/sites-create.ts index 6f65fea1ebf..d02214f182a 100644 --- a/src/commands/sites/sites-create.ts +++ b/src/commands/sites/sites-create.ts @@ -3,7 +3,15 @@ import inquirer from 'inquirer' import { pick } from '../../utils/object-utilities.js' import prettyjson from 'prettyjson' -import { chalk, logAndThrowError, log, logJson, warn, type APIError } from '../../utils/command-helpers.js' +import { + chalk, + logAndThrowError, + log, + logJson, + prettyJsonRenderOptions, + warn, + type APIError, +} from '../../utils/command-helpers.js' import getRepoData from '../../utils/get-repo-data.js' import { configureRepo } from '../../utils/init/config.js' import { isInteractive } from '../../utils/scripted-commands.js' @@ -158,11 +166,14 @@ export const sitesCreate = async (options: OptionValues, command: BaseCommand) = const siteUrl = site.ssl_url || site.url log( - prettyjson.render({ - 'Admin URL': site.admin_url, - URL: siteUrl, - 'Project ID': site.id, - }), + prettyjson.render( + { + 'Admin URL': site.admin_url, + URL: siteUrl, + 'Project ID': site.id, + }, + prettyJsonRenderOptions(), + ), ) track('sites_created', { diff --git a/src/commands/status/status-hooks.ts b/src/commands/status/status-hooks.ts index 1034634ebd1..a1b5cd32914 100644 --- a/src/commands/status/status-hooks.ts +++ b/src/commands/status/status-hooks.ts @@ -1,7 +1,7 @@ import type { OptionValues } from 'commander' import prettyjson from 'prettyjson' -import { log } from '../../utils/command-helpers.js' +import { log, prettyJsonRenderOptions } from '../../utils/command-helpers.js' import type BaseCommand from '../base-command.js' interface StatusHook { @@ -40,5 +40,5 @@ export const statusHooks = async (_options: OptionValues, command: BaseCommand): log(`─────────────────┐ Project Hook Status │ ─────────────────┘`) - log(prettyjson.render(data)) + log(prettyjson.render(data, prettyJsonRenderOptions())) } diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index ed44723aaad..c4eeb2583e6 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -9,6 +9,7 @@ import { getToken, log, logJson, + prettyJsonRenderOptions, warn, type APIError, } from '../../utils/command-helpers.js' @@ -67,7 +68,7 @@ export const status = async (options: OptionValues, command: BaseCommand) => { // another lib. (clean as unknown as >(obj: T) => Partial)(accountData) - log(prettyjson.render(cleanAccountData)) + log(prettyjson.render(cleanAccountData, prettyJsonRenderOptions())) if (!siteId) { warn('Did you run `netlify link` yet?') @@ -92,13 +93,16 @@ export const status = async (options: OptionValues, command: BaseCommand) => { Netlify Project Info │ ────────────────────┘`) log( - prettyjson.render({ - 'Current project': siteInfo.name, - 'Netlify TOML': site.configPath, - 'Admin URL': chalk.magentaBright(siteInfo.admin_url), - 'Project URL': chalk.cyanBright(siteInfo.ssl_url || siteInfo.url), - 'Project Id': chalk.yellowBright(siteInfo.id), - }), + prettyjson.render( + { + 'Current project': siteInfo.name, + 'Netlify TOML': site.configPath, + 'Admin URL': chalk.magentaBright(siteInfo.admin_url), + 'Project URL': chalk.cyanBright(siteInfo.ssl_url || siteInfo.url), + 'Project Id': chalk.yellowBright(siteInfo.id), + }, + prettyJsonRenderOptions(), + ), ) log() } diff --git a/src/commands/watch/watch.ts b/src/commands/watch/watch.ts index da36642468b..1cfe9115baa 100644 --- a/src/commands/watch/watch.ts +++ b/src/commands/watch/watch.ts @@ -3,7 +3,7 @@ import pWaitFor from 'p-wait-for' import prettyjson from 'prettyjson' import { type Spinner, startSpinner, stopSpinner } from '../../lib/spinner.js' -import { chalk, logAndThrowError, log } from '../../utils/command-helpers.js' +import { chalk, logAndThrowError, log, prettyJsonRenderOptions } from '../../utils/command-helpers.js' import type BaseCommand from '../base-command.js' import { init } from '../init/init.js' @@ -98,10 +98,13 @@ export const watch = async (_options: unknown, command: BaseCommand) => { log() log(message) log( - prettyjson.render({ - URL: siteData.ssl_url || siteData.url, - Admin: siteData.admin_url, - }), + prettyjson.render( + { + URL: siteData.ssl_url || siteData.url, + Admin: siteData.admin_url, + }, + prettyJsonRenderOptions(), + ), ) console.timeEnd('Deploy time') } catch (error_) { diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..2878571789e 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -33,7 +33,15 @@ const safeChalk = function (noColors: boolean) { return new Chalk() } -export const chalk = safeChalk(argv.includes('--json')) +/** Honors the NO_COLOR convention (https://no-color.org): any non-empty value disables colors */ +export const shouldDisableColors = (env: NodeJS.ProcessEnv = process.env): boolean => Boolean(env.NO_COLOR) + +export const chalk = safeChalk(argv.includes('--json') || shouldDisableColors()) + +/** Options for `prettyjson.render()` so human-formatted output honors NO_COLOR and --json like `chalk` does */ +export const prettyJsonRenderOptions = (): { noColor: boolean } => ({ + noColor: argv.includes('--json') || shouldDisableColors(), +}) export type ChalkInstance = ChalkInstancePrimitiveType diff --git a/tests/unit/utils/no-color.test.ts b/tests/unit/utils/no-color.test.ts new file mode 100644 index 00000000000..f42fd5c2a5a --- /dev/null +++ b/tests/unit/utils/no-color.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { shouldDisableColors } from '../../../src/utils/command-helpers.js' + +describe('NO_COLOR support', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + test('shouldDisableColors is true for any non-empty NO_COLOR value', () => { + expect(shouldDisableColors({ NO_COLOR: '1' })).toBe(true) + expect(shouldDisableColors({ NO_COLOR: 'true' })).toBe(true) + expect(shouldDisableColors({ NO_COLOR: '0' })).toBe(true) + }) + + test('shouldDisableColors is false when NO_COLOR is unset or empty', () => { + expect(shouldDisableColors({})).toBe(false) + expect(shouldDisableColors({ NO_COLOR: '' })).toBe(false) + }) + + test('chalk is initialized colorless when NO_COLOR is set', async () => { + vi.stubEnv('NO_COLOR', '1') + const { chalk } = await import('../../../src/utils/command-helpers.js') + expect(chalk.level).toBe(0) + expect(chalk.red('plain')).toBe('plain') + }) +})