Repository: denoland/deno
Pull request: fix(ext/node): add hasColors() to process.stdout and process.stderr

## Summary
- Adds `hasColors()` method to `process.stdout` and `process.stderr`
- Fixes Node.js compatibility issue where packages like Commander.js check color support

Fixes #27278

## Changes
- Modified `ext/node/polyfills/_process/streams.mjs` to add `hasColors` property
- Added tests in `tests/unit_node/process_test.ts`

## Implementation
The `hasColors` method mirrors the logic from `tty.WriteStream.prototype.hasColors()`:
- When called with no arguments or an object, defaults to checking for 16 colors
- Uses `op_bootstrap_color_depth()` to get the current color depth
- Returns `true` if `count <= 2 ** depth`

## Test plan
- [x] Added unit tests for `process.stdout.hasColors`
- [x] Added unit tests for `process.stderr.hasColors`
- [ ] Verify Commander.js color detection works correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Changed files:
filename: ext/node/polyfills/_process/streams.mjs
patch: @@ -25,6 +25,7 @@ import { Duplex, Readable, Writable } from "node:stream";
 import * as io from "ext:deno_io/12_io.js";
 import { guessHandleType } from "ext:deno_node/internal_binding/util.ts";
 import { op_bootstrap_color_depth } from "ext:core/ops";
+import { validateInteger } from "ext:deno_node/internal/validators.mjs";
 
 // https://github.com/nodejs/node/blob/00738314828074243c9a52a228ab4c68b04259ef/lib/internal/bootstrap/switches/is_main_thread.js#L41
 export function createWritableStdioStream(writer, name, warmup = false) {
@@ -114,6 +115,26 @@ export function createWritableStdioStream(writer, name, warmup = false) {
       writable: true,
       value: () => op_bootstrap_color_depth(),
     },
+    hasColors: {
+      __proto__: null,
+      enumerable: true,
+      configurable: true,
+      writable: true,
+      value: (count, env) => {
+        if (
+          env === undefined &&
+          (count === undefined || typeof count === "object" && count !== null)
+        ) {
+          env = count;
+          count = 16;
+        } else {
+          validateInteger(count, "count", 2);
+        }
+
+        const depth = op_bootstrap_color_depth();
+        return count <= 2 ** depth;
+      },
+    },
   });
 
   // If we're warming up, create a stdout/stderr stream that assumes a terminal (the most likely case).

filename: ext/node/polyfills/process.ts
patch: @@ -749,6 +749,10 @@ Object.defineProperty(process, "platform", {
   get() {
     return platform;
   },
+  set(value) {
+    platform = value;
+  },
+  configurable: true,
 });
 
 // https://nodejs.org/api/process.html#processsetsourcemapsenabledval

filename: ext/node/polyfills/tty.js
patch: @@ -1,19 +1,266 @@
 // Copyright 2018-2026 the Deno authors. MIT license.
+// Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
 
-import { op_bootstrap_color_depth } from "ext:core/ops";
 import { core, primordials } from "ext:core/mod.js";
 const {
+  ArrayPrototypeSome,
   Error,
+  ObjectEntries,
+  ObjectPrototypeHasOwnProperty,
+  RegExpPrototypeExec,
+  SafeMap,
+  SafeMapIterator,
+  SafeRegExp,
+  StringPrototypeSplit,
+  StringPrototypeToLowerCase,
 } = primordials;
 const {
   isTerminal,
 } = core;
 
 import { ERR_INVALID_FD } from "ext:deno_node/internal/errors.ts";
+import { validateInteger } from "ext:deno_node/internal/validators.mjs";
 import { TTY } from "ext:deno_node/internal_binding/tty_wrap.ts";
 import { Socket } from "node:net";
 import { setReadStream } from "ext:deno_node/_process/streams.mjs";
 import * as io from "ext:deno_io/12_io.js";
