Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/cli-engine/src/execution/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,11 @@ export function renderCompletedHuman(
* (amends the 2026-08-09 "always" ruling; any redirection of either
* stream keeps the mirror, so pipes still receive exactly the data
* lines). A harness that allocates two separate PTYs reports
* outputStreamsShareDevice false and keeps its mirror; a host that
* cannot tell is treated as one terminal. */
* outputStreamsShareDevice false and keeps its mirror. */
const oneScreen =
runtime.isTty.stdout &&
runtime.isTty.stderr &&
runtime.outputStreamsShareDevice !== false;
runtime.outputStreamsShareDevice;
if (!oneScreen) {
for (const line of presented.presentation.stdout) {
runtime.stdout.write(`${line}\n`);
Expand Down
12 changes: 8 additions & 4 deletions packages/cli-engine/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ export interface Runtime {
* human blocks and the machine stdout mirror would draw on one screen
* as visible duplication. Consulted only when both streams are TTYs:
* `false` there means two separate terminals, so the mirror is kept
* for whatever is reading stdout. Absent means the host cannot tell,
* which is treated as "same" — the overwhelmingly common case for two
* TTYs is one terminal.
* for whatever is reading stdout.
*
* Required, and deliberately so. It decides whether a command's stdout
* payload is written at all, so a host that says nothing is choosing to
* drop that payload — a choice that belongs at the call site, in the open,
* not in a default the host never sees. A bin that cannot tell answers
* `true`: two TTYs are one terminal far more often than not.
*/
readonly outputStreamsShareDevice?: boolean;
readonly outputStreamsShareDevice: boolean;
/**
* Forces the answer to "is this CI", where telemetry never reports.
* Absent — the normal case — means the engine detects CI from `env`
Expand Down
6 changes: 6 additions & 0 deletions packages/cli-engine/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export interface TestCli {
readonly onSettled?: (summary: RunSummary) => void;
readonly cwd?: string;
readonly isTty?: { stdin?: boolean; stdout?: boolean; stderr?: boolean };
/** Whether stdout and stderr are the same open device. Defaults to
* true — two TTYs are one terminal far more often than not — which
* suppresses the stdout mirror. Set false to assert a command's
* stdout payload the way a caller with separate sinks receives it. */
readonly outputStreamsShareDevice?: boolean;
/** Terminal width, as the stream would report it. Absent means
* not a terminal, which is what ui.width reads as unbounded. */
readonly columns?: { stderr?: number };
Expand Down Expand Up @@ -361,6 +366,7 @@ export function createTestCli(spec: {
stdout: opts?.isTty?.stdout ?? false,
stderr: opts?.isTty?.stderr ?? false,
},
outputStreamsShareDevice: opts?.outputStreamsShareDevice ?? true,
isCIOverride: opts?.isCI ?? spec.isCI,
exit: (code: number): never => {
throw new Error(
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/clack-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe("scripted and non-TTY paths are clack-free", () => {
cwd: "/",
env: {},
isTty: { stdin: true, stdout: true, stderr: true },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/clack-prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ async function runInteractive(
cwd: "/",
env: {},
isTty: { stdin: true, stdout: true, stderr: true },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,7 @@ describe("needs.config", { timeout: 60_000 }, () => {
cwd,
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/engine.type-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ export const runtimeShape: Runtime = {
cwd: "/",
env: { CI: "1" },
isTty: { stdin: true, stdout: true, stderr: true },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(String(code));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ describe("wired as a Runtime's manager", () => {
cwd: "/",
env,
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-engine/tests/execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ describe("needs preconditions", () => {
cwd: process.cwd(),
env: {},
isTty: { stdin: opts.interactive, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down Expand Up @@ -821,6 +822,7 @@ describe("report() after the handler resolved", () => {
cwd: "/",
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down Expand Up @@ -896,6 +898,7 @@ describe("credentials that cannot be read", () => {
cwd: "/",
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/lifetimes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ describe("the engine owns the double-signal policy", () => {
cwd: "/",
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
exited.push(code);
throw new Error(`runtime.exit(${code})`);
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/management-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function makeRuntime(overrides?: {
cwd: "/",
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ describe("stdin cleanup", () => {
cwd: "/",
env: {},
isTty: { stdin: true, stdout: true, stderr: true },
outputStreamsShareDevice: true,
exit: (code: number): never => {
throw new Error(`runtime.exit(${code})`);
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/tests/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,7 @@ function controllableRuntime() {
cwd: "/",
env: {},
isTty: { stdin: false, stdout: false, stderr: false },
outputStreamsShareDevice: true,
exit: (code: number): never => {
exited.push(code);
throw new Error(`runtime.exit(${code})`);
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,17 @@ function warnOnDeprecatedStateFileEnvVar(proc: HostProcess): void {

/** Whether fd 1 and fd 2 are the same open device. Distinguishes one
* terminal (mirror suppressed) from a harness that allocated separate
* PTYs for the two streams (mirror kept). Undefined when the fds
* cannot be inspected — the engine then assumes one terminal. */
function outputStreamsShareDevice(): boolean | undefined {
* PTYs for the two streams (mirror kept). */
function outputStreamsShareDevice(): boolean {
try {
const out = fstatSync(1);
const err = fstatSync(2);
return out.dev === err.dev && out.ino === err.ino && out.rdev === err.rdev;
} catch {
return undefined;
// Cannot tell: answer "one terminal", which two TTYs are far more
// often than not. Same behaviour the engine used to apply to an
// absent field, now stated here rather than defaulted out of sight.
return true;
}
}

Expand Down
Loading