From ad8d70cbaa0e1ec4852d5204a08b95af9faf6142 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Mon, 13 Jul 2026 01:33:48 +0800 Subject: [PATCH] fix(init): detect native system language --- scripts/windows-smoke.mjs | 60 ++++++++++++++++++- src/system/init-onboarding.ts | 106 ++++++++++++++++++++++++++++++++-- tests/init-onboarding.test.ts | 80 +++++++++++++++++++++++-- 3 files changed, 236 insertions(+), 10 deletions(-) diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs index 21ea178..8a29e6d 100644 --- a/scripts/windows-smoke.mjs +++ b/scripts/windows-smoke.mjs @@ -55,6 +55,40 @@ try { 'refresh-project did not regenerate static adapter context', ); + if (process.platform === 'win32') { + const systemLocale = readWindowsUiCulture(); + const normalizedSystemLocale = systemLocale.toLowerCase().replace('_', '-'); + const isChinese = + normalizedSystemLocale === 'zh' || normalizedSystemLocale.startsWith('zh-'); + const isEnglish = + normalizedSystemLocale === 'en' || normalizedSystemLocale.startsWith('en-'); + if (isChinese || isEnglish) { + const opposingLocale = isChinese ? 'en_US.UTF-8' : 'zh_CN.UTF-8'; + const localeProject = await createProject('locale-project'); + const localeResult = runCli( + localeProject, + ['init', '--platform', 'codex'], + { + ...process.env, + LANGUAGE: opposingLocale, + LC_ALL: opposingLocale, + LC_MESSAGES: opposingLocale, + LANG: opposingLocale, + }, + ); + assert( + localeResult.stdout.includes( + isChinese ? '检测系统依赖' : 'Checking system dependencies', + ), + `init did not prefer Windows UI culture ${systemLocale}: ${localeResult.stdout}`, + ); + } else { + console.log( + `Skipping locale priority assertion for unsupported Windows UI culture ${systemLocale}.`, + ); + } + } + const codexProject = await createProject('codex-project'); runCli(codexProject, ['init', '--platform', 'codex']); const codexState = await readJson( @@ -101,16 +135,38 @@ async function createProject(name) { return project; } -function runCli(cwd, args) { +function runCli(cwd, args, env = noToolPath) { const result = spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: 'utf8', - env: noToolPath, + env, }); assert( result.status === 0, `CLI failed (${result.status}): ${result.stderr || result.stdout}`, ); + return result; +} + +function readWindowsUiCulture() { + const result = spawnSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + '[System.Globalization.CultureInfo]::CurrentUICulture.Name', + ], + { encoding: 'utf8' }, + ); + assert( + result.status === 0, + `could not read Windows UI culture: ${result.stderr || result.stdout}`, + ); + const locale = result.stdout.replace(/^\uFEFF/, '').trim(); + assert(locale, 'Windows UI culture was empty'); + return locale; } function runHook(cwd, fileName, input = '') { diff --git a/src/system/init-onboarding.ts b/src/system/init-onboarding.ts index f298d95..df6c86a 100644 --- a/src/system/init-onboarding.ts +++ b/src/system/init-onboarding.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -22,19 +23,116 @@ export interface InitPrompter { } const ALL_PLATFORMS = Object.keys(PLATFORM_INSTALLERS) as PlatformName[]; +let cachedNativeSystemLocale: string | null | undefined; export function detectInitLocale( override?: string, environment: NodeJS.ProcessEnv = process.env, systemLocale: string = Intl.DateTimeFormat().resolvedOptions().locale, + nativeLocale?: string | null, ): InitLocale | null { if (override) return parseLocale(override); - const environmentLocale = - environment.LC_ALL ?? environment.LC_MESSAGES ?? environment.LANG; - return parseLocale(environmentLocale) ?? parseLocale(systemLocale) ?? 'en'; + const resolvedNativeLocale = + nativeLocale === undefined ? getNativeSystemLocale() : nativeLocale; + return ( + parseLocale(resolvedNativeLocale) ?? + detectEnvironmentLocale(environment) ?? + parseLocale(systemLocale) ?? + 'en' + ); } -function parseLocale(value?: string): InitLocale | null { +export function detectNativeSystemLocale( + platform: NodeJS.Platform = process.platform, + runCommand: ( + command: string, + args: readonly string[], + ) => string | null = runLocaleCommand, +): string | null { + if (platform === 'darwin') { + const languages = runCommand('defaults', ['read', '-g', 'AppleLanguages']); + const primaryLanguage = parsePrimaryAppleLanguage(languages); + if (primaryLanguage) return primaryLanguage; + return cleanLocaleOutput( + runCommand('defaults', ['read', '-g', 'AppleLocale']), + ); + } + + if (platform === 'win32') { + return cleanLocaleOutput( + runCommand('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + '[System.Globalization.CultureInfo]::CurrentUICulture.Name', + ]), + ); + } + + return null; +} + +function getNativeSystemLocale(): string | null { + if (cachedNativeSystemLocale === undefined) { + cachedNativeSystemLocale = detectNativeSystemLocale(); + } + return cachedNativeSystemLocale; +} + +function detectEnvironmentLocale( + environment: NodeJS.ProcessEnv, +): InitLocale | null { + const candidates = [ + environment.LANGUAGE, + environment.LC_ALL, + environment.LC_MESSAGES, + environment.LANG, + ]; + for (const candidate of candidates) { + for (const locale of candidate?.split(':') ?? []) { + const parsed = parseLocale(locale); + if (parsed) return parsed; + } + } + return null; +} + +function runLocaleCommand( + command: string, + args: readonly string[], +): string | null { + try { + const output = execFileSync(command, [...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2_000, + windowsHide: true, + }); + return cleanLocaleOutput(output); + } catch { + return null; + } +} + +function parsePrimaryAppleLanguage(output: string | null): string | null { + const cleaned = cleanLocaleOutput(output); + if (!cleaned) return null; + const quoted = cleaned.match(/"([^"]+)"/)?.[1]; + if (quoted) return quoted; + return ( + cleaned + .split(/[\s(),]+/) + .find((value) => /^[a-z]{2,3}(?:[-_][a-z0-9]+)*$/i.test(value)) ?? null + ); +} + +function cleanLocaleOutput(output: string | null): string | null { + const cleaned = output?.replace(/^\uFEFF/, '').trim(); + return cleaned || null; +} + +function parseLocale(value?: string | null): InitLocale | null { if (!value) return null; const normalized = value.toLowerCase().replace('_', '-'); if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN'; diff --git a/tests/init-onboarding.test.ts b/tests/init-onboarding.test.ts index 212454e..c75ecbe 100644 --- a/tests/init-onboarding.test.ts +++ b/tests/init-onboarding.test.ts @@ -23,6 +23,7 @@ import { import type { InitPrompter } from '../src/system/init-onboarding.js'; import { detectInitLocale, + detectNativeSystemLocale, parsePlatformSelection, } from '../src/system/init-onboarding.js'; @@ -35,13 +36,84 @@ describe('init onboarding', () => { ); }); - it('uses terminal locale with an explicit override', () => { - expect(detectInitLocale(undefined, { LANG: 'zh_CN.UTF-8' })).toBe('zh-CN'); - expect(detectInitLocale('en', { LANG: 'zh_CN.UTF-8' })).toBe('en'); - expect(detectInitLocale(undefined, {}, 'zh-CN')).toBe('zh-CN'); + it('uses an explicit locale override before automatic detection', () => { + expect( + detectInitLocale('en', { LANG: 'zh_CN.UTF-8' }, 'zh-CN', 'zh-CN'), + ).toBe('en'); expect(detectInitLocale('fr', {})).toBeNull(); }); + it('prefers the macOS UI language when the terminal locale is C.UTF-8', () => { + expect( + detectInitLocale( + undefined, + { LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' }, + 'en-US', + 'zh-Hans-CN', + ), + ).toBe('zh-CN'); + }); + + it('uses Linux locale variables and treats C/POSIX as unknown', () => { + expect( + detectInitLocale( + undefined, + { LANGUAGE: 'zh_CN:en_US', LANG: 'en_US.UTF-8' }, + 'en-US', + null, + ), + ).toBe('zh-CN'); + expect( + detectInitLocale( + undefined, + { LC_MESSAGES: 'zh_CN.UTF-8' }, + 'en-US', + null, + ), + ).toBe('zh-CN'); + expect( + detectInitLocale(undefined, { LANG: 'zh_CN.UTF-8' }, 'en-US', null), + ).toBe('zh-CN'); + expect( + detectInitLocale( + undefined, + { LC_ALL: 'POSIX', LANG: 'C.UTF-8' }, + 'zh-CN', + null, + ), + ).toBe('zh-CN'); + }); + + it('reads the primary macOS Apple language', () => { + const locale = detectNativeSystemLocale('darwin', (command, args) => { + expect(command).toBe('defaults'); + expect(args).toEqual(['read', '-g', 'AppleLanguages']); + return '(\n "zh-Hans-CN",\n "en-CN"\n)'; + }); + + expect(locale).toBe('zh-Hans-CN'); + }); + + it('falls back to the macOS Apple locale', () => { + const locale = detectNativeSystemLocale('darwin', (_command, args) => + args.includes('AppleLanguages') ? null : 'zh_CN', + ); + + expect(locale).toBe('zh_CN'); + }); + + it('reads the Windows UI culture without a shell pipeline', () => { + const locale = detectNativeSystemLocale('win32', (command, args) => { + expect(command).toBe('powershell.exe'); + expect(args).toContain( + '[System.Globalization.CultureInfo]::CurrentUICulture.Name', + ); + return '\uFEFFzh-CN\r\n'; + }); + + expect(locale).toBe('zh-CN'); + }); + it('keeps English init output consistently English', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'mancode-locale-')); dirs.push(dir);