List commands (status, query, callers, callees, impact, files) embedded ANSI color codes even when stdout was a pipe, and NO_COLOR had no effect. One switch now decides color for all codegraph-authored output: --no-color > --color > NO_COLOR > FORCE_COLOR > stdout TTY > CI. Piped init/index/sync also stop emitting shimmer animation frames (\r + erase-line rewrites) and print one plain line per phase instead; a TTY with NO_COLOR keeps the animation but drops the color codes. The detection mirrors picocolors' so @clack frames and our own lines agree within a run. Fixes #1281 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
/**
|
|
* Color handling for CLI output (issue #1281).
|
|
*
|
|
* Contract:
|
|
* - piped/redirected stdout (the spawned-child default here) -> no ANSI codes
|
|
* - NO_COLOR set and non-empty -> no ANSI codes (https://no-color.org)
|
|
* - --no-color anywhere on the command line -> no ANSI codes, even vs FORCE_COLOR
|
|
* - FORCE_COLOR / --color -> ANSI codes even when piped
|
|
*
|
|
* Exercised end-to-end against the built binary so every list command's output
|
|
* path is covered by the same switch.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { execFileSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CodeGraph } from '../src';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
const ANSI = /\x1b\[/;
|
|
|
|
/**
|
|
* Env for spawns: neutralize ambient color signals so each case is explicit.
|
|
* The vars must be DELETED, not set to '' — Node itself (and some deps) treat
|
|
* a present-but-empty FORCE_COLOR as "colors on".
|
|
*/
|
|
function colorEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
CODEGRAPH_NO_DAEMON: '1',
|
|
CODEGRAPH_TELEMETRY: '0',
|
|
};
|
|
delete env.NO_COLOR;
|
|
delete env.FORCE_COLOR;
|
|
delete env.CI;
|
|
return { ...env, ...extra };
|
|
}
|
|
|
|
function run(args: string[], env: NodeJS.ProcessEnv, cwd: string): string {
|
|
return execFileSync(process.execPath, [BIN, ...args], {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
describe('CLI color handling (#1281)', () => {
|
|
let tempDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-no-color-'));
|
|
fs.writeFileSync(
|
|
path.join(tempDir, 'a.ts'),
|
|
'export function alpha(): number { return beta(); }\nexport function beta(): number { return 1; }\n'
|
|
);
|
|
const cg = CodeGraph.initSync(tempDir);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
}, 60000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('piped stdout gets no ANSI codes by default (status, query, callers, files)', () => {
|
|
for (const args of [['status'], ['query', 'alpha'], ['callers', 'beta'], ['files']]) {
|
|
const out = run(args, colorEnv(), tempDir);
|
|
expect(out, `command: ${args.join(' ')}`).not.toMatch(ANSI);
|
|
}
|
|
});
|
|
|
|
it('NO_COLOR=1 suppresses ANSI codes even when colors are forced on by env', () => {
|
|
const out = run(['status'], colorEnv({ NO_COLOR: '1', FORCE_COLOR: '1' }), tempDir);
|
|
expect(out).not.toMatch(ANSI);
|
|
});
|
|
|
|
it('FORCE_COLOR=1 emits ANSI codes even though stdout is piped', () => {
|
|
const out = run(['status'], colorEnv({ FORCE_COLOR: '1' }), tempDir);
|
|
expect(out).toMatch(ANSI);
|
|
});
|
|
|
|
it('--color forces ANSI codes on piped stdout; --no-color wins over FORCE_COLOR', () => {
|
|
const forced = run(['status', '--color'], colorEnv(), tempDir);
|
|
expect(forced).toMatch(ANSI);
|
|
|
|
const suppressed = run(['status', '--no-color'], colorEnv({ FORCE_COLOR: '1' }), tempDir);
|
|
expect(suppressed).not.toMatch(ANSI);
|
|
});
|
|
|
|
it('--color / --no-color are accepted in any argv position (not rejected by subcommands)', () => {
|
|
// Would exit non-zero with "unknown option" if the flag reached commander.
|
|
const out = run(['query', 'alpha', '--no-color'], colorEnv(), tempDir);
|
|
expect(out).toContain('alpha');
|
|
});
|
|
|
|
it('piped `codegraph index` emits plain per-phase lines: no ANSI, no \\r rewrites', () => {
|
|
const out = run(['index'], colorEnv(), tempDir);
|
|
expect(out).not.toMatch(ANSI);
|
|
expect(out).not.toContain('\r');
|
|
}, 60000);
|
|
});
|