Skip to content

Commit c46e02d

Browse files
authored
spinner: do not print escape sequences to non-tty output (#7133)
The workers-sdk defines an `isInteractive` test which (incorrectly) does not check whether stdout is a TTY. This means that when piping output to a file or to another process, UI elements like the spinner write ANSI escape sequences to stdout where they are not properly interpreted. Wrangler has its own separate interactivity test that does check that stdout is a TTY. This commit updates workers-sdk to use the `isInteractive` test from Wrangler (which checks that both stdin and stdout are TTYs) and then updates Wrangler to use this function. This both eliminates code duplication and also fixes the problem mentioned above where escape sequences are written to non-TTY outputs. In addition, the `logOutput` function that the spinner uses (which uses code from the 3rd party `log-output` library) _unconditionally_ assumes that stdout is a TTY (it doesn't even check!) and always emits escape sequences. So when we are running non-interactively, we must use the `logRaw` function to avoid emitting escape sequences. While making this change, I also addressed the TODO on the `isNonInteractiveOrCI` function by using that function throughout the wrangler codebase.
1 parent 56dcc94 commit c46e02d

File tree

13 files changed

+74
-42
lines changed

13 files changed

+74
-42
lines changed

.changeset/wild-falcons-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Do not emit escape sequences when stdout is not a TTY

packages/cli/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export const logRaw = (msg: string) => {
6161

6262
// A simple stylized log for use within a prompt
6363
export const log = (msg: string) => {
64-
const lines = msg.split("\n").map((ln) => `${gray(shapes.bar)} ${white(ln)}`);
64+
const lines = msg
65+
.split("\n")
66+
.map((ln) => `${gray(shapes.bar)}${ln.length > 0 ? " " + white(ln) : ""}`);
6567

6668
logRaw(lines.join("\n"));
6769
};

packages/cli/interactive.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
import type { OptionWithDetails } from "./select-list";
2424
import type { Prompt } from "@clack/core";
2525

26+
// logUpdate writes text to a TTY (it uses escape sequences to move the cursor
27+
// and clear lines). This function should not be used when running
28+
// non-interactively.
2629
const logUpdate = createLogUpdate(stdout);
2730

2831
export type Arg = string | boolean | string[] | undefined | number;
@@ -644,7 +647,10 @@ export const spinner = (
644647
start(msg: string, helpText?: string) {
645648
helpText ||= ``;
646649
currentMsg = msg;
647-
startMsg = `${currentMsg} ${dim(helpText)}`;
650+
startMsg = currentMsg;
651+
if (helpText !== undefined && helpText.length > 0) {
652+
startMsg += ` ${dim(helpText)}`;
653+
}
648654

649655
if (isInteractive()) {
650656
let index = 0;
@@ -660,7 +666,7 @@ export const spinner = (
660666
}
661667
}, frameRate);
662668
} else {
663-
logUpdate(`${leftT} ${startMsg}`);
669+
logRaw(`${leftT} ${startMsg}`);
664670
}
665671
},
666672
update(msg: string) {
@@ -678,7 +684,7 @@ export const spinner = (
678684
clearLoop();
679685
} else {
680686
if (msg !== undefined) {
681-
logUpdate(`\n${grayBar} ${msg}`);
687+
logRaw(`${grayBar} ${msg}`);
682688
}
683689
newline();
684690
}
@@ -710,6 +716,13 @@ export const spinnerWhile = async <T>(opts: {
710716
}
711717
};
712718

713-
export const isInteractive = () => {
714-
return process.stdin.isTTY;
715-
};
719+
/**
720+
* Test whether the process is "interactive".
721+
*/
722+
export function isInteractive(): boolean {
723+
try {
724+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
725+
} catch {
726+
return false;
727+
}
728+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { grayBar, leftT, spinner } from "@cloudflare/cli/interactive";
2+
import { collectCLIOutput } from "./helpers/collect-cli-output";
3+
import { useMockIsTTY } from "./helpers/mock-istty";
4+
5+
describe("cli", () => {
6+
describe("spinner", () => {
7+
const std = collectCLIOutput();
8+
const { setIsTTY } = useMockIsTTY();
9+
test("does not animate when stdout is not a TTY", async () => {
10+
setIsTTY(false);
11+
const s = spinner();
12+
const startMsg = "Start message";
13+
s.start(startMsg);
14+
const stopMsg = "Stop message";
15+
s.stop(stopMsg);
16+
expect(std.out).toEqual(
17+
`${leftT} ${startMsg}\n${grayBar} ${stopMsg}\n${grayBar}\n`
18+
);
19+
});
20+
});
21+
});

packages/wrangler/src/__tests__/cloudchamber/curl.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ describe("cloudchamber curl", () => {
153153
);
154154
expect(std.err).toMatchInlineSnapshot(`""`);
155155
expect(std.out).toMatchInlineSnapshot(`
156-
"├ Loading account
157-
156+
"├ Loading account
157+
158158
>> Body
159159
[
160160
{
@@ -310,7 +310,7 @@ describe("cloudchamber curl", () => {
310310
"cloudchamber curl /deployments/v2 --header something:here"
311311
);
312312
expect(std.err).toMatchInlineSnapshot(`""`);
313-
const text = std.out.split("\n").splice(1).join("\n");
313+
const text = std.out.split("\n").splice(2).join("\n");
314314
const response = JSON.parse(text);
315315
expect(response.status).toEqual(500);
316316
expect(response.statusText).toEqual("Unhandled Exception");

packages/wrangler/src/cloudchamber/common.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { version as wranglerVersion } from "../../package.json";
77
import { readConfig } from "../config";
88
import { getConfigCache, purgeConfigCaches } from "../config-cache";
99
import { getCloudflareApiBaseUrl } from "../environment-variables/misc-variables";
10-
import { CI } from "../is-ci";
11-
import isInteractive from "../is-interactive";
10+
import { isNonInteractiveOrCI } from "../is-interactive";
1211
import { logger } from "../logger";
1312
import {
1413
DefaultScopeKeys,
@@ -225,7 +224,7 @@ async function fillOpenAPIConfiguration(config: Config, json: boolean) {
225224
}
226225

227226
export function interactWithUser(config: { json?: boolean }): boolean {
228-
return !config.json && isInteractive() && !CI.isCI();
227+
return !config.json && !isNonInteractiveOrCI();
229228
}
230229

231230
type NonObject = undefined | null | boolean | string | number;

packages/wrangler/src/config-cache.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
22
import * as path from "path";
33
import { findUpSync } from "find-up";
4-
import { CI } from "./is-ci";
5-
import isInteractive from "./is-interactive";
4+
import { isNonInteractiveOrCI } from "./is-interactive";
65
import { logger } from "./logger";
76

87
let cacheMessageShown = false;
@@ -32,7 +31,7 @@ const arrayFormatter = new Intl.ListFormat("en-US", {
3231
});
3332

3433
function showCacheMessage(fields: string[], folder: string) {
35-
if (!cacheMessageShown && isInteractive() && !CI.isCI()) {
34+
if (!cacheMessageShown && !isNonInteractiveOrCI()) {
3635
if (fields.length > 0) {
3736
logger.debug(
3837
`Retrieving cached values for ${arrayFormatter.format(

packages/wrangler/src/d1/migrations/apply.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { printWranglerBanner } from "../..";
55
import { withConfig } from "../../config";
66
import { confirm } from "../../dialogs";
77
import { UserError } from "../../errors";
8-
import { CI } from "../../is-ci";
9-
import isInteractive from "../../is-interactive";
8+
import { isNonInteractiveOrCI } from "../../is-interactive";
109
import { logger } from "../../logger";
1110
import { requireAuth } from "../../user";
1211
import { createBackup } from "../backups";
@@ -155,7 +154,7 @@ Your database may not be available to serve requests during the migration, conti
155154
remote,
156155
config,
157156
name: database,
158-
shouldPrompt: isInteractive() && !CI.isCI(),
157+
shouldPrompt: !isNonInteractiveOrCI(),
159158
persistTo,
160159
command: query,
161160
file: undefined,

packages/wrangler/src/d1/migrations/helpers.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import fs from "node:fs";
22
import path from "path";
33
import { confirm } from "../../dialogs";
44
import { UserError } from "../../errors";
5-
import { CI } from "../../is-ci";
6-
import isInteractive from "../../is-interactive";
5+
import { isNonInteractiveOrCI } from "../../is-interactive";
76
import { logger } from "../../logger";
87
import { DEFAULT_MIGRATION_PATH } from "../constants";
98
import { executeSql } from "../execute";
@@ -110,7 +109,7 @@ const listAppliedMigrations = async ({
110109
remote,
111110
config,
112111
name,
113-
shouldPrompt: isInteractive() && !CI.isCI(),
112+
shouldPrompt: !isNonInteractiveOrCI(),
114113
persistTo,
115114
command: `SELECT *
116115
FROM ${migrationsTableName}
@@ -178,7 +177,7 @@ export const initMigrationsTable = async ({
178177
remote,
179178
config,
180179
name,
181-
shouldPrompt: isInteractive() && !CI.isCI(),
180+
shouldPrompt: !isNonInteractiveOrCI(),
182181
persistTo,
183182
command: `CREATE TABLE IF NOT EXISTS ${migrationsTableName}(
184183
id INTEGER PRIMARY KEY AUTOINCREMENT,
Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isInteractive as __isInteractive } from "@cloudflare/cli/interactive";
12
import { CI } from "./is-ci";
23

34
/**
@@ -10,14 +11,12 @@ export default function isInteractive(): boolean {
1011
return false;
1112
}
1213

13-
try {
14-
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
15-
} catch {
16-
return false;
17-
}
14+
return __isInteractive();
1815
}
1916

20-
// TODO: Use this function across the codebase.
17+
/**
18+
* Test whether a process is non-interactive or running in CI.
19+
*/
2120
export function isNonInteractiveOrCI(): boolean {
2221
return !isInteractive() || CI.isCI();
2322
}

0 commit comments

Comments
 (0)