From e9bdee169e3e68966f236ad17a8514d565a78a88 Mon Sep 17 00:00:00 2001 From: mmskazak Date: Tue, 1 Sep 2026 09:32:31 +0300 Subject: [PATCH 1/3] fix(terminal): stop forcing en_US.UTF-8 in execa command environment ExecaTerminalProcess unconditionally overwrote LANG/LC_ALL with en_US.UTF-8 for every command it ran, even when the host already had a correctly configured non-US UTF-8 locale (e.g. en_AU.UTF-8). This produced setlocale warnings on every command for anyone whose system locale isn't en_US. ensureUtf8Locale() now preserves an existing locale if it already specifies a UTF-8 encoding, upgrades the encoding portion of a non-UTF-8 locale while keeping its language/territory, and only falls back to en_US.UTF-8 when LANG/LC_ALL is unset or one of the encoding-less POSIX defaults ("C"/"POSIX"). Fixes #1084 --- .../terminal/ExecaTerminalProcess.ts | 27 ++++++++++++++++--- .../__tests__/ExecaTerminalProcess.spec.ts | 27 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index cde5a1251f..faef79536c 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -6,6 +6,25 @@ 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 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" + } + + if (/utf-?8$/i.test(value)) { + return value + } + + const [base] = value.split(".") + return `${base}.UTF-8` +} + export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef private aborted = false @@ -47,9 +66,11 @@ 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: ensureUtf8Locale(process.env.LC_ALL), }, })`${command}` diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 8292875b87..004a95d548 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -62,7 +62,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 and LC_ALL to en_US.UTF-8 when unset", 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( @@ -98,6 +101,28 @@ describe("ExecaTerminalProcess", () => { expect(calledOptions.env.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") + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as unknown as { env: Record } + expect(calledOptions.env.LANG).toBe("en_AU.UTF-8") + expect(calledOptions.env.LC_ALL).toBe("en_AU.UTF-8") + }) + + 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") + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as unknown as { env: Record } + expect(calledOptions.env.LANG).toBe("de_DE.UTF-8") + expect(calledOptions.env.LC_ALL).toBe("en_US.UTF-8") + }) + it("should use execaShellPath when set", async () => { BaseTerminal.setExecaShellPath("/bin/bash") await terminalProcess.run("echo test") From 6f0d18612ec3cdcdb7cb9fdc943d13ae9d944579 Mon Sep 17 00:00:00 2001 From: mmskazak Date: Tue, 1 Sep 2026 09:34:21 +0300 Subject: [PATCH 2/3] chore: add changeset for execa terminal locale fix --- .changeset/fix-execa-terminal-locale-override.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-execa-terminal-locale-override.md 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. From fc20cf51607ecdaaf4461069b15560157ba21554 Mon Sep 17 00:00:00 2001 From: mmskazak Date: Wed, 2 Sep 2026 13:32:21 +0300 Subject: [PATCH 3/3] fix(terminal): don't fabricate LC_ALL and preserve locale @modifiers Addresses CodeRabbit review feedback on #1481: - LC_ALL overrides LANG and every category-specific LC_* variable. The previous version of ensureUtf8Locale() defaulted LC_ALL to en_US.UTF-8 whenever it was unset, even if LANG was already a correctly configured non-US UTF-8 locale -- silently re-forcing en_US and recreating the exact bug from #1084 for the common case where only LANG is set. LC_ALL is now only normalized (and only ever set) when the environment already had it. - ensureUtf8Locale() now parses a trailing locale modifier (e.g. "de_DE@euro") separately from the language/encoding portion, so the modifier survives an encoding upgrade instead of being appended after ".UTF-8" (which produced an invalid locale string) or dropped entirely. - Added regression tests for both fixes, plus the isolated C/POSIX fallback cases CodeRabbit asked for, and replaced the two remaining `as any` test casts with a small typed getCalledEnv() helper (also pruned the now-inflated eslint-suppressions.json count for the test file from 10 to 8, since two pre-existing `any` casts became unused by that same helper). --- src/eslint-suppressions.json | 2 +- .../terminal/ExecaTerminalProcess.ts | 25 +++++-- .../__tests__/ExecaTerminalProcess.spec.ts | 70 ++++++++++++++----- 3 files changed, 71 insertions(+), 26 deletions(-) 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 faef79536c..dbaf002861 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -8,21 +8,26 @@ import { BaseTerminalProcess } from "./BaseTerminalProcess" /** * Returns a UTF-8 locale string derived from `value`, preserving the - * language/territory 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"). + * 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" } - if (/utf-?8$/i.test(value)) { + 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] = value.split(".") - return `${base}.UTF-8` + const [base] = localeAndEncoding.split(".") + return `${base}.UTF-8${modifier}` } export class ExecaTerminalProcess extends BaseTerminalProcess { @@ -70,7 +75,13 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { // 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: ensureUtf8Locale(process.env.LC_ALL), + // 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 004a95d548..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,7 @@ describe("ExecaTerminalProcess", () => { }) describe("UTF-8 encoding fix", () => { - it("should default LANG and LC_ALL to en_US.UTF-8 when unset", 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) @@ -75,30 +81,35 @@ 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 () => { @@ -106,10 +117,19 @@ describe("ExecaTerminalProcess", () => { process.env.LC_ALL = "en_AU.UTF-8" 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: Record } - expect(calledOptions.env.LANG).toBe("en_AU.UTF-8") - expect(calledOptions.env.LC_ALL).toBe("en_AU.UTF-8") + 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 () => { @@ -117,10 +137,24 @@ describe("ExecaTerminalProcess", () => { delete process.env.LC_ALL 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: Record } - expect(calledOptions.env.LANG).toBe("de_DE.UTF-8") - expect(calledOptions.env.LC_ALL).toBe("en_US.UTF-8") + 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 () => {