diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 53d2e2b6ae..276de7699f 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -420,6 +420,7 @@ Options: --interactive Interactive mode (port selection for dashboard, issue selection for import) --paused Start with engine paused (automation disabled) --dev Start dashboard only (no AI engine) + --lang UI locale for this run (en, zh-CN, zh-TW, fr, es) --attach Attach file(s) on task create (repeatable) --depends Declare dependency on task create (repeatable) --no-dedup Bypass deterministic duplicate guard on task create diff --git a/packages/cli/src/commands/settings.ts b/packages/cli/src/commands/settings.ts index 856523e283..c8bdc331ae 100644 --- a/packages/cli/src/commands/settings.ts +++ b/packages/cli/src/commands/settings.ts @@ -4,6 +4,7 @@ import { type GlobalSettings, DEFAULT_SETTINGS, resolveWorktrunkSettings, + SUPPORTED_LOCALES, } from "@fusion/core"; import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; @@ -28,9 +29,10 @@ export const VALID_SETTINGS = [ "worktrunk.enabled", "worktrunk.binaryPath", "worktrunk.onFailure", + "language", ] as const; -const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const; +const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const; const PROJECT_ONLY_SETTINGS = [ "maxConcurrent", "maxWorktrees", @@ -64,6 +66,7 @@ const ENUM_SETTINGS: Record = { worktreeNaming: ["random", "task-id", "task-title"], unavailableNodePolicy: ["block", "fallback-local"], "worktrunk.onFailure": ["fail", "fallback-native"], + language: SUPPORTED_LOCALES, }; const STRING_SETTINGS: readonly string[] = [ diff --git a/packages/cli/src/i18n/__tests__/i18n.test.tsx b/packages/cli/src/i18n/__tests__/i18n.test.tsx index 7c2c0702df..4321401ae5 100644 --- a/packages/cli/src/i18n/__tests__/i18n.test.tsx +++ b/packages/cli/src/i18n/__tests__/i18n.test.tsx @@ -23,6 +23,21 @@ describe("detectEnvLocale", () => { expect(detectEnvLocale({ LANG: "de_DE.UTF-8" })).toBeUndefined(); expect(detectEnvLocale({})).toBeUndefined(); }); + + it("resolves Traditional-script Chinese tags to zh-TW, not Simplified", () => { + expect(detectEnvLocale({ LANG: "zh_Hant_TW.UTF-8" })).toBe("zh-TW"); + expect(detectEnvLocale({ LANG: "zh_Hant" })).toBe("zh-TW"); + expect(detectEnvLocale({ LANG: "zh_HK.UTF-8" })).toBe("zh-TW"); + expect(detectEnvLocale({ LANG: "zh_MO" })).toBe("zh-TW"); + // Simplified-script and mainland tags stay Simplified. + expect(detectEnvLocale({ LANG: "zh_Hans_CN" })).toBe("zh-CN"); + expect(detectEnvLocale({ LANG: "zh_SG" })).toBe("zh-CN"); + }); + + it("normalizes multi-underscore POSIX tags", () => { + expect(detectEnvLocale({ LANG: "en_US_POSIX" })).toBe("en"); + expect(detectEnvLocale({ LANGUAGE: "fr_FR.UTF-8" })).toBe("fr"); + }); }); describe("resolveCliLocale precedence", () => { diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 404768e76f..d382c40396 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -26,13 +26,27 @@ export function detectEnvLocale(env: NodeJS.ProcessEnv = process.env): Locale | if (!raw) return undefined; // Strip encoding/modifier (`.UTF-8`, `@euro`) and normalize `_` to `-`. - const code = raw.split(/[.:@\s]/)[0].replace("_", "-"); + const code = raw.split(/[.:@\s]/)[0].replaceAll("_", "-"); if (isLocale(code)) return code; - const lang = code.split("-")[0].toLowerCase(); + const lower = code.toLowerCase(); + // Script/region-aware Chinese resolution BEFORE the bare-language fallback, + // so Traditional-script tags (zh_Hant, zh_Hant_TW, zh_HK, zh_MO) are not + // silently served Simplified. Region-only zh_CN/zh_TW already matched above. + if (lower.startsWith("zh")) { + if ( + lower.includes("hant") || + lower.includes("-tw") || + lower.includes("-hk") || + lower.includes("-mo") + ) { + return "zh-TW"; + } + return "zh-CN"; + } + + const lang = lower.split("-")[0]; if (isLocale(lang)) return lang; - // A bare `zh` resolves to Simplified, mirroring the shared fallback chain. - if (lang === "zh") return "zh-CN"; return undefined; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a3dd0cd780..a7d43b6589 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -8,6 +8,7 @@ import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSn import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js"; +import { validateLocale } from "./settings-validation.js"; import { normalizeTaskPriority } from "./task-priority.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; @@ -3251,6 +3252,18 @@ export class TaskStore extends EventEmitter { (globalPatch as Record)["experimentalFeatures"] = mergedMap; } + // Validate the optional UI locale at the write boundary: drop unrecognized + // values rather than persisting junk into settings.json. Runtime consumers + // also guard via isLocale, but the contract is `language?: Locale`. + if ("language" in globalPatch) { + const validatedLanguage = validateLocale((globalPatch as Record)["language"]); + if (validatedLanguage === undefined) { + delete (globalPatch as Record)["language"]; + } else { + globalPatch.language = validatedLanguage; + } + } + const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch); const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings; try { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 65169a169e..fb45501433 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -56,6 +56,7 @@ "verify:locale-chunks": "node scripts/assert-locale-chunks.mjs", "prebuild": "node scripts/sync-locales.mjs", "build": "vite build && tsc", + "prebuild:client": "node scripts/sync-locales.mjs", "build:client": "vite build", "predev:serve": "node scripts/sync-locales.mjs", "dev": "pnpm build && pnpm typecheck && pnpm dev:serve", diff --git a/packages/dashboard/scripts/assert-locale-chunks.mjs b/packages/dashboard/scripts/assert-locale-chunks.mjs index 6bfc8a2075..1c2ba1031b 100644 --- a/packages/dashboard/scripts/assert-locale-chunks.mjs +++ b/packages/dashboard/scripts/assert-locale-chunks.mjs @@ -12,6 +12,13 @@ const here = dirname(fileURLToPath(import.meta.url)); const assetsDir = join(here, "..", "dist", "client", "assets"); const namespaces = ["common", "app", "errors"]; +// Derive the expected per-namespace chunk floor from the authored locale set +// rather than hardcoding a count, so adding a locale needs no edit here. +const localesDir = join(here, "..", "..", "i18n", "locales"); +const expectedLocaleCount = readdirSync(localesDir, { withFileTypes: true }).filter( + (d) => d.isDirectory(), +).length; + if (!existsSync(assetsDir)) { console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`); process.exit(1); @@ -23,9 +30,9 @@ const errors = []; // One chunk per dashboard namespace per locale (5 locales) → at least 5 each. for (const ns of namespaces) { const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f)); - if (matches.length < 5) { + if (matches.length < expectedLocaleCount) { errors.push( - `expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`, + `expected >=${expectedLocaleCount} split chunks for namespace "${ns}" (one per locale), found ${matches.length}`, ); } }