From 42215638c240985deb2f836af65f49a15ccd244f Mon Sep 17 00:00:00 2001 From: hebulin Date: Sun, 20 Sep 2026 11:42:56 +0800 Subject: [PATCH] fix(terminal): inherit the host UTF-8 locale instead of forcing en_US.UTF-8 Commands spawned by the execa terminal spread process.env and then hardcoded LANG and LC_ALL to en_US.UTF-8, so a host that already resolved to a UTF-8 locale (for example en_AU.UTF-8) was overridden and every command printed "setlocale: LC_ALL: cannot change locale (en_US.UTF-8)". Resolve the effective locale the way POSIX does (LC_ALL, then LC_CTYPE, then LANG) and only fall back to en_US.UTF-8 when that locale is not UTF-8, which keeps the UTF-8 guarantee for Ruby/CocoaPods on hosts that do not configure a UTF-8 locale. Fixes #1084 --- .../terminal/ExecaTerminalProcess.ts | 7 +- .../__tests__/ExecaTerminalProcess.spec.ts | 24 +++++++ .../terminal/__tests__/localeEnv.spec.ts | 65 +++++++++++++++++++ src/integrations/terminal/localeEnv.ts | 42 ++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/integrations/terminal/__tests__/localeEnv.spec.ts create mode 100644 src/integrations/terminal/localeEnv.ts diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index a37646f81d..6e969ba8f3 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -6,6 +6,7 @@ import type { RooTerminal } from "./types" import { BaseTerminal } from "./BaseTerminal" import { BaseTerminalProcess } from "./BaseTerminalProcess" import { getShell } from "../../utils/shell" +import { getUtf8LocaleEnv } from "./localeEnv" export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -48,9 +49,9 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { stdin: "ignore", env: { ...process.env, - // Ensure UTF-8 encoding for Ruby, CocoaPods, etc. - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", + // Keep the host locale when it already is UTF-8 (e.g. en_AU.UTF-8), otherwise + // fall back to en_US.UTF-8 so tools such as Ruby and CocoaPods still emit UTF-8. + ...getUtf8LocaleEnv(), }, })`${command}` diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 94f627200c..03747a4e52 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -63,9 +63,20 @@ describe("ExecaTerminalProcess", () => { }) describe("UTF-8 encoding fix", () => { + /** + * Clears the locale variables so the assertion does not depend on the locale of the + * machine (or CI runner) that executes the test. + */ + const clearLocaleVariables = () => { + delete process.env.LANG + delete process.env.LC_ALL + delete process.env.LC_CTYPE + } + it("should set LANG and LC_ALL to en_US.UTF-8", async () => { // Deterministic shell so the assertion focuses solely on LANG/LC_ALL. vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") + clearLocaleVariables() await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) expect(execaMock).toHaveBeenCalledWith( @@ -81,6 +92,18 @@ describe("ExecaTerminalProcess", () => { ) }) + it("preserves an inherited UTF-8 locale instead of forcing en_US.UTF-8 (#1084)", async () => { + process.env.LANG = "en_AU.UTF-8" + delete process.env.LC_ALL + delete process.env.LC_CTYPE + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as unknown as { env: NodeJS.ProcessEnv } + expect(calledOptions.env.LANG).toBe("en_AU.UTF-8") + expect(calledOptions.env.LC_ALL).toBeUndefined() + }) + it("should preserve existing environment variables", async () => { process.env.EXISTING_VAR = "existing" terminalProcess = new ExecaTerminalProcess(mockTerminal) @@ -91,6 +114,7 @@ describe("ExecaTerminalProcess", () => { }) it("should override existing LANG and LC_ALL values", async () => { + // "C" and "POSIX" select ASCII, not UTF-8, so the UTF-8 fallback still applies. process.env.LANG = "C" process.env.LC_ALL = "POSIX" terminalProcess = new ExecaTerminalProcess(mockTerminal) diff --git a/src/integrations/terminal/__tests__/localeEnv.spec.ts b/src/integrations/terminal/__tests__/localeEnv.spec.ts new file mode 100644 index 0000000000..3aa8780230 --- /dev/null +++ b/src/integrations/terminal/__tests__/localeEnv.spec.ts @@ -0,0 +1,65 @@ +import { getUtf8LocaleEnv } from "../localeEnv" + +describe("getUtf8LocaleEnv", () => { + it("falls back to en_US.UTF-8 when the host provides no locale at all", () => { + expect(getUtf8LocaleEnv({})).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }) + }) + + it("falls back when the host only provides empty locale variables", () => { + expect(getUtf8LocaleEnv({ LANG: "", LC_ALL: "", LC_CTYPE: "" })).toEqual({ + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }) + }) + + it("falls back for ASCII locales such as C and POSIX", () => { + expect(getUtf8LocaleEnv({ LANG: "C" })).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }) + expect(getUtf8LocaleEnv({ LC_ALL: "POSIX" })).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }) + }) + + it("leaves an inherited UTF-8 LANG untouched (#1084)", () => { + expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8" })).toEqual({}) + expect(getUtf8LocaleEnv({ LANG: "en_GB.UTF-8", PATH: "/usr/bin" })).toEqual({}) + }) + + it("leaves an inherited UTF-8 LC_ALL untouched", () => { + expect(getUtf8LocaleEnv({ LC_ALL: "de_DE.UTF-8" })).toEqual({}) + }) + + it("accepts the utf8 spelling and LC_CTYPE as a UTF-8 signal", () => { + expect(getUtf8LocaleEnv({ LANG: "en_AU.utf8" })).toEqual({}) + expect(getUtf8LocaleEnv({ LC_CTYPE: "zh_CN.UTF-8" })).toEqual({}) + }) + + it("honors the POSIX precedence order, where LC_ALL wins over LANG", () => { + expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8", LC_ALL: "POSIX" })).toEqual({ + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }) + }) + + it("honors the POSIX precedence order, where LC_CTYPE wins over LANG", () => { + // Guards the resolution order: reading LANG before LC_CTYPE would wrongly + // treat the ASCII LC_CTYPE as an inherited UTF-8 locale. + expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8", LC_CTYPE: "POSIX" })).toEqual({ + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }) + }) + + it("honors the POSIX precedence order, where LC_ALL wins over LC_CTYPE", () => { + // LC_ALL overrides the category variables, so a UTF-8 LC_CTYPE must not + // rescue an ASCII LC_ALL. + expect(getUtf8LocaleEnv({ LC_ALL: "POSIX", LC_CTYPE: "de_DE.UTF-8" })).toEqual({ + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }) + }) + + it("returns a fresh object so callers cannot share and mutate the override", () => { + const first = getUtf8LocaleEnv({}) + first.LANG = "mutated" + + expect(getUtf8LocaleEnv({})).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }) + }) +}) diff --git a/src/integrations/terminal/localeEnv.ts b/src/integrations/terminal/localeEnv.ts new file mode 100644 index 0000000000..96c5dd284a --- /dev/null +++ b/src/integrations/terminal/localeEnv.ts @@ -0,0 +1,42 @@ +/** + * Locale environment handling for commands spawned by Zoo Code. + * + * Commands used to be spawned with a hardcoded `LANG`/`LC_ALL` of `en_US.UTF-8` so that + * tools such as Ruby and CocoaPods always emit UTF-8. Overriding the locale + * unconditionally is harmful on hosts where `en_US.UTF-8` is not generated: every + * command then prints `setlocale: LC_ALL: cannot change locale (en_US.UTF-8)`, and + * locale-sensitive tools behave as if the machine were US English. + */ + +/** UTF-8 locale used when (and only when) the host does not provide one of its own. */ +const FALLBACK_UTF8_LOCALE = "en_US.UTF-8" + +/** + * Resolves the effective locale of a process environment. + * + * POSIX resolves the locale from the first non-empty value of `LC_ALL`, `LC_CTYPE` and + * `LANG`, so the effective locale is what matters here, not the individual variables. + */ +function getEffectiveLocale(env: NodeJS.ProcessEnv): string { + return env.LC_ALL || env.LC_CTYPE || env.LANG || "" +} + +/** Whether a locale string selects a UTF-8 codeset (accepts the `UTF-8` and `utf8` spellings). */ +function isUtf8Locale(locale: string): boolean { + return /utf-?8/i.test(locale) +} + +/** + * Returns the locale overrides to merge into the environment of a spawned command. + * + * A host that already resolves to a UTF-8 locale is left untouched so that commands + * inherit the user's locale; only a host without any UTF-8 locale receives the UTF-8 + * fallback that the historical hardcoded override was meant to provide. + */ +export function getUtf8LocaleEnv(env: NodeJS.ProcessEnv = process.env): { LANG?: string; LC_ALL?: string } { + if (isUtf8Locale(getEffectiveLocale(env))) { + return {} + } + + return { LANG: FALLBACK_UTF8_LOCALE, LC_ALL: FALLBACK_UTF8_LOCALE } +}