+import { release } from "node:os";
+import process from "node:process";
+
+// Color depth constants
+const COLORS_2 = 1;
+const COLORS_16 = 4;
+const COLORS_256 = 8;
+const COLORS_16M = 24;
+
+// Terminal environments supporting specific color depths
+const TERM_ENVS = {
+  "eterm": COLORS_16,
+  "cons25": COLORS_16,
+  "console": COLORS_16,
+  "cygwin": COLORS_16,
+  "dtterm": COLORS_16,
+  "gnome": COLORS_16,
+  "hurd": COLORS_16,
+  "jfbterm": COLORS_16,
+  "konsole": COLORS_16,
+  "kterm": COLORS_16,
+  "mlterm": COLORS_16,
+  "mosh": COLORS_16M,
+  "putty": COLORS_16,
+  "st": COLORS_16,
+  "rxvt-unicode-24bit": COLORS_16M,
+  "terminator": COLORS_16M,
+  "xterm-kitty": COLORS_16M,
+};
+
+// CI environments and their color support
+const CI_ENVS_MAP = new SafeMap(ObjectEntries({
+  APPVEYOR: COLORS_256,
+  BUILDKITE: COLORS_256,
+  CIRCLECI: COLORS_16M,
+  DRONE: COLORS_256,
+  GITEA_ACTIONS: COLORS_16M,
+  GITHUB_ACTIONS: COLORS_16M,
+  GITLAB_CI: COLORS_256,
+  TRAVIS: COLORS_256,
+}));
+
+// Regular expressions for terminal types
+const TERM_ENVS_REG_EXP = [
+  new SafeRegExp("ansi"),
+  new SafeRegExp("color"),
+  new SafeRegExp("linux"),
+  new SafeRegExp("direct"),
+  new SafeRegExp("^con[0-9]*x[0-9]"),
+  new SafeRegExp("^rxvt"),
+  new SafeRegExp("^screen"),
+  new SafeRegExp("^xterm"),
+  new SafeRegExp("^vt100"),
+  new SafeRegExp("^vt220"),
+];
+
+let warned = false;
+function warnOnDeactivatedColors(env) {
+  if (warned) {
+    return;
+  }
+  let name = "";
+  if (env.NODE_DISABLE_COLORS !== undefined && env.NODE_DISABLE_COLORS !== "") {
+    name = "NODE_DISABLE_COLORS";
+  }
+  if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") {
+    if (name !== "") {
+      name += "' and '";
+    }
+    name += "NO_COLOR";
+  }
+
+  if (name !== "") {
+    process.emitWarning(
+      `The '${name}' env is ignored due to the 'FORCE_COLOR' env being set.`,
+      "Warning",
+    );
+    warned = true;
+  }
+}
+
+let OSRelease;
+
+/**
+ * @param {Record<string, string>} [env]
+ * @returns {1 | 4 | 8 | 24}
+ */
+function getColorDepth(env = process.env) {
+  // Use level 0-3 to support the same levels as `chalk` does.
+  if (env.FORCE_COLOR !== undefined) {
+    switch (env.FORCE_COLOR) {
+      case "":
+      case "1":
+      case "true":
+        warnOnDeactivatedColors(env);
+        return COLORS_16;
+      case "2":
+        warnOnDeactivatedColors(env);
+        return COLORS_256;
+      case "3":
+        warnOnDeactivatedColors(env);
+        return COLORS_16M;
+      default:
+        return COLORS_2;
+    }
+  }
+
+  if (
+    (env.NODE_DISABLE_COLORS !== undefined &&
+      env.NODE_DISABLE_COLORS !== "") ||
+    (env.NO_COLOR !== undefined && env.NO_COLOR !== "") ||
+    env.TERM === "dumb"
+  ) {
+    return COLORS_2;
+  }
+
+  if (process.platform === "win32") {
+    if (OSRelease === undefined) {
+      OSRelease = StringPrototypeSplit(release(), ".", 3);
+    }
+    if (+OSRelease[0] >= 10) {
+      const build = +OSRelease[2];
+      if (build >= 14931) {
+        return COLORS_16M;
+      }
+      if (build >= 10586) {
+        return COLORS_256;
+      }
+    }
+    return COLORS_16;
+  }
+
+  if (env.TMUX) {
+    return COLORS_16M;
+  }
+
+  // Azure DevOps
+  if (
+    ObjectPrototypeHasOwnProperty(env, "TF_BUILD") &&
+    ObjectPrototypeHasOwnProperty(env, "AGENT_NAME")
+  ) {
+    return COLORS_16;
+  }
+
+  if (ObjectPrototypeHasOwnProperty(env, "CI")) {
+    for (const { 0: envName, 1: colors } of new SafeMapIterator(CI_ENVS_MAP)) {
+      if (ObjectPrototypeHasOwnProperty(env, envName)) {
+        return colors;
+      }
+    }
+    if (env.CI_NAME === "codeship") {
+      return COLORS_256;
+    }
+    return COLORS_2;
+  }
+
+  if (ObjectPrototypeHasOwnProperty(env, "TEAMCITY_VERSION")) {
+    return RegExpPrototypeExec(
+        new SafeRegExp("^(9\\.(0*[1-9]\\d*)\\.|\\d{2,}\\.)"),
+        env.TEAMCITY_VERSION,
+      ) !== null
+      ? COLORS_16
+      : COLORS_2;
+  }
+
+  switch (env.TERM_PROGRAM) {
+    case "iTerm.app":
+      if (
+        !env.TERM_PROGRAM_VERSION ||
+        RegExpPrototypeExec(
+            new SafeRegExp("^[0-2]\\."),
+            env.TERM_PROGRAM_VERSION,
+          ) !== null
+      ) {
+        return COLORS_256;
+      }
+      return COLORS_16M;
+    case "HyperTerm":
+    case "MacTerm":
+      return COLORS_16M;
+    case "Apple_Terminal":
+      return COLORS_256;
+  }
+
+  if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") {
+    return COLORS_16M;
+  }
+
+  if (env.TERM) {
+    if (RegExpPrototypeExec(new SafeRegExp("truecolor"), env.TERM) !== null) {
+      return COLORS_16M;
+    }
+
+    if (RegExpPrototypeExec(new SafeRegExp("^xterm-256"), env.TERM) !== null) {
+      return COLORS_256;
+    }
+
+    const termEnv = StringPrototypeToLowerCase(env.TERM);
+
+    if (TERM_ENVS[termEnv]) {
+      return TERM_ENVS[termEnv];
+    }
+    if (
+      ArrayPrototypeSome(
+        TERM_ENVS_REG_EXP,
+        (term) => RegExpPrototypeExec(term, termEnv) !== null,
+      )
+    ) {
+      return COLORS_16;
+    }
+  }
+
+  // Move 16 color COLORTERM below 16m and 256
+  if (env.COLORTERM) {
+    return COLORS_16;
+  }
+
+  return COLORS_2;
+}
 
 // Returns true when the given numeric fd is associated with a TTY and false otherwise.
 function isatty(fd) {
@@ -101,21 +348,20 @@ export class WriteStream extends Socket {
     ) {
       env = count;
       count = 16;
+    } else {
+      validateInteger(count, "count", 2);
     }
 
     const depth = this.getColorDepth(env);
     return count <= 2 ** depth;
   }
 
   /**
-   * @param {Record<string, string} [env]
+   * @param {Record<string, string>} [env]
    * @returns {1 | 4 | 8 | 24}
    */
