diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a33cb91d..4c88efeda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,6 +203,31 @@ untouched. When updating `marked`, compare representative terminal, table, list, code, link, streaming, and memory-file output and run the markdown regression tests. Regenerate the exact dependency pin and lockfile with `bun add --exact marked@`. +### Subprocesses and terminal colors + +Use the exact `execa` and `chalk` pins in `package.json` and their public ESM +exports and bundled types. Regenerate both pins and the lockfile with +`bun add --exact execa@ chalk@`. + +Prefer asynchronous `execa(file, args, options)`; use `shell: true` only for +commands that need shell syntax. With `reject: false`, check `failed`, +`timedOut` and `exitCode` before consuming `stdout` or `stderr`. Use +`SyncOptions` for synchronous options. The subprocess is a promise; access +Node child-process event and lifecycle APIs through `subprocess.nodeChildProcess`. +Use template strings or `parseCommandString()` for command splitting, and +`ipc: true` for IPC. Explicit `input` takes precedence over inherited stdin; +set stdin deliberately, and bound subprocess output with `maxBuffer` and +execution time with `timeout`. + +Use the shared default `chalk` instance for terminal styles and the existing +`colorize()` and theme helpers for theme colors. Keep the VS Code color boost +and tmux clamp in `src/ink/colorize.ts`. Numeric `FORCE_COLOR` selects an exact +level (0 disables color, 1 selects 16 colors, 2 selects 256, 3 selects truecolor); +`ansi256()` and `bgAnsi256()` downsample at level 1. Use `new Chalk({ level })` +only when a separate capability level is intentional, as for Apple Terminal +charts. Tests that change `chalk.level` must restore it, and environment-driven +color detection tests must import in a fresh process. + ## AI Agent Guidelines If you are an AI agent (Copilot, Cursor, Claude, etc.) working on this codebase, refer to [AGENTS.md](AGENTS.md) for project-specific coding guidelines, conventions, and validation commands. Following these guidelines will help your contributions align with the project's patterns and reduce review friction. diff --git a/bun.lock b/bun.lock index 1cffe990c..c42072af5 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "auto-bind": "5.0.1", "axios": "1.20.0", "bidi-js": "1.1.0", - "chalk": "5.6.2", + "chalk": "6.0.1", "chokidar": "4.0.3", "cli-boxes": "4.0.1", "cli-highlight": "2.1.11", @@ -46,7 +46,7 @@ "emoji-regex": "11.0.0", "env-paths": "4.0.0", "eslint": "9.39.5", - "execa": "9.6.1", + "execa": "10.2.1", "fflate": "0.8.3", "figures": "6.1.0", "fuse.js": "7.5.0", @@ -649,7 +649,7 @@ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@6.0.1", "", {}, "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -767,7 +767,7 @@ "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "execa": ["execa@10.2.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.1", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.2.0" } }, "sha512-/Bofl3ZBh2Q4/PG0W63qFzP5vDAypi8Zc90UWTBzaDNIYiX7apPOd0N6L8hG8CojUdq4V51adu8BObP+iVsSTw=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -1081,7 +1081,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], @@ -1245,6 +1245,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-command": ["which-command@0.1.0", "", { "bin": { "which-command": "cli.js" } }, "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -1269,7 +1271,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/package.json b/package.json index 040b6200a..7659b5a9b 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "auto-bind": "5.0.1", "axios": "1.20.0", "bidi-js": "1.1.0", - "chalk": "5.6.2", + "chalk": "6.0.1", "chokidar": "4.0.3", "cli-boxes": "4.0.1", "cli-highlight": "2.1.11", @@ -126,7 +126,7 @@ "emoji-regex": "11.0.0", "env-paths": "4.0.0", "eslint": "9.39.5", - "execa": "9.6.1", + "execa": "10.2.1", "fflate": "0.8.3", "figures": "6.1.0", "fuse.js": "7.5.0", diff --git a/src/ink/colorize.test.ts b/src/ink/colorize.test.ts new file mode 100644 index 000000000..bfbbc5972 --- /dev/null +++ b/src/ink/colorize.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' + +const colorizeUrl = new URL('./colorize.ts', import.meta.url).href + +// Each process imports chalk and colorize after its terminal environment is set. +// Leave two seconds for assertions beyond the 8-second subprocess deadline. +test.each([ + { force: '0', terminal: '', tmux: '', level: 0, color: 'X' }, + { force: '1', terminal: '', tmux: '', level: 1, color: '\x1b[91mX\x1b[39m' }, + { force: '2', terminal: '', tmux: '', level: 2, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '3', terminal: '', tmux: '', level: 3, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '0', terminal: 'vscode', tmux: '', level: 0, color: 'X' }, + { force: '2', terminal: 'vscode', tmux: '', level: 3, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '3', terminal: 'vscode', tmux: 'test', level: 2, color: '\x1b[38;5;196mX\x1b[39m' }, +])('colorize respects FORCE_COLOR=$force, terminal=$terminal, tmux=$tmux', ({ force, terminal, tmux, level, color }) => { + const script = ` + import chalk from 'chalk'; + import { colorize } from ${JSON.stringify(colorizeUrl)}; + process.stdout.write(JSON.stringify({ + level: chalk.level, + foreground: colorize('X', 'ansi256(196)', 'foreground'), + background: colorize('X', 'ansi256(196)', 'background'), + })); + ` + const result = spawnSync(process.execPath, ['--eval', script], { + env: { + ...process.env, + FORCE_COLOR: force, + TERM_PROGRAM: terminal, + TMUX: tmux, + CLAUDE_CODE_TMUX_TRUECOLOR: '', + COLORTERM: '', + }, + encoding: 'utf8', + timeout: 8_000, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + const rendered = JSON.parse(result.stdout) + expect(rendered.level).toBe(level) + expect(rendered.foreground).toBe(color) + expect(rendered.background).toBe(level === 0 ? 'X' : level === 1 + ? '\x1b[101mX\x1b[49m' + : '\x1b[48;5;196mX\x1b[49m') +}, 10_000) diff --git a/src/utils/execFileNoThrowPortable.test.ts b/src/utils/execFileNoThrowPortable.test.ts new file mode 100644 index 000000000..0ac632b3f --- /dev/null +++ b/src/utils/execFileNoThrowPortable.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' +import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrowPortable.js' + +// Allow assertions and cleanup beyond the subprocess's 8-second deadline. +const options = { timeout: 8_000 } + +test('portable sync execution trims captured stdout', () => { + expect(execSyncWithDefaults_DEPRECATED('echo hello', options)).toBe('hello') +}, 10_000) + +test('portable sync execution preserves stdout on nonzero exit', () => { + expect(execSyncWithDefaults_DEPRECATED('echo partial && exit 7', options)).toBe('partial') +}, 10_000) + +test('portable sync execution returns null without stdout', () => { + expect(execSyncWithDefaults_DEPRECATED('exit 7', options)).toBeNull() +}, 10_000) + +test('portable sync execution forwards explicit stdin', () => { + const command = `"${process.execPath}" -e "process.stdin.pipe(process.stdout)"` + expect(execSyncWithDefaults_DEPRECATED(command, { + ...options, + input: 'payload\n', + stdio: ['pipe', 'pipe', 'pipe'], + })).toBe('payload') +}, 10_000) diff --git a/src/utils/execFileNoThrowPortable.ts b/src/utils/execFileNoThrowPortable.ts index 674653382..e6af9ed43 100644 --- a/src/utils/execFileNoThrowPortable.ts +++ b/src/utils/execFileNoThrowPortable.ts @@ -9,8 +9,8 @@ type ExecSyncOptions = { abortSignal?: AbortSignal timeout?: number input?: string - // Sync variant: execaSync rejects async-only stdio values like 'overlapped' - stdio?: ExecaSyncOptions['stdio'] + // This wrapper uses UTF-8 output, so exclude binary-only stdio transforms. + stdio?: Extract['stdio'] } /**