diff --git a/.changeset/fix-execa-terminal-locale-override.md b/.changeset/fix-execa-terminal-locale-override.md new file mode 100644 index 0000000000..fe6a5c0da8 --- /dev/null +++ b/.changeset/fix-execa-terminal-locale-override.md @@ -0,0 +1,5 @@ +--- +"zoo-code": patch +--- + +Fix commands run by Zoo Code forcing `LANG`/`LC_ALL` to `en_US.UTF-8` even when the system already has a correctly configured non-US UTF-8 locale (e.g. `en_AU.UTF-8`), which caused a `setlocale: LC_ALL: cannot change locale` warning on every command for anyone whose system locale isn't `en_US.UTF-8`. The existing locale is now preserved when it already specifies a UTF-8 encoding; only an unset locale or an encoding-less POSIX default (`C`/`POSIX`) falls back to `en_US.UTF-8`, and a locale with a non-UTF-8 encoding has its encoding upgraded while its language/territory is kept. diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..32b78ca671 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1206,7 +1206,7 @@ }, "integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 10 + "count": 8 } }, "integrations/terminal/__tests__/OutputInterceptor.test.ts": { diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index cde5a1251f..dbaf002861 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -6,6 +6,30 @@ import type { RooTerminal } from "./types" import { BaseTerminal } from "./BaseTerminal" import { BaseTerminalProcess } from "./BaseTerminalProcess" +/** + * Returns a UTF-8 locale string derived from `value`, preserving the + * language/territory (and any @modifier, e.g. "de_DE@euro") the system + * already has configured instead of forcing en_US. Falls back to + * en_US.UTF-8 when `value` is unset or is one of the encoding-less POSIX + * defaults ("C"/"POSIX"). + */ +export function ensureUtf8Locale(value: string | undefined): string { + if (!value || value === "C" || value === "POSIX") { + return "en_US.UTF-8" + } + + const atIndex = value.indexOf("@") + const modifier = atIndex === -1 ? "" : value.slice(atIndex) + const localeAndEncoding = atIndex === -1 ? value : value.slice(0, atIndex) + + if (/utf-?8$/i.test(localeAndEncoding)) { + return value + } + + const [base] = localeAndEncoding.split(".") + return `${base}.UTF-8${modifier}` +} + export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef private aborted = false @@ -47,9 +71,17 @@ 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", + // Ensure UTF-8 encoding for Ruby, CocoaPods, etc., without + // clobbering a locale the system already has correctly + // configured (see https://github.com/Zoo-Code-Org/Zoo-Code/issues/1084). + LANG: ensureUtf8Locale(process.env.LANG), + // LC_ALL overrides LANG and every category-specific LC_* + // variable, so only normalize it when the system already set + // it -- fabricating one here would silently override a + // correctly configured LANG with en_US.UTF-8. + ...(process.env.LC_ALL !== undefined + ? { LC_ALL: ensureUtf8Locale(process.env.LC_ALL) } + : undefined), }, })`${command}` diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 8292875b87..14de265005 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -30,6 +30,12 @@ import type { RooTerminal } from "../types" import { clearAllMocks } from "../../../test-utils/reset" +function getCalledEnv(): Record { + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as unknown as { env: Record } + return calledOptions.env +} + describe("ExecaTerminalProcess", () => { let mockTerminal: RooTerminal let terminalProcess: ExecaTerminalProcess @@ -62,7 +68,10 @@ describe("ExecaTerminalProcess", () => { }) describe("UTF-8 encoding fix", () => { - it("should set LANG and LC_ALL to en_US.UTF-8", async () => { + it("should default LANG to en_US.UTF-8 and leave LC_ALL unset when neither is set", async () => { + delete process.env.LANG + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) expect(execaMock).toHaveBeenCalledWith( @@ -72,30 +81,80 @@ describe("ExecaTerminalProcess", () => { all: true, env: expect.objectContaining({ LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", }), }), ) + expect(getCalledEnv().LC_ALL).toBeUndefined() }) it("should preserve existing environment variables", async () => { process.env.EXISTING_VAR = "existing" terminalProcess = new ExecaTerminalProcess(mockTerminal) await terminalProcess.run("echo test") - const execaMock = vitest.mocked(execa) - const calledOptions = execaMock.mock.calls[0][0] as any - expect(calledOptions.env.EXISTING_VAR).toBe("existing") + expect(getCalledEnv().EXISTING_VAR).toBe("existing") }) - it("should override existing LANG and LC_ALL values", async () => { + it("should normalize LANG=C to en_US.UTF-8 without fabricating LC_ALL", async () => { process.env.LANG = "C" + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("en_US.UTF-8") + expect(getCalledEnv().LC_ALL).toBeUndefined() + }) + + it("should normalize LC_ALL=POSIX to en_US.UTF-8 when explicitly set", async () => { + delete process.env.LANG process.env.LC_ALL = "POSIX" terminalProcess = new ExecaTerminalProcess(mockTerminal) await terminalProcess.run("echo test") - const execaMock = vitest.mocked(execa) - const calledOptions = execaMock.mock.calls[0][0] as any - expect(calledOptions.env.LANG).toBe("en_US.UTF-8") - expect(calledOptions.env.LC_ALL).toBe("en_US.UTF-8") + expect(getCalledEnv().LANG).toBe("en_US.UTF-8") + expect(getCalledEnv().LC_ALL).toBe("en_US.UTF-8") + }) + + it("should preserve an already-UTF-8 non-US locale instead of forcing en_US (issue #1084)", async () => { + process.env.LANG = "en_AU.UTF-8" + process.env.LC_ALL = "en_AU.UTF-8" + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("en_AU.UTF-8") + expect(getCalledEnv().LC_ALL).toBe("en_AU.UTF-8") + }) + + it("should not fabricate LC_ALL when only LANG is configured (issue #1084)", async () => { + // LC_ALL overrides LANG entirely, so setting it to en_US.UTF-8 here + // would silently re-force en_US despite LANG being correct. + process.env.LANG = "en_AU.UTF-8" + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("en_AU.UTF-8") + expect(getCalledEnv().LC_ALL).toBeUndefined() + }) + + it("should upgrade a non-UTF-8 encoding while keeping the language/territory", async () => { + process.env.LANG = "de_DE.ISO-8859-1" + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("de_DE.UTF-8") + expect(getCalledEnv().LC_ALL).toBeUndefined() + }) + + it("should upgrade the encoding while preserving a locale modifier (e.g. @euro)", async () => { + process.env.LANG = "de_DE@euro" + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("de_DE.UTF-8@euro") + }) + + it("should preserve an already-UTF-8 locale that also has a modifier", async () => { + process.env.LANG = "de_DE.UTF-8@euro" + delete process.env.LC_ALL + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + expect(getCalledEnv().LANG).toBe("de_DE.UTF-8@euro") }) it("should use execaShellPath when set", async () => {