-  getColorDepth(_env) {
-    // TODO(@marvinhagemeister): Ignore env parameter.
-    // Haven't seen it used anywhere, seems more done
-    // to make testing easier in Node
-    return op_bootstrap_color_depth();
+  getColorDepth(env) {
+    return getColorDepth(env);
   }
 }
 

filename: tests/node_compat/config.jsonc
patch: @@ -1627,6 +1627,7 @@
     "pseudo-tty/test-set-raw-mode-reset.js": {},
     "pseudo-tty/test-tty-color-support-warning-2.js": {},
     "pseudo-tty/test-tty-color-support-warning.js": {},
+    "pseudo-tty/test-tty-color-support.js": {},
     "pseudo-tty/test-tty-stdin-call-end.js": {},
     "pseudo-tty/test-tty-stdin-end.js": {},
     "pseudo-tty/test-tty-stdout-end.js": {},

filename: tests/unit_node/tty_test.ts
patch: @@ -39,11 +39,13 @@ Deno.test("[node/tty WriteStream.isTTY] returns true when fd is a tty", () => {
 });
 
 Deno.test("[node/tty WriteStream.hasColors] returns true when colors are supported", () => {
+  const stubEnv = Deno.noColor ? { NO_COLOR: "1" } : {};
+
   assert(tty.WriteStream.prototype.hasColors() === !Deno.noColor);
-  assert(tty.WriteStream.prototype.hasColors({}) === !Deno.noColor);
+  assert(tty.WriteStream.prototype.hasColors(stubEnv) === !Deno.noColor);
 
-  assert(tty.WriteStream.prototype.hasColors(1));
-  assert(tty.WriteStream.prototype.hasColors(1, {}));
+  assert(tty.WriteStream.prototype.hasColors(2));
+  assert(tty.WriteStream.prototype.hasColors(2, {}));
 });
 
 Deno.test("[node/tty WriteStream.getColorDepth] returns current terminal color depth", () => {