UNPKG

@alauda-fe/i18n-tools

Version:

基于 Azure OpenAI 的 JSON i18n 文件翻译和英文语法检查工具集

922 lines (778 loc) 27.5 kB
import fs from "node:fs/promises"; import path from "node:path"; import { AzureOpenAI } from "openai"; import { setTimeout } from "node:timers/promises"; import { Logger, Validator, ErrorHandler, ProgressTracker, CONFIG, } from "./utils.js"; import { buildTranslationUserPrompt, buildCustomTranslationSystemPrompt, getFriendlyLanguageName, } from "./prompts.js"; // ====================== 初始化工具 ====================== const logger = new Logger("Translate"); const errorHandler = new ErrorHandler(logger, CONFIG.LOGS.TRANSLATE); // ====================== 转义/反转义 JSON key 中的点 ====================== function escapeKey(seg) { return seg.replace(/\./g, CONFIG.DOT_ESC); } function unescapeKey(seg) { return seg.replace(new RegExp(CONFIG.DOT_ESC, "g"), "."); } // ====================== 工具函数 ====================== // 递归收集所有叶子节点路径,并 escapeKey 转义 "." // 过滤掉 key 为空字符串或者 value 为空字符串的条目 function collectKeys(obj, prefix = "") { return Object.entries(obj).flatMap(([key, val]) => { // 忽略空字符串的 key if (key === "") { return []; } const esc = escapeKey(key); const p = prefix ? `${prefix}.${esc}` : esc; if (val !== null && typeof val === "object") { return collectKeys(val, p); } // 忽略空字符串的 value if (val === "") { return []; } return [p]; }); } // 深度删除嵌套对象中的键 function deleteNestedKey(obj, pathStr) { const segs = pathStr.split(".").map(unescapeKey); let cur = obj; for (let i = 0; i < segs.length - 1; i++) { if (!cur[segs[i]] || typeof cur[segs[i]] !== "object") { return; // 路径不存在,无需删除 } cur = cur[segs[i]]; } delete cur[segs[segs.length - 1]]; // 递归删除空对象 const parent = segs.slice(0, -1); if (parent.length > 0 && Object.keys(cur).length === 0) { deleteNestedKey(obj, parent.join(".")); } } // 比较两个JSON对象的差异 function compareJsonObjects(newObj, snapshotObj) { const newKeys = new Set(collectKeys(newObj)); const snapshotKeys = new Set(collectKeys(snapshotObj)); const added = [...newKeys].filter((k) => !snapshotKeys.has(k)); const deleted = [...snapshotKeys].filter((k) => !newKeys.has(k)); const modified = [...newKeys].filter((k) => { if (!snapshotKeys.has(k)) return false; const newVal = nestedUtils.get(newObj, k); const snapshotVal = nestedUtils.get(snapshotObj, k); return newVal !== snapshotVal; }); return { added, deleted, modified }; } // 嵌套对象读写:路径分割后 unescapeKey const nestedUtils = { get(obj, pathStr) { return pathStr .split(".") .map(unescapeKey) .reduce((o, k) => (o || {})[k], obj); }, set(obj, pathStr, value) { const segs = pathStr.split(".").map(unescapeKey); let cur = obj; while (segs.length > 1) { const k = segs.shift(); if (!cur[k] || typeof cur[k] !== "object") cur[k] = {}; cur = cur[k]; } cur[segs[0]] = value; }, }; // 将数组按指定大小分块 function chunkArray(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) { out.push(arr.slice(i, i + size)); } return out; } // ====================== 占位符处理 ====================== function placeholderize(text) { const map = {}; // 支持 {{var}} 和 ${var} 两种模板格式 const parts = text.match(/(\{\{[\s\S]+?\}\}|\$\{[\s\S]+?\})/g) || []; parts.forEach((p, i) => { const key = `__PH_${i}__`; map[key] = p; text = text.replace(p, key); }); return { text, map }; } function restorePlaceholders(text, map) { Object.entries(map).forEach(([key, orig]) => { text = text.replace(new RegExp(key, "g"), orig); }); return text; } function encodeBatch(batch) { const maps = {}; const newBatch = {}; for (const [k, v] of Object.entries(batch)) { // 忽略空字符串的 key 或 value if (k === "" || v === "") { continue; } if (typeof v === "string") { const { text, map } = placeholderize(v); newBatch[k] = text; maps[k] = map; } else { newBatch[k] = v; maps[k] = {}; } } return { newBatch, maps }; } function decodeBatch(result, maps) { const out = {}; for (const [k, v] of Object.entries(result)) { // 忽略空字符串的 key 或 value if (k === "" || v === "") { continue; } out[k] = typeof v === "string" ? restorePlaceholders(v, maps[k] || {}) : v; } return out; } // ====================== 翻译器工厂 ====================== function createTranslator( openai, srcLang, tgtLang, customPromptPath, extraRules ) { // 获取友好的语言名称用于提示词 const srcLangName = getFriendlyLanguageName(srcLang); const tgtLangName = getFriendlyLanguageName(tgtLang); async function doRequest(batch) { await setTimeout(CONFIG.REQUEST_INTERVAL); // 构建自定义提示词 const systemPrompt = await buildCustomTranslationSystemPrompt( customPromptPath, extraRules ); const payload = { model: "gpt-4o-mini", messages: [ { role: "system", content: systemPrompt }, { role: "user", content: buildTranslationUserPrompt(srcLangName, tgtLangName), }, { role: "user", content: JSON.stringify(batch) }, ], temperature: 0.2, // 降低随机性,提高一致性 response_format: { type: "json_object" }, }; logger.apiRequest(batch, `翻译 (${srcLang}${tgtLang})`); const resp = await openai.chat.completions.create(payload); const content = resp.choices[0].message.content; logger.apiResponse(content); return content; } return async (batch) => { // 分离需要翻译的条目、注释条目和英文条目 const commentKeys = []; const englishKeys = []; const translateBatch = {}; Object.entries(batch).forEach(([k, v]) => { // 忽略空字符串的 key 或 value if (k === "" || v === "") { return; } if (typeof v === "string" && v.startsWith("// ")) { commentKeys.push(k); } else if (k.endsWith("_en")) { englishKeys.push(k); } else { translateBatch[k] = v; } }); // 如果没有需要翻译的条目,直接返回注释条目和英文条目 if (Object.keys(translateBatch).length === 0) { const commentEntries = commentKeys.map(k => [k, batch[k]]); const englishEntries = englishKeys.map(k => [k, batch[k]]); return Object.fromEntries([...commentEntries, ...englishEntries]); } const { newBatch, maps } = encodeBatch(translateBatch); try { const content = await doRequest(newBatch); const data = JSON.parse(content); const decoded = decodeBatch(data, maps); // 验证模板变量完整性(支持 {{var}} 和 ${var} 格式) const translatedEntries = Object.entries(translateBatch).map(([k, v]) => { if (typeof v !== "string") return [k, decoded[k]]; const originalVars = (v.match(/(\{\{.*?\}\}|\$\{.*?\})/g) || []) .sort() .join(); const translatedVars = ( (decoded[k] || "").match(/(\{\{.*?\}\}|\$\{.*?\})/g) || [] ) .sort() .join(); // 如果模板变量不匹配,返回 null 表示翻译失败 return [k, originalVars === translatedVars ? decoded[k] : null]; }); // 合并翻译结果、注释条目和英文条目 const commentEntries = commentKeys.map(k => [k, batch[k]]); const englishEntries = englishKeys.map(k => [k, batch[k]]); return Object.fromEntries([...translatedEntries, ...commentEntries, ...englishEntries]); } catch (err) { const errorResult = errorHandler.handleApiError(err, () => doRequest(newBatch) ); if (errorResult.shouldRetry && errorResult.retryCallback) { await setTimeout(errorResult.waitMs || 60000); try { const content = await errorResult.retryCallback(); const data = JSON.parse(content); const decoded = decodeBatch(data, maps); // 验证模板变量完整性(支持 {{var}} 和 ${var} 格式) const translatedEntries = Object.entries(translateBatch).map(([k, v]) => { if (typeof v !== "string") return [k, decoded[k]]; const originalVars = (v.match(/(\{\{.*?\}\}|\$\{.*?\})/g) || []) .sort() .join(); const translatedVars = ( (decoded[k] || "").match(/(\{\{.*?\}\}|\$\{.*?\})/g) || [] ) .sort() .join(); return [k, originalVars === translatedVars ? decoded[k] : null]; }); // 合并翻译结果、注释条目和英文条目 const commentEntries = commentKeys.map(k => [k, batch[k]]); const englishEntries = englishKeys.map(k => [k, batch[k]]); return Object.fromEntries([...translatedEntries, ...commentEntries, ...englishEntries]); } catch {} } await errorHandler.logError(err, { batch: Object.keys(batch), srcLang, tgtLang, }); // 即使翻译失败,也返回注释条目和英文条目 const failedEntries = Object.keys(translateBatch).map(k => [k, null]); const commentEntries = commentKeys.map(k => [k, batch[k]]); const englishEntries = englishKeys.map(k => [k, batch[k]]); return Object.fromEntries([...failedEntries, ...commentEntries, ...englishEntries]); } }; } // ====================== 主翻译函数 ====================== async function translateFiles( srcLang, tgtLang, token, customPromptPath, extraRules ) { // 参数验证 const validatedToken = Validator.validateApiKey(token); const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言"); const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言"); // 验证自定义提示词文件(如果提供) if (customPromptPath) { try { await Validator.validateFile(customPromptPath); logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`); } catch (err) { throw new Error(`自定义提示词文件无效: ${err.message}`); } } if (extraRules) { logger.info("应用额外处理规则"); } const srcDir = await Validator.validateDirectory(validatedSrcLang); const tgtDir = path.resolve(validatedTgtLang); // 创建目标目录 try { await fs.mkdir(tgtDir, { recursive: true }); logger.info(`目标目录已准备: ${path.basename(tgtDir)}`); } catch (err) { throw new Error(`无法创建目标目录: ${err.message}`); } // 初始化 OpenAI 客户端 const openai = new AzureOpenAI({ endpoint: CONFIG.ENDPOINT, apiKey: validatedToken, apiVersion: CONFIG.API_VERSION, }); const translate = createTranslator( openai, validatedSrcLang, validatedTgtLang, customPromptPath, extraRules ); // 获取需要处理的文件 const files = (await fs.readdir(srcDir)).filter( (f) => f.endsWith(".json") && f !== "_config.json" ); if (files.length === 0) { logger.skip("源目录中没有找到可翻译的 JSON 文件"); return; } logger.start( `开始翻译 ${files.length} 个文件从 ${validatedSrcLang}${validatedTgtLang}` ); let totalTranslated = 0; let totalSkipped = 0; for (const file of files) { const srcPath = path.join(srcDir, file); const tgtPath = path.join(tgtDir, file); logger.file("read", srcPath); const srcObj = JSON.parse(await fs.readFile(srcPath, "utf8")); let tgtObj = {}; try { tgtObj = JSON.parse(await fs.readFile(tgtPath, "utf8")); } catch { logger.info(`目标文件不存在,将创建新文件: ${file}`); } const allKeys = collectKeys(srcObj); const todo = allKeys.filter( (k) => nestedUtils.get(tgtObj, k) === undefined || nestedUtils.get(tgtObj, k) === null ); if (!todo.length) { logger.skip(`${file} 已完成翻译`); totalSkipped++; continue; } logger.progress(`处理 ${file}: 需翻译 ${todo.length} 条目`); const chunks = chunkArray(todo, CONFIG.TRANSLATE_BATCH_SIZE); const progress = new ProgressTracker(logger, chunks.length); for (const keys of chunks) { const batch = {}; keys.forEach((k) => { batch[k] = nestedUtils.get(srcObj, k); }); const result = await translate(batch); const successCount = Object.values(result).filter( (v) => v !== null ).length; Object.entries(result).forEach(([k, v]) => nestedUtils.set(tgtObj, k, v)); logger.file("write", tgtPath); await fs.writeFile(tgtPath, JSON.stringify(tgtObj, null, 2)); progress.update(successCount === Object.keys(batch).length); totalTranslated += successCount; } progress.finish(); logger.success(`${file} 翻译完成`); } logger.stats("翻译任务完成统计", { 处理文件数: files.length, 跳过文件数: totalSkipped, 翻译条目数: totalTranslated, 源语言: validatedSrcLang, 目标语言: validatedTgtLang, }); } // ====================== 重试失败翻译函数 ====================== export async function retryFailedTranslations( srcLang, tgtLang, token, customPromptPath, extraRules ) { // 参数验证 const validatedToken = Validator.validateApiKey(token); const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言"); const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言"); // 验证自定义提示词文件(如果提供) if (customPromptPath) { try { await Validator.validateFile(customPromptPath); logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`); } catch (err) { throw new Error(`自定义提示词文件无效: ${err.message}`); } } if (extraRules) { logger.info("应用额外处理规则"); } const srcDir = await Validator.validateDirectory(validatedSrcLang); const tgtDir = await Validator.validateDirectory(validatedTgtLang); // 初始化 OpenAI 客户端 const openai = new AzureOpenAI({ endpoint: CONFIG.ENDPOINT, apiKey: validatedToken, apiVersion: CONFIG.API_VERSION, }); const translate = createTranslator( openai, validatedSrcLang, validatedTgtLang, customPromptPath, extraRules ); const files = (await fs.readdir(tgtDir)).filter( (f) => f.endsWith(".json") && f !== "_config.json" ); if (files.length === 0) { logger.skip("目标目录中没有找到可重试的 JSON 文件"); return; } logger.start( `开始重试失败的翻译任务 (${validatedSrcLang}${validatedTgtLang})` ); let totalRetried = 0; let filesProcessed = 0; for (const file of files) { const srcPath = path.join(srcDir, file); const tgtPath = path.join(tgtDir, file); try { logger.file("read", srcPath); const srcObj = JSON.parse(await fs.readFile(srcPath, "utf8")); let tgtObj = {}; try { tgtObj = JSON.parse(await fs.readFile(tgtPath, "utf8")); } catch { logger.warn(`目标文件读取失败,跳过: ${file}`); continue; } const allKeys = collectKeys(tgtObj); const failed = allKeys.filter((k) => nestedUtils.get(tgtObj, k) === null); if (!failed.length) { logger.skip(`${file} 无失败条目`); continue; } logger.progress(`处理 ${file}: 重试 ${failed.length} 条失败条目`); const chunks = chunkArray(failed, CONFIG.TRANSLATE_BATCH_SIZE); const progress = new ProgressTracker(logger, chunks.length); for (const keys of chunks) { const batch = {}; keys.forEach((k) => { batch[k] = nestedUtils.get(srcObj, k); }); const result = await translate(batch); const successCount = Object.values(result).filter( (v) => v !== null ).length; Object.entries(result).forEach(([k, v]) => nestedUtils.set(tgtObj, k, v) ); logger.file("write", tgtPath); await fs.writeFile(tgtPath, JSON.stringify(tgtObj, null, 2)); progress.update(successCount === Object.keys(batch).length); totalRetried += successCount; } progress.finish(); logger.success(`${file} 重试完成`); filesProcessed++; } catch (err) { logger.error(`处理文件 ${file} 时出错: ${err.message}`); await errorHandler.logError(err, { file, operation: "retry" }); } } logger.stats("重试任务完成统计", { 检查文件数: files.length, 处理文件数: filesProcessed, 重试成功条目: totalRetried, 源语言: validatedSrcLang, 目标语言: validatedTgtLang, }); } // ====================== 增量翻译函数 ====================== export async function incrementalTranslate( srcLang, tgtLang, token, customPromptPath, extraRules ) { // 参数验证 const validatedToken = Validator.validateApiKey(token); const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言"); const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言"); // 验证自定义提示词文件(如果提供) if (customPromptPath) { try { await Validator.validateFile(customPromptPath); logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`); } catch (err) { throw new Error(`自定义提示词文件无效: ${err.message}`); } } if (extraRules) { logger.info("应用额外处理规则"); } // 验证new和snapshot目录 const newDir = path.resolve("new", validatedSrcLang); const snapshotDir = path.resolve("snapshot", validatedSrcLang); const tgtDir = path.resolve("src", validatedTgtLang); // 默认翻译目标目录是src下的目标语言 try { await Validator.validateDirectory(newDir); } catch (err) { throw new Error(`new目录不存在或无法访问: ${newDir}`); } let snapshotExists = true; try { await Validator.validateDirectory(snapshotDir); } catch (err) { logger.warn(`snapshot目录不存在,将进行全量翻译: ${snapshotDir}`); snapshotExists = false; } // 创建目标目录 try { await fs.mkdir(tgtDir, { recursive: true }); logger.info(`翻译目标目录已准备: ${tgtDir}`); } catch (err) { throw new Error(`无法创建翻译目标目录: ${err.message}`); } // 初始化 OpenAI 客户端 const openai = new AzureOpenAI({ endpoint: CONFIG.ENDPOINT, apiKey: validatedToken, apiVersion: CONFIG.API_VERSION, }); const translate = createTranslator( openai, validatedSrcLang, validatedTgtLang, customPromptPath, extraRules ); // 获取新文件列表 const newFiles = (await fs.readdir(newDir)).filter( (f) => f.endsWith(".json") && f !== "_config.json" ); if (newFiles.length === 0) { logger.skip("new目录中没有找到可翻译的 JSON 文件"); return; } logger.start( `开始增量翻译 ${newFiles.length} 个文件从 ${validatedSrcLang}${validatedTgtLang}` ); let totalAdded = 0; let totalModified = 0; let totalDeleted = 0; let filesProcessed = 0; for (const file of newFiles) { const newPath = path.join(newDir, file); const snapshotPath = path.join(snapshotDir, file); const translationPath = path.join(tgtDir, file); // 翻译文件路径(src/目标语言/) logger.file("read", newPath); const newObj = JSON.parse(await fs.readFile(newPath, "utf8")); let snapshotObj = {}; let translationObj = {}; // 读取snapshot文件(如果存在) if (snapshotExists) { try { snapshotObj = JSON.parse(await fs.readFile(snapshotPath, "utf8")); } catch { logger.info(`snapshot文件不存在,视为新文件: ${file}`); } } // 读取翻译文件(如果存在) try { translationObj = JSON.parse(await fs.readFile(translationPath, "utf8")); } catch { logger.info(`翻译文件不存在,将创建新文件: ${file}`); } // 比较new和snapshot的差异 const { added, deleted, modified } = compareJsonObjects( newObj, snapshotObj ); if (added.length === 0 && deleted.length === 0 && modified.length === 0) { logger.skip(`${file} 无变化`); continue; } logger.progress( `处理 ${file}: 新增 ${added.length}, 修改 ${modified.length}, 删除 ${deleted.length}` ); // 处理删除的键 deleted.forEach((key) => { deleteNestedKey(translationObj, key); logger.info(`删除翻译条目: ${key}`); }); // 处理新增和修改的键 const toTranslate = [...added, ...modified]; if (toTranslate.length > 0) { const chunks = chunkArray(toTranslate, CONFIG.TRANSLATE_BATCH_SIZE); const progress = new ProgressTracker(logger, chunks.length); for (const keys of chunks) { const batch = {}; keys.forEach((k) => { batch[k] = nestedUtils.get(newObj, k); }); const result = await translate(batch); const successCount = Object.values(result).filter( (v) => v !== null ).length; Object.entries(result).forEach(([k, v]) => { if (v !== null) { nestedUtils.set(translationObj, k, v); } }); progress.update(successCount === Object.keys(batch).length); } progress.finish(); } // 保存更新后的翻译文件 logger.file("write", translationPath); await fs.writeFile( translationPath, JSON.stringify(translationObj, null, 2) ); // 🆕 更新snapshot文件夹 try { await fs.mkdir(path.dirname(snapshotPath), { recursive: true }); logger.file("update", `快照: ${snapshotPath}`); await fs.writeFile(snapshotPath, JSON.stringify(newObj, null, 2)); } catch (err) { logger.warn(`更新快照文件失败: ${err.message}`); } totalAdded += added.length; totalModified += modified.length; totalDeleted += deleted.length; filesProcessed++; logger.success(`${file} 增量翻译完成`); } logger.stats("增量翻译任务完成统计", { 处理文件数: filesProcessed, 新增翻译: totalAdded, 修改翻译: totalModified, 删除翻译: totalDeleted, 源语言: validatedSrcLang, 目标语言: validatedTgtLang, 已更新翻译目录: `src/${validatedTgtLang}/`, 已更新快照目录: `snapshot/${validatedSrcLang}/`, }); // 增量翻译成功后删除new目录 try { logger.info(`删除已处理的new目录: ${newDir}`); await fs.rm(newDir, { recursive: true, force: true }); logger.success(`已删除new目录: ${newDir}`); } catch (err) { logger.warn(`删除new目录失败: ${err.message}`); } } // ====================== 更新快照函数 ====================== export async function updateSnapshot(srcLang) { const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言"); const newDir = path.resolve("new", validatedSrcLang); const snapshotDir = path.resolve("snapshot", validatedSrcLang); try { await Validator.validateDirectory(newDir); } catch (err) { throw new Error(`new目录不存在或无法访问: ${newDir}`); } // 创建snapshot目录 try { await fs.mkdir(snapshotDir, { recursive: true }); logger.info(`快照目录已准备: ${snapshotDir}`); } catch (err) { throw new Error(`无法创建快照目录: ${err.message}`); } const files = (await fs.readdir(newDir)).filter( (f) => f.endsWith(".json") && f !== "_config.json" ); if (files.length === 0) { logger.skip("new目录中没有找到可复制的 JSON 文件"); return; } logger.start(`更新快照文件: ${files.length} 个文件`); for (const file of files) { const srcPath = path.join(newDir, file); const destPath = path.join(snapshotDir, file); logger.file("copy", `${srcPath}${destPath}`); const content = await fs.readFile(srcPath, "utf8"); await fs.writeFile(destPath, content); } logger.success(`快照更新完成: ${files.length} 个文件`); } // ====================== 导出命令函数 ====================== export async function translateCommand(options) { const { source, target, token, customPrompt, extraRules } = options; try { logger.start(`开始翻译任务`); // 解析 token(支持环境变量) const { TokenResolver } = await import("./utils.js"); const resolvedToken = TokenResolver.resolveToken(token); const tokenSource = TokenResolver.getTokenSource(token); logger.info(`Token 来源: ${tokenSource}`); await translateFiles(source, target, resolvedToken, customPrompt, extraRules); logger.finish("翻译任务完成!"); } catch (err) { logger.error(`翻译任务失败: ${err.message}`); await errorHandler.logError(err, { command: "translate", options }); process.exit(1); } } export async function retryFailedCommand(options) { const { source, target, token, customPrompt, extraRules } = options; try { logger.start(`开始重试任务`); // 解析 token(支持环境变量) const { TokenResolver } = await import("./utils.js"); const resolvedToken = TokenResolver.resolveToken(token); const tokenSource = TokenResolver.getTokenSource(token); logger.info(`Token 来源: ${tokenSource}`); await retryFailedTranslations( source, target, resolvedToken, customPrompt, extraRules ); logger.finish("重试任务完成!"); } catch (err) { logger.error(`重试任务失败: ${err.message}`); await errorHandler.logError(err, { command: "retry-failed", options }); process.exit(1); } } export async function incrementalTranslateCommand(options) { const { source, target, token, customPrompt, extraRules } = options; try { logger.start(`开始增量翻译任务`); // 解析 token(支持环境变量) const { TokenResolver } = await import("./utils.js"); const resolvedToken = TokenResolver.resolveToken(token); const tokenSource = TokenResolver.getTokenSource(token); logger.info(`Token 来源: ${tokenSource}`); await incrementalTranslate(source, target, resolvedToken, customPrompt, extraRules); logger.finish("增量翻译任务完成!"); } catch (err) { logger.error(`增量翻译任务失败: ${err.message}`); await errorHandler.logError(err, { command: "incremental-translate", options, }); process.exit(1); } } export async function updateSnapshotCommand(options) { const { source } = options; try { logger.start(`开始更新快照任务`); await updateSnapshot(source); logger.finish("快照更新任务完成!"); } catch (err) { logger.error(`快照更新任务失败: ${err.message}`); await errorHandler.logError(err, { command: "update-snapshot", options }); process.exit(1); } }