UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

549 lines (548 loc) 23.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.output = void 0; exports.isStaticOutputStyle = isStaticOutputStyle; exports.printsFullTaskOutput = printsFullTaskOutput; exports.isLogGroupingEnabled = isLogGroupingEnabled; exports.shouldGroupBatchOutput = shouldGroupBatchOutput; exports.orange = orange; const tslib_1 = require("tslib"); const figures = tslib_1.__importStar(require("figures")); const fs_1 = require("fs"); const os_1 = require("os"); const pc = tslib_1.__importStar(require("picocolors")); const readline = tslib_1.__importStar(require("readline")); const GH_GROUP_PREFIX = '::group::'; const GH_GROUP_SUFFIX = '::endgroup::'; /** * `static-failures-only` is `static` with successful tasks collapsed to a single * line. They select the same life cycle and differ only in what it prints, so * everywhere the life cycle is chosen, the TUI is ruled out, or output is * routed, the two behave identically. */ function isStaticOutputStyle(outputStyle) { return outputStyle === 'static' || outputStyle === 'static-failures-only'; } /** * Whether a run prints every task's output in full rather than collapsing the * ones that succeeded. Both static life cycles and the batch renderer have to * agree on this, so they read it from here rather than each deriving it. * * Exactly one style collapses, and it is also what a run that named no style * gets. Every other style prints in full because it was asked for explicitly, * so none may quietly withhold output. Note the static life cycles serve more * styles than the static-sounding ones: `shouldUseDynamicLifeCycle` bails on * `isCI()` before it looks at the style at all, so in CI `dynamic` and `tui` * land here too. This is written as a deny-list for that reason — a new style * prints in full until someone decides otherwise, rather than silently * collapsing because an allow-list did not list it. * * Resolving the absent case here rather than assigning `outputStyle` upstream is * deliberate: the value is also read by the orchestrator to decide whether a * task streams, and naming the default there stops `shouldStreamOutput` from * ever being consulted — including for the continuous tasks that must stream. */ function printsFullTaskOutput(args) { return (!!args.verbose || (args.outputStyle ?? 'static-failures-only') !== 'static-failures-only'); } /** * Whether task output should be wrapped in collapsible log groups. Grouping * requires each task's output to be written as one contiguous block, which is * why batch mode's implicit streaming backs off when this is on. It does not * govern streaming in general — an explicit `--output-style`, the TUI, and * long running tasks all still stream. */ function isLogGroupingEnabled() { return (process.env.NX_SKIP_LOG_GROUPING !== 'true' && !!process.env.GITHUB_ACTIONS); } /** * Whether a batch task's output should be held back from the live stream and * rendered inside a fold instead. A batch worker writes to stdout/stderr live, * so forwarding that copy would put its bytes outside the group and defeat the * fold; what is held back is written to a file and rendered by the orchestrator. * * Note it is the captured file, not each task's `terminalOutput`, that makes * this lossless. A worker is free to write bytes it attributes to no task — * `@nx/maven`'s exit-code dump, `@nx/gradle`'s configuration phase — and those * appear in no `terminalOutput` at all, so the fold has to be able to fall back * to the file. See `TaskOrchestrator.printGroupedBatchOutput` for when it does. * * This is only worth doing when grouping is on and the user has not asked to * stream — an explicit stream style (which sets NX_STREAM_OUTPUT) wants the * live copy, folds or not. */ function shouldGroupBatchOutput() { return isLogGroupingEnabled() && process.env.NX_STREAM_OUTPUT !== 'true'; } /** * Custom orange color using ANSI 256-color code 214. * picocolors does not support keyword-based colors like chalk, * so orange is implemented manually. */ function orange(text) { return pc.isColorSupported ? `\x1b[38;5;214m${text}\x1b[39m` : String(text); } /** * Map of color names to picocolors functions, used for dynamic color access. */ const pcColors = { cyan: pc.cyan, red: pc.red, yellow: pc.yellow, green: pc.green, gray: pc.gray, white: pc.white, blue: pc.blue, magenta: pc.magenta, orange, }; class CLIOutput { constructor() { this.cliName = 'NX'; this.formatCommand = (taskId) => `${pc.dim('nx run')} ${taskId}`; /** * Expose some color and other utility functions so that other parts of the codebase that need * more fine-grained control of message bodies are still using a centralized * implementation. */ this.colors = { gray: pc.gray, green: pc.green, red: pc.red, cyan: pc.cyan, white: pc.white, orange, }; this.bold = pc.bold; this.underline = pc.underline; this.dim = pc.dim; /** * Whether the terminal is positioned at the start of a line. Task output does * not reliably end in a newline, so writers that must begin on a fresh line * ask for one via {@link ensureLineStart} rather than guessing. * * Holding that true means a writer that can leave the cursor mid-line has to * be routed through this class or declared to it. The ones that exist today: * * - This class's own writes, via {@link writeToStream}. * - A batch worker's live output, via {@link writeTaskOutputChunk}. * - `nx:run-commands`, which is the only executor that runs in the main * process (`task-orchestrator.ts` gates that on the executor name), and * whose raw writes go through {@link writeTaskOutputChunk} for this reason. * Its `addColorAndPrefix` splits on newlines without ever appending one, so * its chunks routinely end mid-line. * - A pseudo-terminal task, which cannot be routed: the native side writes to * this process's stdout from Rust, at arbitrary PTY read boundaries. It * declares itself via {@link noteExternalWrite} instead, which is why that * exists. * * Two bypasses are deliberate and safe, both because they re-emit output a * whole line at a time via `formatPrefixedLines`, which appends `EOL` to every * line it writes: forked task streaming through * `NodeChildProcessWithNonDirectOutput`'s `addPrefixTransformer`, and * `writePrefixedLines` for a main-process `nx:run-commands` under * `NX_PREFIX_OUTPUT`. * * One bypass is known and is NOT safe. With `NX_NATIVE_COMMAND_RUNNER=false`, * `forkProcessLegacy` forks with inherited stdio and yields * `NodeChildProcessWithDirectOutput`, whose child writes straight to this * process's fd 1 at arbitrary boundaries — unroutable and undeclarable from * here. Line tracking is simply wrong on that path; it degrades to the * pre-tracking behavior of a glued marker rather than to anything new. Do not * read this list as closed: it is what is known, and the way to tell you are * adding to it is that you are writing to stdout during a run without going * through {@link writeToStream}, {@link writeTaskOutputChunk} or * {@link noteExternalWrite}. */ this.atLineStart = true; } /** * Longer dash character which forms more of a continuous line when place side to side * with itself, unlike the standard dash character */ get VERTICAL_SEPARATOR() { let divider = ''; for (let i = 0; i < process.stdout.columns - 1; i++) { divider += '\u2014'; } return divider; } writeToStream(str, stream = process.stdout) { // stdout and stderr share one cursor wherever this matters — a CI log, a // terminal — so a write to either moves it. if ((stream === process.stdout || stream === process.stderr) && str.length > 0) { this.atLineStart = str.endsWith('\n'); } stream.write(str); } /** * Forwards a chunk of a task's output live, keeping {@link atLineStart} * accurate. Batch workers write raw chunks that routinely end mid-line, and a * collapsed summary line must not be glued onto one. * * @internal Not part of the output API plugins may rely on. */ writeTaskOutputChunk(chunk, stream = process.stdout) { if (chunk.length > 0 && (stream === process.stdout || stream === process.stderr)) { // A Buffer is written through undecoded so a chunk that splits a // multi-byte character is not mangled; 0x0a only ever encodes a newline // in UTF-8, so its last byte answers the question on its own. this.atLineStart = typeof chunk === 'string' ? chunk.endsWith('\n') : chunk[chunk.length - 1] === 0x0a; } stream.write(chunk); } /** * Declares output this class could not route — a pseudo-terminal task's, which * the native side writes straight to our stdout — so the next writer needing a * fresh line asks for one instead of trusting a stale position. * * The chunk is inspected rather than assumed mid-line, so output that did end * on a line boundary does not cost a blank line. PTY chunks often end in an * escape sequence after the newline, and that reads as mid-line, which is the * safe direction to be wrong in: a spare newline, never a glued one. */ noteExternalWrite(chunk) { if (chunk === undefined || chunk.length === 0) { this.atLineStart = false; return; } this.atLineStart = typeof chunk === 'string' ? chunk.endsWith('\n') : chunk[chunk.length - 1] === 0x0a; } ensureLineStart() { if (!this.atLineStart) { this.addNewline(); } } overwriteLine(lineText = '') { // Ensure we always start writing from column 0. readline.cursorTo(process.stdout, 0); // this replaces the existing text up to the new line length process.stdout.write(lineText); // clear whatever text might be left to the right of the cursor (happens // when existing text was longer than new one) readline.clearLine(process.stdout, 1); // Move to the next line and re-anchor to column 0 without relying on // terminal newline translation behavior. process.stdout.write('\n'); readline.cursorTo(process.stdout, 0); } writeOutputTitle({ color, title, }, stream = process.stdout) { this.writeToStream(`${this.applyNxPrefix(color, title)}${os_1.EOL}`, stream); } writeOptionalOutputBody(bodyLines, stream = process.stdout) { if (!bodyLines) { return; } this.addNewline(stream); bodyLines.forEach((bodyLine) => this.writeToStream(`${bodyLine}${os_1.EOL}`, stream)); } applyNxPrefix(color = 'cyan', text) { const colorFn = pcColors[color] || ((t) => t); const nxPrefix = pc.inverse(pc.bold(colorFn(` ${this.cliName} `))); return `${nxPrefix} ${text}`; } addNewline(stream = process.stdout) { this.writeToStream(os_1.EOL, stream); } addVerticalSeparator(color = 'gray') { this.addNewline(); this.addVerticalSeparatorWithoutNewLines(color); this.addNewline(); } addVerticalSeparatorWithoutNewLines(color = 'gray') { this.writeToStream(`${this.getVerticalSeparator(color)}${os_1.EOL}`); } getVerticalSeparatorLines(color = 'gray') { return ['', this.getVerticalSeparator(color), '']; } getVerticalSeparator(color) { const colorFn = pcColors[color] || ((t) => t); return pc.dim(colorFn(this.VERTICAL_SEPARATOR)); } error({ title, slug, bodyLines }) { const stream = process.stderr; this.addNewline(stream); this.writeOutputTitle({ color: 'red', title: pc.red(title), }, stream); this.writeOptionalOutputBody(bodyLines, stream); /** * Optional slug to be used in an Nx error message redirect URL */ if (slug && typeof slug === 'string') { this.addNewline(stream); this.writeToStream(`${pc.gray(' Learn more about this error: ')}https://errors.nx.dev/${slug}${os_1.EOL}`, stream); } this.addNewline(stream); } warn({ title, slug, bodyLines }) { this.addNewline(process.stderr); this.writeOutputTitle({ color: 'yellow', title: pc.yellow(title), }, process.stderr); this.writeOptionalOutputBody(bodyLines, process.stderr); /** * Optional slug to be used in an Nx warning message redirect URL */ if (slug && typeof slug === 'string') { this.addNewline(process.stderr); this.writeToStream(`${pc.gray(' Learn more about this warning: ')}https://errors.nx.dev/${slug}${os_1.EOL}`, process.stderr); } this.addNewline(process.stderr); } note({ title, bodyLines }) { this.addNewline(); this.writeOutputTitle({ color: 'orange', title: orange(title), }); this.writeOptionalOutputBody(bodyLines); this.addNewline(); } success({ title, bodyLines }) { this.addNewline(); this.writeOutputTitle({ color: 'green', title: pc.green(title), }); this.writeOptionalOutputBody(bodyLines); this.addNewline(); } logSingleLine(message) { this.addNewline(); this.writeOutputTitle({ color: 'gray', title: message, }); this.addNewline(); } logRawLine(message) { this.writeToStream(`${message}${os_1.EOL}`); this.addNewline(); } logCommand(message, taskStatus) { this.addNewline(); this.writeToStream(this.getCommandWithStatus(message, taskStatus)); this.addNewline(); this.addNewline(); } logCommandOutput(message, taskStatus, output) { let commandOutputWithStatus = this.getCommandWithStatus(message, taskStatus); const grouped = isLogGroupingEnabled(); if (grouped) { const icon = this.getStatusIcon(taskStatus); commandOutputWithStatus = `${GH_GROUP_PREFIX}${icon} ${commandOutputWithStatus}`; } this.addNewline(); this.writeToStream(commandOutputWithStatus); this.addNewline(); this.addNewline(); this.writeToStream(output); if (grouped) { // GitHub only recognizes ::endgroup:: as a workflow command when it // starts a line, and task output routinely lacks a trailing newline. this.ensureLineStart(); this.writeToStream(`${GH_GROUP_SUFFIX}${os_1.EOL}`); } } /** * A single line standing in for a task's full output, used when the output * itself carries no information worth printing (a success, or a cache hit). * Statuses that carry a diagnosable body are deliberately not accepted here. */ logCommandSummary(message, taskStatus) { // The preceding task may have left the cursor mid-line, and this line must // not be glued onto the end of that task's output. this.ensureLineStart(); const icon = pc.green(figures.tick); const command = this.addTaskStatus(taskStatus, this.formatCommand(this.normalizeMessage(message))); this.writeToStream(`${icon} ${command}${os_1.EOL}`); } /** * A one-line stand-in for a task whose full output is shown elsewhere — used * for the tasks of a batch rendered as a single log group rather than per * task. `note` points the reader at that group. */ logCommandRedirect(message, // A skipped task never ran, so it has no fold to be redirected to - and the // icon ladder below would render it with the success tick. Excluding it // makes the call site's `status !== 'skipped'` check a consequence of the // type rather than a convention to remember. taskStatus, note) { this.ensureLineStart(); // A stopped task did not fail; it never got to finish. The TUI summary // already draws that distinction, so use the same glyph. const icon = taskStatus === 'stopped' ? pc.cyan(figures.squareSmallFilled) : taskStatus === 'failure' ? pc.red(figures.cross) : pc.green(figures.tick); const command = this.formatCommand(this.normalizeMessage(message)); this.writeToStream(`${icon} ${command} ${pc.dim(note)}${os_1.EOL}`); } /** * Prints a batch's combined output as one log group. A batch runner's * diagnostics — a crash, a config-phase error, a runner summary — belong to no * single task, so the group is labelled with the batch rather than a task. * * The batch's own output is copied straight from the file it was captured * into, so an arbitrarily long log costs a fixed amount of memory here. * Nothing is withheld: this rendering is chosen because the whole log was * asked for, because no task claimed any of it, or because a task failed or * was stopped — in which case repeating claimed bytes beside the per-task * blocks is deliberate (see TaskOrchestrator.printGroupedBatchOutput). */ logBatchGroup(label, body, taskStatus) { const grouped = isLogGroupingEnabled(); let header = `${pc.dim('> ')}${pc.bold(label)}`; if (grouped) { header = `${GH_GROUP_PREFIX}${this.getStatusIcon(taskStatus)} ${header}`; } this.addNewline(); this.writeToStream(header); this.addNewline(); this.addNewline(); try { if (body.capturedOutputPath) { this.copyFileToStream(body.capturedOutputPath); } if (body.trailer) { this.ensureLineStart(); this.writeToStream(`${body.trailer}${os_1.EOL}`); } } finally { // The header is already on the stream, so an exception while rendering // the body must not skip the terminator: GitHub would fold every line // after this point into a group that never closes, swallowing the rest of // the run's output rather than just this batch's. if (grouped) { this.ensureLineStart(); this.writeToStream(`${GH_GROUP_SUFFIX}${os_1.EOL}`); } else { this.ensureLineStart(); } } } /** * Copies a file to stdout a chunk at a time. Reading it into one string would * reintroduce the unbounded growth that writing it to disk avoided, and a * long batch log can exceed the maximum length of a JS string. */ copyFileToStream(path) { let fd; try { fd = (0, fs_1.openSync)(path, 'r'); } catch { // The batch left nothing behind, or it is already cleaned up. The tasks' // own redirect lines still point at this group. return; } try { const buffer = Buffer.allocUnsafe(64 * 1024); let bytesRead; while ((bytesRead = (0, fs_1.readSync)(fd, buffer, 0, buffer.length, null)) > 0) { // Copy before writing. `stream.write` queues a Buffer by reference, and // stdout is a pipe wherever grouping is on, so a backed-up write would // still be holding this memory when the next read overwrites it. this.writeTaskOutputChunk(Buffer.from(buffer.subarray(0, bytesRead))); } } finally { (0, fs_1.closeSync)(fd); } } getCommandWithStatus(message, taskStatus) { const commandOutput = pc.dim('> ') + this.formatCommand(this.normalizeMessage(message)); return this.addTaskStatus(taskStatus, commandOutput); } getStatusIcon(taskStatus) { switch (taskStatus) { case 'success': return '✅'; case 'failure': return '❌'; case 'stopped': return '⏹️'; case 'skipped': case 'local-cache-kept-existing': return '⏩'; case 'local-cache': case 'remote-cache': return '🔁'; default: { // The repo compiles with `strict: false`, so a `: string` return type // alone does not reject a fallthrough - `undefined` stays assignable. // This does: a new `TaskStatus` member fails to narrow to `never` here. // Worth the lines because the gap already shipped once, rendering // `::group::undefined > nx run ...` for a stopped task. const unhandled = taskStatus; void unhandled; // Returning `unhandled` would print the status text where an icon goes // (`::group::queued > nx run ...`) for a value that reached us over IPC // or from cache metadata without passing the compiler. No icon reads // better than a wrong one. return ''; } } } normalizeMessage(message) { if (message.startsWith('nx run ')) { return message.substring('nx run '.length); } else if (message.startsWith('run ')) { return message.substring('run '.length); } else { return message; } } addTaskStatus(taskStatus, commandOutput) { if (taskStatus === 'local-cache') { return `${commandOutput} ${pc.dim('[local cache]')}`; } else if (taskStatus === 'remote-cache') { return `${commandOutput} ${pc.dim('[remote cache]')}`; } else if (taskStatus === 'local-cache-kept-existing') { return `${commandOutput} ${pc.dim('[existing outputs match the cache, left as is]')}`; } else { return commandOutput; } } log({ title, bodyLines, color }) { this.addNewline(); const colorFn = color ? pcColors[color] : undefined; this.writeOutputTitle({ color: 'cyan', title: colorFn ? colorFn(title) : title, }); this.writeOptionalOutputBody(bodyLines); this.addNewline(); } drain() { return new Promise((resolve) => { if (process.stdout.writableNeedDrain) { process.stdout.once('drain', resolve); } else { resolve(); } }); } } exports.output = new CLIOutput();