Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions scripts/windows-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = '') {
Expand Down
106 changes: 102 additions & 4 deletions src/system/init-onboarding.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down
80 changes: 76 additions & 4 deletions tests/init-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import type { InitPrompter } from '../src/system/init-onboarding.js';
import {
detectInitLocale,
detectNativeSystemLocale,
parsePlatformSelection,
} from '../src/system/init-onboarding.js';

Expand All @@ -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);
Expand Down
Loading