Skip to content

Commit 021ba4c

Browse files
os-zhuangclaude
andauthored
fix(core): ObjectLogger honors NO_COLOR and TTY detection before emitting ANSI colors (#3111)
The kernel/plugin logger colorized pretty-format level tags unconditionally, so NO_COLOR=1 runs and piped/CI output still carried ANSI escapes, breaking plain-text log scanners (publish-smoke.sh had to strip ANSI before grepping). Per no-color.org, color is now emitted only when the destination stream (stdout, or stderr for error/fatal) is an interactive TTY and NO_COLOR is unset or empty. Interactive terminals keep colorized output. The optional file destination always receives plain text now. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 25a19be commit 021ba4c

3 files changed

Lines changed: 162 additions & 18 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): ObjectLogger honors NO_COLOR and TTY detection before emitting ANSI colors
6+
7+
The kernel/plugin logger (`ctx.logger`, wired by `os serve` / `os dev`) colorized its
8+
`pretty`-format level tags unconditionally, so `NO_COLOR=1` runs and piped/CI output
9+
still carried ANSI escapes (e.g. `\x1b[31m…ERROR\x1b[0m`), breaking plain-text log
10+
scanners (see scripts/publish-smoke.sh, which had to strip ANSI before grepping).
11+
12+
Per the no-color.org convention, color is now emitted only when the destination stream
13+
(stdout, or stderr for error/fatal) is an interactive TTY **and** `NO_COLOR` is unset or
14+
empty — any non-empty `NO_COLOR` value disables color. Interactive terminals keep the
15+
existing colorized output. The optional file destination now always receives plain text.

packages/core/src/logger.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { createLogger, ObjectLogger } from './logger';
33

44
describe('ObjectLogger', () => {
@@ -113,4 +113,113 @@ describe('ObjectLogger', () => {
113113
expect(() => logger.log('Compatible log')).not.toThrow();
114114
});
115115
});
116+
117+
describe('Color emission (no-color.org)', () => {
118+
const ANSI = '\x1b[';
119+
let stdoutChunks: string[];
120+
let stderrChunks: string[];
121+
const originalNoColor = process.env.NO_COLOR;
122+
const originalStdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
123+
const originalStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
124+
125+
const setTTY = (stream: NodeJS.WriteStream, value: boolean) => {
126+
Object.defineProperty(stream, 'isTTY', { value, configurable: true });
127+
};
128+
129+
// Assert on the single write() chunk carrying our message, so unrelated
130+
// writes to the shared process streams (e.g. the test runner's own
131+
// reporter output) can never leak into the assertions.
132+
const chunkWith = (chunks: string[], text: string) =>
133+
chunks.find((c) => c.includes(text)) ?? '';
134+
135+
beforeEach(() => {
136+
stdoutChunks = [];
137+
stderrChunks = [];
138+
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => {
139+
stdoutChunks.push(String(chunk));
140+
return true;
141+
}) as any);
142+
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: any) => {
143+
stderrChunks.push(String(chunk));
144+
return true;
145+
}) as any);
146+
delete process.env.NO_COLOR;
147+
});
148+
149+
afterEach(() => {
150+
vi.restoreAllMocks();
151+
if (originalNoColor === undefined) delete process.env.NO_COLOR;
152+
else process.env.NO_COLOR = originalNoColor;
153+
const restoreTTY = (stream: NodeJS.WriteStream, desc?: PropertyDescriptor) => {
154+
if (desc) Object.defineProperty(stream, 'isTTY', desc);
155+
else delete (stream as any).isTTY;
156+
};
157+
restoreTTY(process.stdout, originalStdoutTTY);
158+
restoreTTY(process.stderr, originalStderrTTY);
159+
});
160+
161+
it('colorizes pretty output on an interactive TTY by default', () => {
162+
setTTY(process.stdout, true);
163+
logger.info('tty message');
164+
const line = chunkWith(stdoutChunks, 'tty message');
165+
expect(line).toContain('\x1b[32m'); // info = green
166+
expect(line).toContain('\x1b[0m');
167+
expect(line).toContain('INFO');
168+
});
169+
170+
it('emits no ANSI codes when NO_COLOR is set to any non-empty value', () => {
171+
setTTY(process.stdout, true);
172+
for (const value of ['1', 'true', '0']) {
173+
stdoutChunks.length = 0;
174+
process.env.NO_COLOR = value;
175+
logger.info('no color message');
176+
const line = chunkWith(stdoutChunks, 'no color message');
177+
expect(line).not.toContain(ANSI);
178+
expect(line).toMatch(/INFO no color message/);
179+
}
180+
});
181+
182+
it('treats an empty NO_COLOR as unset', () => {
183+
setTTY(process.stdout, true);
184+
process.env.NO_COLOR = '';
185+
logger.info('still colored');
186+
expect(chunkWith(stdoutChunks, 'still colored')).toContain(ANSI);
187+
});
188+
189+
it('emits no ANSI codes when the stream is not a TTY (piped/CI output)', () => {
190+
setTTY(process.stdout, false);
191+
logger.info('piped message');
192+
const line = chunkWith(stdoutChunks, 'piped message');
193+
expect(line).not.toContain(ANSI);
194+
expect(line).toMatch(/INFO piped message/);
195+
});
196+
197+
it('gates error/fatal color on stderr TTY-ness and NO_COLOR', () => {
198+
setTTY(process.stdout, false);
199+
setTTY(process.stderr, true);
200+
logger.error('boom');
201+
expect(chunkWith(stderrChunks, 'boom')).toContain('\x1b[31m'); // error = red
202+
203+
stderrChunks.length = 0;
204+
process.env.NO_COLOR = '1';
205+
logger.error('boom again');
206+
const line = chunkWith(stderrChunks, 'boom again');
207+
expect(line).not.toContain(ANSI);
208+
expect(line).toMatch(/ERROR boom again/);
209+
});
210+
211+
it('never writes ANSI codes to the file destination', () => {
212+
setTTY(process.stdout, true);
213+
const fileChunks: string[] = [];
214+
(logger as any).fileStream = {
215+
write: (chunk: string) => fileChunks.push(String(chunk)),
216+
end: (cb: () => void) => cb(),
217+
};
218+
logger.info('file message');
219+
expect(chunkWith(stdoutChunks, 'file message')).toContain(ANSI); // console keeps color…
220+
const fileLine = chunkWith(fileChunks, 'file message');
221+
expect(fileLine).not.toContain(ANSI); // …the file copy stays plain
222+
expect(fileLine).toMatch(/INFO file message/);
223+
});
224+
});
116225
});

