translate-app.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. #!/usr/bin/env bun
  2. import path from "path"
  3. import { parseArgs } from "util"
  4. import { pathToFileURL } from "url"
  5. const locales = [
  6. "ar",
  7. "br",
  8. "bs",
  9. "da",
  10. "de",
  11. "es",
  12. "fr",
  13. "ja",
  14. "ko",
  15. "no",
  16. "pl",
  17. "ru",
  18. "uk",
  19. "th",
  20. "tr",
  21. "zh",
  22. "zht",
  23. ] as const
  24. type Locale = (typeof locales)[number]
  25. const languages = {
  26. ar: "Arabic",
  27. br: "Brazilian Portuguese",
  28. bs: "Bosnian",
  29. da: "Danish",
  30. de: "German",
  31. es: "Spanish",
  32. fr: "French",
  33. ja: "Japanese",
  34. ko: "Korean",
  35. no: "Norwegian Bokmal",
  36. pl: "Polish",
  37. ru: "Russian",
  38. uk: "Ukrainian",
  39. th: "Thai",
  40. tr: "Turkish",
  41. zh: "Simplified Chinese",
  42. zht: "Traditional Chinese",
  43. } as const satisfies Record<Locale, string>
  44. type Dictionary = Record<string, string>
  45. type Drift = ReturnType<typeof findDrift>
  46. type Domain = { name: string; source: string; target: string; drift: Drift }
  47. const desktopLocales = new Set<Locale>(locales.filter((locale) => locale !== "th" && locale !== "tr"))
  48. const root = path.resolve(import.meta.dir, "..")
  49. export function parseTranslationArgs(args: string[]) {
  50. const parsed = parseArgs({
  51. args,
  52. options: {
  53. concurrency: { type: "string", short: "c", default: "4" },
  54. model: { type: "string", default: "opencode/gpt-5.5" },
  55. variant: { type: "string", default: "xhigh" },
  56. "dry-run": { type: "boolean", default: false },
  57. check: { type: "boolean", default: false },
  58. help: { type: "boolean", short: "h", default: false },
  59. },
  60. allowPositionals: true,
  61. })
  62. const target = parsed.positionals[0] ?? "all"
  63. const concurrency = Number(parsed.values.concurrency)
  64. if (!parsed.values.help && parsed.positionals.length !== 1) throw new Error("Pass one locale or 'all'.")
  65. if (target !== "all" && !isLocale(target)) throw new Error(`Unknown locale '${target}'.`)
  66. if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Concurrency must be a positive integer.")
  67. return {
  68. target,
  69. concurrency: target === "all" ? concurrency : 1,
  70. model: parsed.values.model,
  71. variant: parsed.values.variant,
  72. dryRun: parsed.values["dry-run"],
  73. check: parsed.values.check,
  74. help: parsed.values.help,
  75. }
  76. }
  77. export function targetFiles(locale: Locale) {
  78. return [
  79. `packages/app/src/i18n/${locale}.ts`,
  80. `packages/ui/src/i18n/${locale}.ts`,
  81. ...(desktopLocales.has(locale) ? [`packages/desktop/src/renderer/i18n/${locale}.ts`] : []),
  82. ]
  83. }
  84. export function glossaryFile(locale: Locale) {
  85. if (locale === "zh") return ".opencode/glossary/zh-cn.md"
  86. if (locale === "zht") return ".opencode/glossary/zh-tw.md"
  87. return `.opencode/glossary/${locale}.md`
  88. }
  89. export function findDrift(source: Dictionary, target: Dictionary) {
  90. return {
  91. missing: Object.keys(source).filter((key) => !Object.hasOwn(target, key)),
  92. extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key)),
  93. placeholders: Object.keys(source).filter(
  94. (key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(),
  95. ),
  96. }
  97. }
  98. export function sessionIDFromEvents(output: string) {
  99. const match = output.match(/"sessionID"\s*:\s*"([^"]+)"/)
  100. if (!match?.[1]) throw new Error("OpenCode did not report a session ID.")
  101. return match[1]
  102. }
  103. export function sessionModels(value: unknown) {
  104. if (!isRecord(value) || !Array.isArray(value.messages))
  105. throw new Error("OpenCode returned an invalid session export.")
  106. return value.messages.flatMap((message) => {
  107. if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant") return []
  108. if (typeof message.info.providerID !== "string" || typeof message.info.modelID !== "string") {
  109. throw new Error("OpenCode session export omitted the assistant model.")
  110. }
  111. return [
  112. {
  113. model: `${message.info.providerID}/${message.info.modelID}`,
  114. variant: typeof message.info.variant === "string" ? message.info.variant : undefined,
  115. },
  116. ]
  117. })
  118. }
  119. export function modelVariants(output: string, model: string) {
  120. const normalized = output.replaceAll("\r\n", "\n")
  121. const marker = `${model}\n`
  122. const start = normalized.indexOf(marker)
  123. if (start < 0) throw new Error(`Model not found: ${model}`)
  124. const provider = model.split("/")[0]
  125. const rest = normalized.slice(start + marker.length)
  126. const next = rest.search(new RegExp(`^${escapeRegExp(provider)}/`, "m"))
  127. const metadata: unknown = JSON.parse((next < 0 ? rest : rest.slice(0, next)).trim())
  128. if (!isRecord(metadata) || !isRecord(metadata.variants)) throw new Error(`Model variants not found: ${model}`)
  129. return metadata.variants
  130. }
  131. export function translationConfig(agent: string, model: string, targets: string[]) {
  132. return {
  133. $schema: "https://opencode.ai/config.json",
  134. model,
  135. default_agent: agent,
  136. share: "disabled" as const,
  137. formatter: false,
  138. lsp: false,
  139. snapshot: false,
  140. agent: {
  141. [agent]: {
  142. mode: "primary" as const,
  143. model,
  144. permission: {
  145. "*": "deny" as const,
  146. read: "allow" as const,
  147. glob: "allow" as const,
  148. grep: "allow" as const,
  149. edit: Object.fromEntries([["*", "deny"], ...targets.map((target) => [target, "allow"])]),
  150. },
  151. },
  152. },
  153. }
  154. }
  155. export function unexpectedChanges(before: Record<string, string>, after: Record<string, string>, allowed: string[]) {
  156. const targets = new Set(allowed)
  157. return [...new Set([...Object.keys(before), ...Object.keys(after)])]
  158. .filter((file) => !targets.has(file) && before[file] !== after[file])
  159. .sort()
  160. }
  161. export async function runPool<T, R>(items: readonly T[], concurrency: number, task: (item: T) => Promise<R>) {
  162. const results = new Map<number, R>()
  163. const entries = items.entries()
  164. const worker = async (): Promise<void> => {
  165. const next = entries.next()
  166. if (next.done) return
  167. results.set(next.value[0], await task(next.value[1]))
  168. await worker()
  169. }
  170. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
  171. return Array.from(results.entries())
  172. .sort((a, b) => a[0] - b[0])
  173. .map((entry) => entry[1])
  174. }
  175. async function main() {
  176. const options = parseTranslationArgs(Bun.argv.slice(2))
  177. if (options.help) {
  178. console.log(`
  179. Usage: bun run translate:app -- <locale|all> [options]
  180. Synchronizes product app translations with the English app, UI, and desktop dictionaries.
  181. Options:
  182. -c, --concurrency <count> Maximum parallel OpenCode runs for 'all' (default: 4)
  183. --model <provider/id> OpenCode model (default: opencode/gpt-5.5)
  184. --variant <name> Model variant (default: xhigh)
  185. --dry-run Report drift without running OpenCode
  186. --check Exit nonzero when translation drift exists
  187. -h, --help Show this help message
  188. Examples:
  189. bun run translate:app -- fr
  190. bun run translate:app -- all --concurrency 4
  191. `)
  192. return
  193. }
  194. const selected = options.target === "all" ? locales : [options.target]
  195. const plans = await Promise.all(selected.map((locale) => inspect(locale)))
  196. plans.forEach(report)
  197. const pending = plans.filter((plan) => plan.domains.some((domain) => changed(domain.drift)))
  198. if (options.check) {
  199. if (pending.length) process.exitCode = 1
  200. return
  201. }
  202. if (options.dryRun || pending.length === 0) return
  203. const targets = pending.flatMap((plan) => plan.domains.map((domain) => domain.target))
  204. const baseline = await worktreeSnapshot()
  205. const variant = await resolveModelVariant(options.model, options.variant)
  206. console.log(`Resolved ${options.model} (${options.variant}): ${JSON.stringify(variant)}`)
  207. const template = await commandTemplate()
  208. const results = await runPool(pending, options.concurrency, (plan) =>
  209. translate(plan, template, options.model, options.variant).catch((error) => ({
  210. locale: plan.locale,
  211. code: 1,
  212. stdout: "",
  213. stderr: error instanceof Error ? error.message : String(error),
  214. })),
  215. )
  216. results.forEach((result) => {
  217. if (result.stdout) process.stdout.write(`\n[${result.locale}]\n${result.stdout}`)
  218. if (result.stderr) process.stderr.write(`\n[${result.locale}]\n${result.stderr}`)
  219. })
  220. const failed = results.filter((result) => result.code !== 0)
  221. const checks = await runPool(pending, options.concurrency, (plan) => check(plan.locale))
  222. const incomplete = checks.filter((result) => result.code !== 0)
  223. const escaped = unexpectedChanges(baseline, await worktreeSnapshot(), targets)
  224. incomplete.forEach((result) => {
  225. if (result.stdout) process.stderr.write(`\n[${result.locale} verification]\n${result.stdout}`)
  226. if (result.stderr) process.stderr.write(`\n[${result.locale} verification]\n${result.stderr}`)
  227. })
  228. if (failed.length === 0 && incomplete.length === 0 && escaped.length === 0) {
  229. console.log(`\nTranslated ${pending.map((plan) => plan.locale).join(", ")}.`)
  230. return
  231. }
  232. if (failed.length) console.error(`\nOpenCode failed for: ${failed.map((result) => result.locale).join(", ")}`)
  233. if (incomplete.length)
  234. console.error(`Translation remains incomplete for: ${incomplete.map((plan) => plan.locale).join(", ")}`)
  235. if (escaped.length) console.error(`Translation changed files outside its locale targets: ${escaped.join(", ")}`)
  236. process.exitCode = 1
  237. }
  238. async function worktreeSnapshot() {
  239. const groups = await Promise.all([
  240. gitPaths(["diff", "--name-only", "-z", "HEAD"]),
  241. gitPaths(["ls-files", "--others", "--exclude-standard", "-z"]),
  242. ])
  243. const files = [...new Set(groups.flat())]
  244. return Object.fromEntries(
  245. await Promise.all(
  246. files.map(async (file) => {
  247. const target = Bun.file(path.join(root, file))
  248. if (!(await target.exists())) return [file, "<missing>"] as const
  249. const hash = new Bun.CryptoHasher("sha256")
  250. hash.update(await target.arrayBuffer())
  251. return [file, hash.digest("hex")] as const
  252. }),
  253. ),
  254. )
  255. }
  256. async function gitPaths(args: string[]) {
  257. const proc = Bun.spawn(["git", ...args], {
  258. cwd: root,
  259. stdin: "ignore",
  260. stdout: "pipe",
  261. stderr: "pipe",
  262. })
  263. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  264. if (result[2] !== 0) throw new Error(result[1] || `git ${args.join(" ")} failed`)
  265. return result[0].split("\0").filter(Boolean)
  266. }
  267. async function check(locale: Locale) {
  268. const proc = Bun.spawn([process.execPath, import.meta.path, locale, "--check"], {
  269. cwd: root,
  270. stdin: "ignore",
  271. stdout: "pipe",
  272. stderr: "pipe",
  273. })
  274. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  275. return { locale, stdout: result[0], stderr: result[1], code: result[2] }
  276. }
  277. async function inspect(locale: Locale) {
  278. const domains = await Promise.all(
  279. targetFiles(locale).map(async (target) => {
  280. const source = target.replace(`/${locale}.ts`, "/en.ts")
  281. const dictionaries = await Promise.all([dictionary(source), dictionary(target)])
  282. return {
  283. name: target.includes("packages/app/") ? "app" : target.includes("packages/ui/") ? "ui" : "desktop",
  284. source,
  285. target,
  286. drift: findDrift(dictionaries[0], dictionaries[1]),
  287. }
  288. }),
  289. )
  290. return { locale, language: languages[locale], domains }
  291. }
  292. async function dictionary(file: string) {
  293. const module: unknown = await import(pathToFileURL(path.join(root, file)).href)
  294. if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
  295. throw new Error(`Invalid translation dictionary: ${file}`)
  296. }
  297. return module.dict
  298. }
  299. async function commandTemplate() {
  300. return (await Bun.file(path.join(root, "script/translate-app.md")).text()).trim()
  301. }
  302. async function translate(
  303. plan: { locale: Locale; language: string; domains: Domain[] },
  304. template: string,
  305. model: string,
  306. variant: string,
  307. ) {
  308. const glossary = glossaryFile(plan.locale)
  309. const glossaryContent = (await Bun.file(path.join(root, glossary)).exists())
  310. ? await Bun.file(path.join(root, glossary)).text()
  311. : undefined
  312. const prompt = template.replaceAll("$1", plan.locale).replaceAll(
  313. "$ARGUMENTS",
  314. JSON.stringify(
  315. {
  316. locale: plan.locale,
  317. language: plan.language,
  318. glossary: glossaryContent ? { file: glossary, content: glossaryContent } : undefined,
  319. domains: plan.domains.map((domain) => ({
  320. source: domain.source,
  321. target: domain.target,
  322. ...domain.drift,
  323. })),
  324. },
  325. null,
  326. 2,
  327. ),
  328. )
  329. const agent = `translate-app-${plan.locale}-${process.pid}`
  330. const env = isolatedEnvironment()
  331. env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
  332. env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
  333. translationConfig(
  334. agent,
  335. model,
  336. plan.domains.map((domain) => domain.target),
  337. ),
  338. )
  339. const proc = Bun.spawn(
  340. [
  341. "opencode",
  342. "--pure",
  343. "run",
  344. "--dir",
  345. root,
  346. "--agent",
  347. agent,
  348. "--model",
  349. model,
  350. "--variant",
  351. variant,
  352. "--title",
  353. `Translate app ${plan.locale}`,
  354. "--format",
  355. "json",
  356. ],
  357. {
  358. cwd: root,
  359. env,
  360. stdin: "pipe",
  361. stdout: "pipe",
  362. stderr: "pipe",
  363. },
  364. )
  365. const stdout = new Response(proc.stdout).text()
  366. const stderr = new Response(proc.stderr).text()
  367. await proc.stdin.write(prompt)
  368. await proc.stdin.end()
  369. const result = await Promise.all([stdout, stderr, proc.exited])
  370. if (result[2] !== 0) return { locale: plan.locale, stdout: result[0], stderr: result[1], code: result[2] }
  371. const sessionID = sessionIDFromEvents(result[0])
  372. const exported = Bun.spawn(["opencode", "--pure", "export", sessionID, "--sanitize"], {
  373. cwd: root,
  374. env,
  375. stdout: "pipe",
  376. stderr: "pipe",
  377. })
  378. const exportResult = await Promise.all([
  379. new Response(exported.stdout).text(),
  380. new Response(exported.stderr).text(),
  381. exported.exited,
  382. ])
  383. if (exportResult[2] !== 0) {
  384. return { locale: plan.locale, stdout: textFromEvents(result[0]), stderr: exportResult[1], code: exportResult[2] }
  385. }
  386. const session: unknown = JSON.parse(exportResult[0])
  387. const observed = sessionModels(session)
  388. const mismatch = observed.length === 0 || observed.some((item) => item.model !== model || item.variant !== variant)
  389. const actual = Array.from(new Set(observed.map((item) => `${item.model} (${item.variant ?? "default"})`))).join(", ")
  390. return {
  391. locale: plan.locale,
  392. stdout: `${textFromEvents(result[0])}\nVerified session model: ${actual}\n`,
  393. stderr: mismatch
  394. ? `Requested ${model} (${variant}), but session used ${actual || "no assistant model"}.\n`
  395. : result[1],
  396. code: mismatch ? 1 : 0,
  397. }
  398. }
  399. function report(plan: { locale: Locale; domains: Domain[] }) {
  400. const details = plan.domains
  401. .map(
  402. (domain) =>
  403. `${domain.name}: ${domain.drift.missing.length} missing, ${domain.drift.extra.length} extra, ${domain.drift.placeholders.length} placeholder mismatches`,
  404. )
  405. .join("; ")
  406. console.log(`[${plan.locale}] ${details}`)
  407. }
  408. function changed(drift: Drift) {
  409. return drift.missing.length > 0 || drift.extra.length > 0 || drift.placeholders.length > 0
  410. }
  411. function isLocale(value: string): value is Locale {
  412. return Object.hasOwn(languages, value)
  413. }
  414. function isDictionary(value: unknown): value is Dictionary {
  415. if (typeof value !== "object" || value === null || Array.isArray(value)) return false
  416. return Object.values(value).every((item) => typeof item === "string")
  417. }
  418. function isRecord(value: unknown): value is Record<string, unknown> {
  419. return typeof value === "object" && value !== null && !Array.isArray(value)
  420. }
  421. async function resolveModelVariant(model: string, variant: string) {
  422. const provider = model.split("/")[0]
  423. if (!provider || !model.includes("/")) throw new Error(`Model must use provider/model syntax: ${model}`)
  424. const env = isolatedEnvironment()
  425. env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
  426. const proc = Bun.spawn(["opencode", "--pure", "models", provider, "--verbose"], {
  427. cwd: root,
  428. env,
  429. stdin: "ignore",
  430. stdout: "pipe",
  431. stderr: "pipe",
  432. })
  433. const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
  434. if (result[2] !== 0) throw new Error(result[1] || `Unable to resolve model: ${model}`)
  435. const variants = modelVariants(result[0], model)
  436. if (!Object.hasOwn(variants, variant)) throw new Error(`Variant '${variant}' is not configured for ${model}.`)
  437. return variants[variant]
  438. }
  439. function isolatedEnvironment() {
  440. const env = { ...process.env }
  441. delete env.OPENCODE_CONFIG
  442. delete env.OPENCODE_CONFIG_DIR
  443. delete env.OPENCODE_CONFIG_CONTENT
  444. delete env.OPENCODE_PERMISSION
  445. delete env.OPENCODE_AUTO_SHARE
  446. return env
  447. }
  448. function escapeRegExp(value: string) {
  449. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
  450. }
  451. export function textFromEvents(output: string) {
  452. return output
  453. .split(/\r?\n/)
  454. .map((line) => line.trim())
  455. .filter((line) => line.startsWith("{") && line.endsWith("}"))
  456. .flatMap((line) => {
  457. const event: unknown = JSON.parse(line)
  458. if (!isRecord(event) || event.type !== "text" || !isRecord(event.part) || typeof event.part.text !== "string") {
  459. return []
  460. }
  461. return [event.part.text.trim()]
  462. })
  463. .filter(Boolean)
  464. .join("\n")
  465. }
  466. function tokens(value: string) {
  467. return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1] ?? "").sort()
  468. }
  469. if (import.meta.main) {
  470. main().catch((error) => {
  471. console.error(error instanceof Error ? error.message : error)
  472. process.exitCode = 1
  473. })
  474. }