Skip to content

Commit c46e02d

Browse files
authoredNov 14, 2024··
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

+5
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

+3-1
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

+19-6
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+
}
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

+3-3
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

+2-3
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

+2-3
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

+2-3
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

+3-4
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,
+5-6
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
}

‎packages/wrangler/src/metrics/metrics-config.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { getConfigCache, saveToConfigCache } from "../config-cache";
66
import { confirm } from "../dialogs";
77
import { getWranglerSendMetricsFromEnv } from "../environment-variables/misc-variables";
88
import { getGlobalWranglerConfigPath } from "../global-wrangler-config-path";
9-
import { CI } from "../is-ci";
10-
import isInteractive from "../is-interactive";
9+
import { isNonInteractiveOrCI } from "../is-interactive";
1110
import { logger } from "../logger";
1211
import { getAPIToken } from "../user";
1312

@@ -103,7 +102,7 @@ export async function getMetricsConfig({
103102

104103
// We couldn't get the metrics permission from the project-level nor the user-level config.
105104
// If we are not interactive or in a CI build then just bail out.
106-
if (!isInteractive() || CI.isCI()) {
105+
if (isNonInteractiveOrCI()) {
107106
return { enabled: false, deviceId, userId };
108107
}
109108

‎packages/wrangler/src/user/user.ts

+4-6
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,7 @@ import { NoDefaultValueProvided, select } from "../dialogs";
223223
import { getCloudflareApiEnvironmentFromEnv } from "../environment-variables/misc-variables";
224224
import { UserError } from "../errors";
225225
import { getGlobalWranglerConfigPath } from "../global-wrangler-config-path";
226-
import { CI } from "../is-ci";
227-
import isInteractive from "../is-interactive";
226+
import { isNonInteractiveOrCI } from "../is-interactive";
228227
import { logger } from "../logger";
229228
import openInBrowser from "../open-in-browser";
230229
import { parseTOML, readFileSync } from "../parse";
@@ -919,11 +918,10 @@ export async function loginOrRefreshIfRequired(
919918
): Promise<boolean> {
920919
// TODO: if there already is a token, then try refreshing
921920
// TODO: ask permission before opening browser
922-
const { isCI } = CI;
923921
if (!getAPIToken()) {
924922
// Not logged in.
925923
// If we are not interactive, we cannot ask the user to login
926-
return isInteractive() && !isCI() && (await login(props));
924+
return !isNonInteractiveOrCI() && (await login(props));
927925
} else if (isAccessTokenExpired()) {
928926
// We're logged in, but the refresh token seems to have expired,
929927
// so let's try to refresh it
@@ -933,7 +931,7 @@ export async function loginOrRefreshIfRequired(
933931
return true;
934932
} else {
935933
// If the refresh token isn't valid, then we ask the user to login again
936-
return isInteractive() && !isCI() && (await login(props));
934+
return !isNonInteractiveOrCI() && (await login(props));
937935
}
938936
} else {
939937
return true;
@@ -1193,7 +1191,7 @@ export async function requireAuth(config: {
11931191
}): Promise<string> {
11941192
const loggedIn = await loginOrRefreshIfRequired();
11951193
if (!loggedIn) {
1196-
if (!isInteractive() || CI.isCI()) {
1194+
if (isNonInteractiveOrCI()) {
11971195
throw new UserError(
11981196
"In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN."
11991197
);

‎packages/wrangler/src/versions/deploy.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import {
1111
import { fetchResult } from "../cfetch";
1212
import { findWranglerToml, readConfig } from "../config";
1313
import { UserError } from "../errors";
14-
import { CI } from "../is-ci";
15-
import isInteractive from "../is-interactive";
14+
import { isNonInteractiveOrCI } from "../is-interactive";
1615
import { logger } from "../logger";
1716
import * as metrics from "../metrics";
1817
import { writeOutput } from "../output";
@@ -272,8 +271,8 @@ export async function confirmLatestDeploymentOverwrite(
272271
type: "confirm",
273272
question: `"wrangler deploy" will upload a new version and deploy it globally immediately.\nAre you sure you want to continue?`,
274273
label: "",
275-
defaultValue: !isInteractive() || CI.isCI(), // defaults to true in CI for back-compat
276-
acceptDefault: !isInteractive() || CI.isCI(),
274+
defaultValue: isNonInteractiveOrCI(), // defaults to true in CI for back-compat
275+
acceptDefault: isNonInteractiveOrCI(),
277276
});
278277
}
279278
} catch (e) {

0 commit comments

Comments
 (0)
Please sign in to comment.