packages/core/src/logger.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ const LEVEL_COLORS: Record<LogLevel, string> = {
2828

2929
const RESET = '\x1b[0m';
3030

31+
/**
32+
* Whether ANSI color may be written to the given stream.
33+
*
34+
* Follows the https://no-color.org convention: a non-empty `NO_COLOR` env var
35+
* disables color regardless of TTY, and non-TTY destinations (pipes, CI logs,
36+
* redirected output) always get plain text so plain-text log scanners see
37+
* uncolored level tags. Browser bundles have no `process`/TTY → plain text.
38+
*/
39+
function colorEnabled(stream: { isTTY?: boolean } | undefined): boolean {
40+
if (typeof process !== 'undefined') {
41+
const noColor = (process as any).env?.NO_COLOR;
42+
if (noColor !== undefined && noColor !== '') return false;
43+
}
44+
return Boolean(stream?.isTTY);
45+
}
46+
3147
export class ObjectLogger implements Logger {
3248
private config: Required<Omit<LoggerConfig, 'file' | 'rotation' | 'name'>> & {
3349
file?: string;
@@ -96,10 +112,15 @@ export class ObjectLogger implements Logger {
96112
const hasContext = Object.keys(context).length > 0;
97113
const ts = new Date().toISOString();
98114

99-
let line: string;
115+
const isErrorLevel = level === 'error' || level === 'fatal';
116+
const proc = typeof process !== 'undefined' ? (process as any) : undefined;
117+
const stream = proc ? (isErrorLevel ? proc.stderr : proc.stdout) : undefined;
118+
119+
let line: string; // console output — may carry ANSI color
120+
let plainLine: string; // file output — never colored
100121

101122
if (this.config.format === 'json') {
102-
line = JSON.stringify({
123+
line = plainLine = JSON.stringify({
103124
time: ts,
104125
level,
105126
...(this.config.name ? { name: this.config.name } : {}),
@@ -109,27 +130,26 @@ export class ObjectLogger implements Logger {
109130
} else if (this.config.format === 'text') {
110131
const parts = [ts, level.toUpperCase(), message];
111132
if (hasContext) parts.push(JSON.stringify(context));
112-
line = parts.join(' | ');
133+
line = plainLine = parts.join(' | ');
113134
} else {
114135
// pretty
115-
const color = LEVEL_COLORS[level] || '';
116136
const label = this.config.name ? `[${this.config.name}] ` : '';
117-
line = `${color}${ts} ${level.toUpperCase()}${RESET} ${label}${message}`;
118-
if (hasContext) line += ` ${JSON.stringify(context)}`;
137+
const head = `${ts} ${level.toUpperCase()}`;
138+
let tail = ` ${label}${message}`;
139+
if (hasContext) tail += ` ${JSON.stringify(context)}`;
140+
plainLine = head + tail;
141+
const color = LEVEL_COLORS[level] || '';
142+
line = color && colorEnabled(stream) ? `${color}${head}${RESET}${tail}` : plainLine;
119143
}
120144

121-
const out = line + '\n';
122-
123145
// Browser-safe output: prefer process streams when available, otherwise
124-
// fall back to console. The previous unguarded `process.stderr?.write`
125-
// throws `ReferenceError: process is not defined` in browsers because
146+
// fall back to console. `process` may be missing entirely (browsers) or
147+
// present without stdio streams (bundler shims) — both fall through to
148+
// console. The previous unguarded `process.stderr?.write` threw
149+
// `ReferenceError: process is not defined` in browsers because
126150
// `process` itself is the missing global, not just its `stderr` field.
127-
if (typeof process !== 'undefined' && (process as any).stderr) {
128-
if (level === 'error' || level === 'fatal') {
129-
(process as any).stderr.write(out);
130-
} else {
131-
(process as any).stdout?.write(out);
132-
}
151+
if (stream) {
152+
stream.write(line + '\n');
133153
} else if (typeof console !== 'undefined') {
134154
const fn =
135155
level === 'error' || level === 'fatal' ? console.error
@@ -140,7 +160,7 @@ export class ObjectLogger implements Logger {
140160
}
141161

142162
if (this.fileStream) {
143-
this.fileStream.write(out);
163+
this.fileStream.write(plainLine + '\n');
144164
}
145165
}
146166

0 commit comments

Comments
 (0)